DEV Community

Mukesh
Mukesh

Posted on

Idempotent Webhook Consumers for Strapi: Handling At-Least-Once Delivery Without a Message Queue

The webhook fires twice, and nobody notices until the invoice is wrong

Strapi ships webhooks for a reason: when an entry is created, updated, published, or deleted, you want to react — reindex it in Algolia, push it to a static site build, notify a Slack channel, charge a customer when an order entry flips to paid. The webhook config screen makes this look like a simple, reliable event stream. It isn't, and the gap between "looks reliable" and "is reliable" is exactly where production bugs live.

Strapi's webhook delivery is at-least-once, not exactly-once. If your receiving endpoint times out, returns a 5xx, or the connection drops after your handler already ran but before the response made it back, Strapi (like effectively every webhook sender) retries. Your endpoint runs again with the same event. If that handler increments a counter, sends an email, or calls a billing API, it does that a second time. In staging this never shows up, because staging doesn't have flaky network hops, cold-starting serverless functions, or a reverse proxy timing out under load — production has all three.

The fix isn't "make the webhook exactly-once" — no sender can promise that, Strapi included. The fix is making your consumer idempotent: safe to invoke twice with the same event and get the same result as invoking it once. This article builds that dedupe layer using only Strapi's existing database, so you don't need to stand up Redis or a queue just to stop double-charging a customer.

What a duplicate actually looks like

Strapi's webhook payload includes an event name (entry.update, entry.publish, etc.) and a model, plus the entry itself. Critically, it does not include a stable, unique delivery ID you can dedupe on out of the box — you have to construct one. A naive approach is to key on entry.id alone, but that collapses two different legitimate updates to the same entry into one, silently dropping a real event. You need a key that identifies this specific delivery, not just this entry.

The combination that works reliably: model + entry.id + entry.updatedAt. Strapi bumps updatedAt on every write, so a genuine second update produces a new key, while a retried delivery of the same update produces an identical one. If your content type doesn't expose updatedAt on the webhook payload (some custom fields configs strip timestamps), fall back to hashing the full payload body with SHA-256 — slightly heavier, but it degrades gracefully.

Building the dedupe store on top of Strapi's own database

Most Strapi projects already run on Postgres, MySQL, or SQLite. Rather than adding Redis purely to hold a set of "seen" keys, create a small table in the same database and query it directly through Strapi's query engine.

Add a migration (or a bootstrap-time table creation in src/index.js for SQLite-based dev setups):

