Company
About Tomosu
Platform
Platform & Agents Indexes How it works Solutions Pricing
Get Started
MCP Server VS Code — Plugin Installation Scan Your Repo — Guide Integrations · GitHub App Integrations · CodeRabbit MCP FAQ
Free Tools
Governance Impact
Resources
Blogs News Download / Free Trial Book a call →
Production Debugging · Webhooks

How to Prevent Duplicate Webhook Processing and Duplicate Side Effects

Tomosu AI·15 min read·

Duplicate webhook events are not a provider bug. Webhooks are delivered at least once, so the same event will eventually reach your handler twice. The question is whether the second delivery ships a second parcel, sends a second transfer, and emails the customer again, or whether it becomes a harmless no-op.

Quick answer

To prevent duplicate webhook processing, make the first thing your handler does a write that can only succeed once: insert the provider’s event id into a table with a unique constraint, and only the delivery whose insert succeeds does the work. Then make every side effect idempotent on its own.

Most duplicate side effects come from a handler that looks correct in review. It verifies the signature, checks whether the event was seen, does the work, and records the event id. Every step is present, but they run in the wrong order and without a constraint, so two concurrent deliveries both pass the check. This guide covers why providers retry, where the idempotency boundary has to sit, and what an idempotent webhook handler looks like in code and SQL.

Why do duplicate webhook events happen?

A webhook provider cannot know whether your handler did the work. It only knows whether it received a successful HTTP response in time. When it does not, it has two choices: drop the event and risk you never learn a payment succeeded, or send it again and risk you process it twice. Providers that retry choose the second, which makes delivery at least once. Exactly-once delivery over an unreliable network is not on offer from anyone.

The situations that produce a second delivery of an event you already processed:

ONE EVENT, TWO DELIVERIES, EVERY EFFECT TWICE Webhook provider Your handler Warehouse, payments POST evt_1 · attempt 1 create shipment → 201 transfer to seller → ok sending receipt email (slow SMTP call) No 2xx within timeout delivery marked failed 200 OK, too late POST evt_1 · attempt 2 create shipment → 201 (2nd) transfer to seller (2nd) Same event id on both deliveries. Nothing in the handler used it before doing the work.
The provider behaved correctly. The handler did the work before it made sure the work had not been done already.

Retry schedules differ by provider, and they are long enough that you cannot treat a duplicate as a rare edge case. The dedupe key differs too. These are the behaviors documented by the providers most teams integrate first:

ProviderDedupe keyRetry behaviorWhat to design for
StripeEvent id (evt_...)Automatic retries for up to three days with exponential backoff in live mode; manual resend from the Dashboard or CLIOrder is not guaranteed, and occasionally two Event objects describe one change, so also dedupe on data.object.id plus type
GitHubX-GitHub-Delivery headerExpects a 2xx within 10 seconds; failed deliveries are not redelivered automatically, you redeliver themThe delivery GUID stays the same on redelivery, so it is a usable dedupe key
Standard Webhookswebhook-id headerThe spec recommends exponential backoff with jitter over multiple daysThe id stays the same across retries of the same message

Check your own provider’s documentation for its id field and retry window. The design below works regardless of the numbers.

Developer questions about duplicate webhook events on Stack Overflow tend to ask how to stop the provider from sending duplicates. You cannot, and you do not want to: the retries are what save you when your endpoint is down. The fix belongs in your handler.

Why checking the event id first is not enough

The first idempotent webhook handler most people write looks up the event id, returns early if it exists, does the work, and then records the id. Stripe’s own guidance, “log the event IDs you’ve processed, and then not process already-logged events,” reads naturally as exactly that. It handles the case where the duplicate arrives an hour later. It fails in the case that actually produces duplicates: a retry that arrives while the first delivery is still running, because the first delivery was slow. That is the timeout scenario from the diagram above.

TWO CONCURRENT DELIVERIES OF THE SAME EVENT BEFORE · CHECK, ACT, THEN RECORD DELIVERY A · ATTEMPT 1DELIVERY B · RETRY t1t2t3 SELECT evt_1 → 0 rows SELECT evt_1 → 0 rows ship + transfer + email ship + transfer + email INSERT evt_1 → ok INSERT evt_1 → ok (no constraint) Both deliveries passed the check before either recorded the event. Every side effect ran twice. AFTER · INSERT FIRST UNDER A UNIQUE CONSTRAINT DELIVERY A · ATTEMPT 1DELIVERY B · RETRY t1t2t3t4 INSERT evt_1 ON CONFLICT → 1 row INSERT evt_1 ON CONFLICT → waits UPDATE orders; INSERT outbox blocked on A's uncommitted row COMMIT → return 200 A committed → 0 rows returned duplicate: skip, return 200 The unique index serializes the two deliveries. Exactly one transaction does the work.
A SELECT cannot see a row another transaction has not written yet. A unique index can, and it makes the second writer wait for the first.

