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 with the same symptom, and one handler has to survive all four — so the answer is a property of your write path, not a patch on whichever mechanism you noticed first.

Your endpoint committed the change, then timed out. You wrote the payment, then spent a few more seconds sending a receipt, and the provider gave up waiting before your 200 arrived. From its side the delivery failed. From yours it succeeded completely.

The provider retried after a genuine failure. A redeploy, an exhausted connection pool, a dependency down. Some of those failures happen after a partial write.

Somebody pressed resend. Support staff replay events from a provider dashboard while investigating a discrepancy. At your endpoint that is indistinguishable from a retry, 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 is a failure in the ordinary sense. The other three happen while everything works as designed, which is why “we will fix the timeouts” is not a strategy. When one gets through, one payment posts to the ledger twice, sends two confirmation emails, decrements stock twice, or fulfils a digital order twice. The email is harmless and immediately visible; the ledger entry is costly and silent until reconciliation.

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 — two workers, or a retry overlapping a slow first attempt — both read “absent” before either writes, and both proceed. A unique constraint on the deduplication key moves that decision into the database: 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. 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. It is not sufficient. Some providers emit more than one event object for a single underlying occurrence, each with its own event ID, and to that key they are two different events. Key on the identifier of the object the event is about together with the event type instead. The provider notes below record where each provider documents this.

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

One neighbouring hazard, named 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

A duplicate the provider actually sends reuses the exact same raw body and 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.

The scenario below delivers the same event twice the way a provider does: one immutable body and event ID, one fresh signature per attempt. Assert its two counts separately — two deliveries, one logical event. An integration that records two payment events because it received two deliveries has already lost.

What this scenario does not prove

The duplicate is delivered sequentially: the second request is sent only after the first completed and returned a success status. That is the common shape, and 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 check and both commit. A unique constraint survives that; “select, then insert if absent” does not, and this scenario will not tell you which one you have.

It does not demonstrate the two-event-objects hazard either. The key above uses the object identifier together with the event type, rather than the event ID alone, because a provider can emit two separate event objects — each with its own event ID — for one occurrence. This scenario emits one event per canonical transition, so it delivers one event ID twice and never two IDs for one payment. That half of the recommendation rests on the provider documentation cited below, not on the run.

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 capture success notification twice. Generated from the same scenario definition the extension runs — one panel per supported provider.

One event here is one signed HTTP POST to the localhost endpoint you configure in the extension, and its body is a single JSON object — the one your handler parses. The timeline below lists those requests in delivery order. Where a row shows more than one delivery, the identical body is posted that many times.

What this proves

Event timeline

Time from startEvent typeDeliveriesWhat your handler must do
+1000mspayment_intent.amount_capturable_updated1 HTTP POSTVerify the signature, then acknowledge with a 2xx response.
+2000mspayment_intent.succeeded2 HTTP POSTsVerify the signature on each of the 2 deliveries and acknowledge every one with a 2xx response; process the underlying change exactly once.

Keep the payloads as fixtures

These two files hold the exact request bodies the timeline above delivers. Run this scenario once, confirm the payloads are what your integration expects, then keep the file. Every broken-delivery scenario you run afterwards — a duplicate, a retry, a delivery arriving out of order — is a diff against that baseline rather than a payload you have to read from scratch.

Download JSON · Download YAML

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);

Updated 2026-08-03