The blueprints/stripe-webhooks post tells you to handle events idempotently. It does not show you the loop that makes idempotency necessary, and without the loop the advice reads like defensive boilerplate rather than a fix for something that will actually happen to you.
The duplicate is not sent by an attacker. You cause it.

Reading the diagram
Three parties, and one question to hold onto: at each step, does Stripe believe the delivery worked?
Step 1. Stripe posts checkout.session.completed to your endpoint. The event carries an id, evt_1A here, and that id is about to be the only thing that saves you.
Step 2 is inside the red band, and it is where a reasonable-looking design goes wrong. Your handler charges the card and sends the receipt before it replies. Every one of those calls is a network round trip to somebody else's service. None of them is slow enough to worry about individually.
Step 3 is the whole point. Thirty seconds pass with no 200, so Stripe concludes the delivery failed and sends evt_1A again. From your endpoint's side this second request is indistinguishable from a customer buying a second time: same shape, same signature, valid, fresh. If your handler treats it as new, the card is charged twice and the receipt goes out twice.
Note what did not happen. Stripe was not confused, nothing was compromised, and no request was lost. The retry is Stripe's delivery guarantee working exactly as documented. The bug is that your handler spent the reply budget on fulfilment.
Steps 4 and 5 are the fix, and they are small. Before doing anything, ask the database whether this event id has been seen. It has, so this run does nothing.
Step 6 finally returns 200 and the loop stops.
Idempotency keyed on anything other than the event id is not idempotency. Deduplicating on customer plus amount plus a time window looks equivalent and is not: it will happily suppress a genuine second purchase from a customer who bought the same thing twice in a minute. The event id is the only field that distinguishes a redelivery from a repeat.
What this changes about how you build it
Reply first, fulfil after. Verify the signature, write the event id down, return 200, then do the work in a background job. This turns the red band from thirty seconds into a few milliseconds. It is the single change that prevents most duplicate-charge incidents.
Record the event before you process it, not after. If you write the id only on success, two copies arriving at once both find nothing recorded and both proceed. Insert the id first, with a unique constraint on it, and let the database reject the second one.
Make the recorded event requeueable. Once you reply 200 up front, Stripe will not retry for you, so a crash mid-fulfilment leaves an order half-done and nobody coming back to finish it. Store a status alongside the id rather than treating "recorded" as "done".
The source
---
title: "A slow reply is why the event arrives twice"
---
sequenceDiagram
autonumber
participant S as Stripe
participant W as Your webhook<br/>endpoint
participant D as Your database
S->>W: evt_1A, checkout.session.completed
rect rgb(254, 242, 242)
W->>D: charge the card, email the receipt
S->>W: 30 seconds, no 200 back.<br/>Stripe sends evt_1A again
end
W->>D: have I recorded evt_1A already?
D-->>W: yes. skip it
W-->>S: 200 OK
Note over S,D: the retry is normal delivery, not an attack.<br/>the event id is all that tells them apart.
Why does Stripe send the same webhook event twice?
Because it did not hear a 2xx response in time. Stripe treats a slow or failed reply as a delivery failure and retries with the identical event, same id and same payload. The usual cause is doing the work first and replying afterwards, so a slow database write or a third-party email call pushes you past the timeout on a delivery that actually succeeded.
How do I tell a retry apart from a real second purchase?
By the event id. A retry carries the same id as the original; a genuine second purchase generates a new one. Nothing else in the payload reliably separates them, which is why idempotency is keyed on the event id rather than on the customer, the amount, or a timestamp.
Should I reply 200 before doing the work?
Yes. Verify the signature, record the event id, return 200, and do the fulfilment after. That collapses the window in the diagram to a few milliseconds. The trade is that a crash after the 200 means Stripe will not retry for you, so the recorded event needs a status you can requeue from rather than being marked done on arrival.
What happens if I return an error on purpose?
Stripe retries with exponential backoff for up to about three days in live mode, then stops and flags the endpoint as failing. Returning an error is right when your own system is genuinely down and you want the redelivery. It is wrong for an event you simply cannot handle, because that one gets retried for days and can get the endpoint disabled.
Does idempotency still matter if my handler is fast?
Yes. Speed shrinks the window, it does not close it. Network partitions, a deploy landing mid-request, and at-least-once delivery all produce duplicates on endpoints that normally reply in 50 milliseconds. A recorded event id is a few lines of code and it is the thing that holds when the timing goes against you.
Check What Your Webhook Endpoint Exposes
Our scanner looks at your deployed app for the things this diagram implies: webhook routes reachable without signature verification, endpoints that leak stack traces on a bad payload, and secrets sitting in client bundles.