Two details make insert-first work, and both are easy to get wrong:

  1. The constraint has to exist in the database. A processed_events table without a primary key or unique index on the event id gives you a log, not a guard. The race is closed by the index, not by application code.
  2. The insert has to happen before the work, in the same transaction as the work’s local writes. In PostgreSQL, when two transactions insert the same key into a unique index, the second one waits until the first commits or rolls back. If the first commits, ON CONFLICT DO NOTHING inserts nothing and the second learns it is a duplicate. If the first rolls back, the second proceeds and does the work. That is exactly the behavior you want when the first attempt crashes halfway.
Recording the id first is not the same as recording it safely

Some handlers fix the race by inserting the event id in its own autocommitted statement and then doing the work. That closes the race and opens a worse hole: if the process dies after the insert and before the work, the retry is rejected as a duplicate and the event is never processed. Either commit the event id together with the state change, or give the row a status (pending, processing, done) and process it from there.

What an idempotent webhook handler looks like

There are two workable shapes. Choose based on what the event causes.

Shape 1: the event only changes your own database

If processing is a handful of writes to your own tables, do it synchronously in one transaction that starts with the dedupe insert. The event record and the state change commit together or not at all.

SQL · one transaction per deliverydedupe and state commit together
CREATE TABLE processed_events (
  provider     text        NOT NULL,
  event_id     text        NOT NULL,
  processed_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (provider, event_id)            -- the guard lives here
);

BEGIN;
INSERT INTO processed_events (provider, event_id)
VALUES ('stripe', $1)
ON CONFLICT DO NOTHING
RETURNING event_id;
-- no row returned: duplicate delivery. COMMIT and respond 200.

UPDATE orders SET status = 'paid', paid_at = now()
WHERE  id = $2 AND status = 'pending_payment';   -- guarded transition
COMMIT;

This keeps a transaction open only for a few short statements. Do not stretch it around an HTTP call to another service: that holds a database connection for the length of the call, which is how a slow dependency turns into connection pool exhaustion.

Shape 2: the event causes external side effects

Charging, refunding, transferring money, creating a shipment, and sending an email cannot be rolled back by your database transaction. For these, split the handler in two. The receiver verifies the signature, stores the event in an inbox table under a unique constraint, and acknowledges. A worker processes the inbox. This is the pattern both Stripe and GitHub recommend when they tell you to respond quickly and process asynchronously.

ACK FAST, DEDUPE ON INSERT, KEY EVERY EFFECT Provider at least once Receiver 1 verify signature 2 INSERT into inbox 3 return 200 webhook_inbox unique (provider, event_id) Worker claim: SKIP LOCKED guarded state change retry with backoff Warehouse API key shipment:ord_42 Stripe transfer key transfer:ord_42 Receipt email effect row receipt:ord_42 Receiver fails: the provider retries. Worker fails: the row is retried. Each effect dedupes on its own key.
Three layers of dedupe: the inbox constraint stops duplicate deliveries, and per-effect keys stop duplicate effects when the worker itself retries.
SQL · migration
CREATE TABLE webhook_inbox (
  id           bigserial   PRIMARY KEY,
  provider     text        NOT NULL,
  event_id     text        NOT NULL,
  event_type   text        NOT NULL,
  payload      jsonb       NOT NULL,
  -- status: pending | processing | done | failed
  status       text        NOT NULL DEFAULT 'pending',
  attempts     int         NOT NULL DEFAULT 0,
  locked_until timestamptz,
  received_at  timestamptz NOT NULL DEFAULT now(),
  UNIQUE (provider, event_id)
);

CREATE TABLE side_effects (
  effect_key text PRIMARY KEY,                  -- e.g. 'receipt:ord_42'
  created_at timestamptz NOT NULL DEFAULT now()
);
TypeScript · receiver (Express, stripe-node, pg)insert first, ack fast
const raw = express.raw({ type: 'application/json' });

