Payments

Duplicate webhook delivery in payment integrations

Duplicate webhook delivery is normal rather than a provider defect: payment providers deliver at least once, so a correct handler makes the same event arriving twice change your data exactly once.

Add to Chrome Run it locally ↓

Why the same event arrives twice

Duplicate delivery is not one bug with one fix. It is four independent mechanisms that happen to produce the same symptom, and a handler has to survive all of them.

Your endpoint committed the change, then timed out. You wrote the payment to your database, then spent another few seconds sending a receipt, and the provider gave up waiting before your 200 arrived. From the provider’s side that delivery failed. From your side it succeeded completely. The retry is correct behaviour on both sides.

The provider retried after a genuine failure. Your service was redeploying, the database connection pool was exhausted, a dependency was down. Some of those failures happen after a partial write.

Somebody pressed resend. Support staff and engineers replay events from a provider dashboard when investigating a discrepancy. That replay is indistinguishable from a retry at your endpoint, and it can arrive days after the original.

Your consumer restarted mid-processing. With an at-least-once queue between the endpoint and the business logic, an unacknowledged message is redelivered after a crash — a duplicate your provider never sent and never sees.

Only the second of these is a failure in the ordinary sense. The other three happen while everything is working as designed, which is why “we will fix the timeouts” is not a strategy.

Four ways this shows up in production

Note how the severity ranking is not the visibility ranking. The email is harmless and obvious; the ledger entry is costly and silent.

Handling it correctly

The rule is short: deduplicate inside the same transaction that changes business state. Anything else leaves a window.

Use a unique constraint, not a read-then-write check. The intuitive implementation asks the database whether the event was already processed and inserts a record if it was not. Two deliveries handled at the same time — by two workers, or by a retry that overlaps a slow first attempt — both read “absent” before either writes, and both proceed. A unique constraint on the deduplication key moves the decision into the database, where exactly one writer wins and the other gets a violation it can safely treat as “already done”. Insert that row in the same transaction as the ledger entry; if they can commit separately, they eventually will.

Choose the key carefully. Logging the provider’s event ID is the obvious first move and it is correct for the plain case: the same event redelivered carries the same ID. But it is not sufficient on its own. Some providers emit more than one event object for a single underlying occurrence, each with its own event ID — deduplicating on the ID alone lets both through, because to the key they are different events. A key built from the identifier of the object the event is about, together with the event type, collapses those correctly. The provider panel below records where each provider documents this.

Return the success status before the slow work. Acknowledge, then process asynchronously. This shrinks the timeout-retry mechanism, which is the most common source of duplicates, though it does not remove the need for idempotency — it just stops you manufacturing extra duplicates yourself.

A related hazard is worth naming so it is not confused with this one: several different events can be generated for one business transition, and they are not guaranteed to arrive in the order they were created. Those are not duplicates and deduplication will not help; they need a handler that does not depend on arrival order.

Testing it, and why a naive test passes

This is where most integrations get false confidence.

A duplicate delivery that a provider actually sends reuses the exact same raw body and the same event ID, and carries a freshly computed, independently valid signature with its own timestamp. Both properties matter, and a test that gets either one wrong is worse than no test, because it reports success.

Two common ways to get it wrong:

The scenario below delivers the same event twice the way a provider does: one immutable body and event ID, one fresh signature per attempt.

What this scenario does not prove

The duplicate is delivered sequentially — the second request is sent only after the first has completed and returned a success status. That is the common real-world shape: a retry after your handler timed out arrives seconds or minutes later, not concurrently. It is not the hard case.

Two deliveries processed in parallel, by two workers or by a retry overlapping a slow handler, can both pass a read-then-write duplicate check and both commit. A deduplication built on a unique constraint survives that; one built on “select, then insert if absent” does not, and this scenario will not tell you which one you have.

One delivery count, one event count

The invariant worth carrying into your own tests: duplicated delivery must not create a second logical event. Delivery count and event count are separate assertions and should be asserted separately. An integration that records two payment events because it received two deliveries has already lost — the downstream damage is just a matter of which system reads that record first.

What goes wrong

Processing one payment notification twice double-posts the ledger entry, so your books disagree with the provider settlement report and the gap only surfaces during reconciliation.

Provider notes

stripe

  • Stripe states that a webhook endpoint might receive the same event more than once, and suggests guarding against it by logging the event IDs already processed. — https://docs.stripe.com/webhooks#handle-duplicate-events
  • Stripe warns that in some cases two separate Event objects are generated and sent for the same occurrence, and recommends identifying those duplicates by the ID of the object in data.object together with the event type. — https://docs.stripe.com/webhooks#handle-duplicate-events
  • Stripe retries delivery with exponential backoff for up to three days in live mode; event deliveries created in a sandbox are retried three times over a few hours. — https://docs.stripe.com/webhooks#automatic-retries
  • An event can be resent by hand: from the Dashboard for up to 15 days after the event was created, or with the Stripe CLI for up to 30 days. — https://docs.stripe.com/webhooks#manual-retries
  • A manual resend does not dismiss Stripe automatic retry behaviour, even when the resend returns a 2xx status code. — https://docs.stripe.com/webhooks#manual-retries
  • Stripe does not guarantee that events are delivered in the order they were generated. — https://docs.stripe.com/webhooks#event-ordering

See it running

Reuse the successful-payment flow and deliver its logical payment success event twice. Generated from the same scenario definition the extension runs — one panel per supported provider.

What this proves

Event timeline

TimeEventDeliveredExpected receiver behavior
+1000mspayment_intent.amount_capturable_updatedVerify the signature, then acknowledge with a 2xx response.
+2000mspayment_intent.succeededVerify the signature on each of the 2 deliveries and acknowledge every one with a 2xx response; process the underlying change exactly once.

Verify the signature

const event = stripe.webhooks.constructEvent(
  requestBody, // the raw, unparsed request body
  request.headers['stripe-signature'],
  endpointSecret,
);
// Verified against the Stripe API version this scenario targets: 2026-04-22.dahlia.

Handle the webhook

switch (event.type) {
  case 'payment_intent.amount_capturable_updated':
    // Handle payment_intent.amount_capturable_updated.
    break;
  case 'payment_intent.succeeded':
    // Handle payment_intent.succeeded.
    break;
  default:
    // Unhandled event type: acknowledge it anyway to stop redelivery.
    break;
}
response.sendStatus(200);

Download the exact payloads for this run: JSON · YAML

Updated 2026-08-01