CREATE TABLE IF NOT EXISTS webhook_deliveries (
  delivery_key TEXT PRIMARY KEY,
  received_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Enter fullscreen mode Exit fullscreen mode

A primary key on delivery_key is the whole trick: the database itself rejects a duplicate insert, so you don't need application-level locking or a read-then-write race condition. Two concurrent retries hitting your endpoint at the same instant both attempt the insert; exactly one wins, the other gets a constraint violation, and you treat that violation as "already handled" rather than an error.

// src/api/webhook-inbox/services/dedupe.js
const crypto = require('crypto');

function buildDeliveryKey(payload) {
  const { model, entry, event } = payload;
  const stable = entry?.updatedAt
    ? `${model}:${entry.id}:${entry.updatedAt}`
    : crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
  return `${event}:${stable}`;
}

async function claimDelivery(payload) {
  const key = buildDeliveryKey(payload);
  const knex = strapi.db.connection;

  try {
    // Postgres/SQLite: ON CONFLICT DO NOTHING, returning affected rows
    const inserted = await knex('webhook_deliveries')
      .insert({ delivery_key: key })
      .onConflict('delivery_key')
      .ignore();

    // knex reports 0 affected rows on a MySQL-style conflict skip too,
    // so treat "0 rows" as "already claimed" rather than throwing.
    const claimed = inserted && inserted.rowCount !== 0;
    return { key, claimed: claimed !== false };
  } catch (err) {
    strapi.log.error('webhook dedupe insert failed', err);
    // Fail closed: if we can't prove this is new, don't double-run side effects.
    return { key, claimed: false };
  }
}

module.exports = { buildDeliveryKey, claimAndCheck: claimDelivery };
Enter fullscreen mode Exit fullscreen mode

The onConflict(...).ignore() call is the load-bearing line. It works across SQLite (INSERT OR IGNORE) and Postgres (ON CONFLICT DO NOTHING) through knex's query builder, which is what Strapi uses internally — you're not adding a new dependency, just a new table.

Wiring it into a receiving route

Strapi webhooks call an external URL, so "the receiver" is usually a route in the same Strapi instance (for internal automations) or a separate service. Either way, the pattern is the same: claim the delivery before running side effects, and bail out early if the claim fails.

// src/api/webhook-inbox/controllers/inbox.js
const { claimAndCheck } = require('../services/dedupe');

module.exports = {
  async receive(ctx) {
    const payload = ctx.request.body;
    const { claimed } = await claimAndCheck(payload);

    if (!claimed) {
      strapi.log.info(`Duplicate webhook delivery ignored: ${payload.event}`);
      ctx.send({ status: 'duplicate-ignored' }, 200); // 200, not an error
      return;
    }

    // Real side effects only run for a newly-claimed delivery.
    await runSideEffects(payload);
    ctx.send({ status: 'processed' }, 200);
  },
};
Enter fullscreen mode Exit fullscreen mode

Returning 200 for duplicates matters as much as the dedupe logic itself. If you return an error status, most webhook senders — Strapi included — interpret that as "delivery failed" and schedule another retry, which just recreates the loop you're trying to break. A duplicate isn't a failure; it's a no-op success.

Handling out-of-order delivery

Retries aren't always sequential. A slow retry can arrive after a newer, unrelated update to the same entry has already been processed. Because the delivery key includes updatedAt, this mostly self-resolves: the stale retry carries an old timestamp, produces a different key than the newer event, and gets processed as if new — which is usually fine for reindexing use cases, but wrong for anything ordering-sensitive, like a status transition (draft → review → published).

For ordering-sensitive consumers, add a second guard: track the last-processed updatedAt per entry and skip any incoming payload whose timestamp is older than what you've already applied.

async function isStale(model, entryId, updatedAt) {
  const last = await strapi.db.connection('webhook_last_applied')
    .where({ model, entry_id: entryId })
    .first();
  return last && new Date(updatedAt) <= new Date(last.updated_at);
}
Enter fullscreen mode Exit fullscreen mode

Run this check after the dedupe claim, not instead of it — dedupe stops exact duplicates, staleness checking stops out-of-order ones, and you need both for a handler that mutates external state based on entry status.

Testing it without waiting for a real retry

You don't need to simulate a flaky network to test this — just POST the same payload to your route twice in a row and assert the second response is duplicate-ignored while your side-effect mock (e.g. a spied email-sender function) was called exactly once:

const payload = { event: 'entry.update', model: 'api::order.order',
  entry: { id: 42, updatedAt: '2026-07-30T10:00:00.000Z' } };

await request(app).post('/webhooks/inbox').send(payload); // processed
const res = await request(app).post('/webhooks/inbox').send(payload); // duplicate
expect(res.body.status).toBe('duplicate-ignored');
expect(sendEmailMock).toHaveBeenCalledTimes(1);
Enter fullscreen mode Exit fullscreen mode

That one test catches the exact bug this article opened with — the double-fired email or the double-charged invoice — before it ships.

The checklist

  • Build a delivery key from model + entry.id + updatedAt, falling back to a payload hash when timestamps aren't available.
  • Claim the delivery with a unique-constraint insert (ON CONFLICT DO NOTHING) before running any side effect — let the database resolve the race, don't hand-roll a check-then-act.
  • Return 200 for duplicates, never an error status, or you'll trigger the sender's retry logic and manufacture more duplicates.
  • If ordering matters for a given consumer, add a last_applied timestamp guard on top of dedupe — they solve different problems.
  • Write the two-POSTs-same-payload test first; it's the cheapest possible proof the dedupe layer actually works.

None of this requires a new service. It's one table, one constraint, and about forty lines of code sitting on infrastructure your Strapi project already has.

Top comments (0)