app.post('/webhooks/stripe', raw, async (req, res) => {
  let event: Stripe.Event;
  try {
    // Raw body, not parsed JSON: the signature covers the exact bytes.
    const sig = req.headers['stripe-signature'] as string;
    event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
  } catch {
    return res.sendStatus(400);   // unsigned or tampered: record nothing
  }

  try {
    await db.query(
      `INSERT INTO webhook_inbox (provider, event_id, event_type, payload)
       VALUES ('stripe', $1, $2, $3)
       ON CONFLICT (provider, event_id) DO NOTHING`,
      [event.id, event.type, event]);
  } catch (err) {
    return res.sendStatus(500);   // not stored: let the provider retry
  }
  res.sendStatus(200);            // stored now or earlier: acknowledge
});

Note what the receiver does not do: it does not look at the order, call any API, or branch on whether the insert found a conflict. A duplicate and a first delivery get the same fast 200. The only way to get a non-2xx is to fail to store the event, which is exactly when you want the provider to try again.

TypeScript · workerclaim, guard, key each effect
async function processNext(): Promise<boolean> {
  const { rows: [job] } = await db.query(`
    UPDATE webhook_inbox
       SET status = 'processing', attempts = attempts + 1,
           locked_until = now() + interval '5 minutes'
     WHERE id = (SELECT id FROM webhook_inbox
                  WHERE status = 'pending'
                     OR (status = 'processing' AND locked_until < now())
                  ORDER BY id
                  FOR UPDATE SKIP LOCKED
                  LIMIT 1)
    RETURNING *`);
  if (!job) return false;

  const session = job.payload.data.object as Stripe.Checkout.Session;
  const order = await orders.findByCheckoutSession(session.id);

  // Local state: a guarded transition is idempotent by construction.
  await db.query(
    `UPDATE orders SET status = 'paid'
      WHERE id = $1 AND status = 'pending_payment'`, [order.id]);

  // External effects: each key names the business fact, not the delivery.
  await warehouse.createShipment(order,
    { idempotencyKey: `shipment:${order.id}` });
  await stripe.transfers.create(
    { amount: order.sellerAmount, currency: 'usd',
      destination: order.sellerAccountId, transfer_group: `order_${order.id}` },
    { idempotencyKey: `transfer:${order.id}` });
  await sendOnce(`receipt:${order.id}`, () => mailer.sendReceipt(order));

  await db.query(
    `UPDATE webhook_inbox SET status = 'done' WHERE id = $1`, [job.id]);
  return true;
}

The claim query uses FOR UPDATE SKIP LOCKED so several workers can drain the inbox without picking the same row, and locked_until lets another worker reclaim a row whose worker died mid-flight. Add a maximum attempt count that moves a row to failed and alerts; an event that fails forever should page someone rather than retry silently for days. The claim runs in its own short statement, so no transaction or connection is held while the worker waits on the warehouse or Stripe.

Because a reclaimed row runs the whole function again, every step after the claim must be safe to repeat. That is the job of the next section.

How do you stop a webhook retry from causing a duplicate payment?

The inbox stops duplicate deliveries. It cannot stop the worker from repeating an external call if the worker crashes between the call and marking the row done. A webhook retry duplicate payment almost always happens at this second layer: the dedupe was on the event, but the money movement had no key of its own.

Give every outgoing call that creates or moves something an idempotency key:

TypeScript · effect with no downstream key
async function sendOnce(effectKey: string, send: () => Promise<void>) {
  const { rowCount } = await db.query(
    `INSERT INTO side_effects (effect_key) VALUES ($1)
     ON CONFLICT DO NOTHING`, [effectKey]);
  if (rowCount === 0) return;     // already sent, or attempted
  await send();                    // at-most-once: a crash here loses this send
}
Key the effect on the business fact, not the delivery

It is tempting to use transfer:${event.id}. Keying on order.id is stronger. If the provider emits two distinct events for the same change, or two different event types (a checkout completion and a payment success) both trigger fulfillment, an event-scoped key lets both through. The thing that must happen once is “pay the seller for order 42”, so that is what the key should name.

Also check the downstream key’s lifetime. Stripe may prune idempotency keys once they are at least 24 hours old, and a reused key after pruning is treated as a new request. A worker retrying a stuck row on day two is outside that window, so a local record of the effect (a transfer_id column on the order) is still worth having.

Crash pointWithout keysWith the inbox onlyWith inbox and effect keys
Before the inbox insert commitsRetry processes normallyRetry processes normallyRetry processes normally
Response lost after insertRetry repeats everythingRetry is a no-opRetry is a no-op
Worker dies after transfer, before doneSecond transferSecond transfer on reclaimSame key returns the original transfer
Provider emits two Event objectsTwo transfersTwo transfers (different ids)Keyed on order: one transfer

What about webhooks that arrive out of order?

Retries reorder events. If event 1 fails its first delivery and is retried a minute later, event 2 arrives first. Stripe says plainly that it does not guarantee delivery order, and warns that its created timestamp has one second resolution, so it cannot be used to order events or to detect duplicates either.

RETRIES REORDER EVENTS ORDER GENERATED BY PROVIDER 1 payment_intent.payment_failed 2 payment_intent.succeeded ORDER RECEIVED BY HANDLER 1 payment_intent.succeeded 2 payment_intent.payment_failed LAST WRITE WINS SET status = event's status Final: payment_failed The customer paid. Fulfillment stops. GUARDED TRANSITION failed only from pending_payment Final: paid The stale event matches no row.
Deduplication does not help here: these are two different events. The state machine has to reject the stale one.

Three techniques, usually combined:

How long to keep processed event ids

Longer than the longest window in which a duplicate can arrive. For Stripe that is the automatic retry period of up to three days plus manual resends, which its docs allow for 15 days from the Dashboard and 30 days from the CLI. A retention of 30 days or more with a scheduled cleanup is a reasonable default. A short TTL cache in front of the table is fine for load, but the database constraint should remain the source of truth.

Where signature verification fits

Verify the signature first, against the raw request body, and reject failures before recording anything. Signature checks stop forged and tampered requests, and the timestamp tolerance limits replay of old captures. They do nothing about duplicates: a legitimate retry is correctly signed. Stripe generates a new signature and timestamp for each delivery attempt, which is why you cannot dedupe on the signature header either.

Reviewing a PR that adds a webhook handler

Here is a PR of the kind that passes review in most teams. It adds a Stripe checkout.session.completed handler for a marketplace: ship the order, pay the seller, email a receipt. It has a signature check, it has a dedupe check, and it has tests that send an event once and assert the order ships.

PR: add checkout webhook · handler.tsduplicate side effects
app.post('/webhooks/stripe', raw, async (req, res) => {
  let event: Stripe.Event;
  try {
    const sig = req.headers['stripe-signature'] as string;
    event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
  } catch { return res.sendStatus(400); }
  if (event.type !== 'checkout.session.completed') return res.sendStatus(200);

  const seen = await db.query(
    'SELECT 1 FROM processed_events WHERE event_id = $1', [event.id]);
  if (seen.rowCount) return res.sendStatus(200);    // check ...

  const session = event.data.object as Stripe.Checkout.Session;
  const order = await orders.findByCheckoutSession(session.id);
  await warehouse.createShipment(order);                // no key
  await stripe.transfers.create({                      // moves money, no key
    amount: order.sellerAmount, currency: 'usd',
    destination: order.sellerAccountId });
  await mailer.sendReceipt(order);                      // slow: can exceed timeout
  await db.query('UPDATE orders SET status = $2 WHERE id = $1',
    [order.id, 'paid']);                                // unguarded

  await db.query('INSERT INTO processed_events (event_id) VALUES ($1)',
    [event.id]);                                        // ... then act
  res.sendStatus(200);
});

// migration.sql
// CREATE TABLE processed_events (event_id text, processed_at timestamptz);
// no primary key, no unique index

Each line is reasonable in isolation. The problem is where the idempotency boundary sits relative to the side effects:

WHERE THE IDEMPOTENCY BOUNDARY SITS PR AS WRITTEN IDEMPOTENT VERSION Verify signature SELECT: has evt_1 been seen? Create shipment (warehouse API) Transfer to seller (Stripe) Send receipt email UPDATE order; INSERT id Return 200 after all of it Verify signature (raw body) INSERT ... ON CONFLICT Return 200 immediately Worker claims row (SKIP LOCKED) Guarded UPDATE of order state Effects with idempotency keys Mark inbox row done 1234567 1234567 TOO LATE BOUNDARY Everything above the boundary can run twice, and three of those steps move goods or money. The first write is the dedupe. Every effect after it carries its own key.
Same steps, different order. The review question is not “is there a dedupe check?” but “what can run before the first write that only succeeds once?”

The review findings, in order of consequence:

  1. Check-then-act with no constraint. The SELECT cannot see a concurrent delivery, and the migration has no unique index, so even the late INSERT cannot fail on a duplicate.
  2. Side effects before the boundary. The transfer moves money on every delivery that passes the check. This is the webhook retry duplicate payment, waiting for the first slow SMTP response.
  3. Synchronous work that invites the retry. Three network calls inside the request make a provider timeout likely, and the timeout is what produces the concurrent duplicate.
  4. No idempotency keys on outgoing calls. Even after the boundary is fixed, a worker retry would repeat the transfer and the shipment.
  5. Unguarded state update. A stale or reordered event can overwrite paid.
  6. Tests that deliver once. None of this is visible until a test sends the same event twice, concurrently.

A useful test to require in the PR: fire the same signed payload at the endpoint from two concurrent requests, then assert one shipment, one transfer, and one email. It fails against the original handler and passes against the inbox version. For how this fits a wider review routine, see Pre Merge Reliability Analysis and How to Identify High Risk Pull Requests.

An idempotent handler is not one that checks for duplicates. It is one where nothing irreversible happens before a write that can only succeed once.

How Tomosu helps

The PR above is hard to catch in line-by-line review because every individual line is defensible. The risk is in the ordering and in what the called functions do: createShipment and transfers.create look like any other await. Tomosu analyzes the change against the code paths it touches and flags this class of problem before merge:

These findings feed the Production Reliability Index, so a webhook PR with a missing idempotency boundary shows up as elevated risk at review time rather than as a double payout in the next retry storm.

Scan your repository with Tomosu →

Key takeaways

Frequently asked questions

Why do I receive duplicate webhook events?

Webhook providers deliver at least once. If your endpoint does not return a 2xx response in time, returns an error after partly processing the event, or the response is lost on the network, the provider retries with the same event id. Operators can also resend events manually, and some providers occasionally emit two separate events for one change. Duplicates are expected behavior, not a provider bug.

How do I make a webhook handler idempotent?

Make the first write one that can only succeed once: insert the provider’s event id into a table with a unique constraint using INSERT ... ON CONFLICT DO NOTHING, and only the delivery whose insert succeeds does the work. Commit that insert together with your state changes, or store the event with a status and process it from a queue. Then give every external side effect its own idempotency key.

Is checking whether the event id exists before processing enough?

No. A SELECT followed by processing and then an INSERT is a check-then-act race. Two concurrent deliveries, which is what a provider timeout produces, can both see no row and both run the side effects. The dedupe must be enforced by a unique constraint and the insert must happen before the work.

Should I return 200 before processing the webhook?

Return 2xx after the event is durably stored, not after it is fully processed. Verify the signature, insert the event into an inbox table, respond, and process it asynchronously. Return a non-2xx only when you failed to store the event, so the provider retries in exactly the case where you need it to.

How do I prevent a duplicate payment when a webhook is retried?

Deduplicate the delivery with a unique event id, and also send an idempotency key on the payment call itself, for example the Idempotency-Key header on Stripe POST requests. Derive the key from the business fact, such as the order id, rather than from the event id, and keep a local record of the created payment in case the retry happens after the provider has pruned the key.

How do I handle webhooks that arrive out of order?

Do not rely on delivery order or on event timestamps. Write status changes as guarded transitions that only apply from allowed previous states, never overwrite terminal states such as paid or refunded, and when in doubt retrieve the object’s current state from the provider’s API before acting.

Does webhook signature verification prevent duplicate processing?

No. Signature verification proves the request came from the provider and was not modified, and a timestamp tolerance limits replay of old requests. A legitimate retry is correctly signed, and providers such as Stripe generate a new signature for each delivery attempt, so you still need event id deduplication.

How long should I store processed webhook event ids?

Longer than the longest window in which a duplicate can arrive, including the provider’s automatic retry period and any manual resend window. For Stripe that means at least several days of automatic retries plus manual resends allowed for weeks, so 30 days or more with scheduled cleanup is a reasonable default.


A duplicate charge is rarely a missing check. It is a side effect that ran before the one write that could only succeed once. Tomosu maps where that write sits in every handler you ship. Assess your repository →