Here's an endpoint that passes every manual test you'd think to run:

Click Pay once on a good connection and it works. (The card number never reaches this server; Stripe's hosted fields collect it, which keeps this code out of PCI DSS scope. Everything below is about the charge, not the card.)
One payment, charged twice
The client clicks Pay once. The charge succeeds, and the response gets lost on the way back: a proxy timeout, a phone dropping signal.
- Client to API: POST /payments
- API to Provider: charge $20
- Provider to API: succeeded
- API to Client: connection drops
- Client to API: POST /payments (retry)
- API to Provider: charge $20 again
- Provider to API: succeeded
Every line of code did what it was written to do, and the card was charged twice. The same shape shows up from three directions:
| Trigger | Who repeats the call | Result |
|---|---|---|
| Double-click | the user | two charges |
| Retry after a timeout | the client, correctly | two charges |
| Webhook redelivered | the provider, by design | order fulfilled twice |
None of these are exotic. They're the default behaviour of people, networks and webhook providers. Disabling the Pay button helps with the first row only; it does nothing for a second tab, the client's own retry, or the webhook. The fix has to live on the server, where all three collide.
The fix: a receipt number, at two layers
Idempotent means doing it once or five times has the same outcome. Think of a booking reference: book a flight twice with the same one and the airline shows you the same booking, it doesn't sell you two seats.
The client generates one key per checkout (when the form is shown, not per HTTP request) and sends it on every attempt. The server claims it once with a unique constraint; any later attempt with the same key is a replay, not a new payment. That's layer one.
Layer two is the one most write-ups skip. If the process crashes
after calling Stripe but before saving the result, the server's
claim on the key can't say what Stripe did. So the same key is
forwarded as #stripe's own Idempotency-Key,
and Stripe replays its stored response instead of charging again.
Replay the diagram above against this version: the retry carries the same key, its insert conflicts, and it returns the first attempt's status without calling Stripe.
A key proves "you asked this already", nothing more
Nothing above checks that a replay is the same request. Pay one
dollar with key K, then send K again for a thousand: the insert
conflicts, the handler finds K succeeded, and returns succeeded
for a thousand-dollar order that was charged one. Compare before
replaying:

Stripe's own idempotency
does the same: reuse a key with different parameters and you get an
error, not the old response. And account_id must come from the
authenticated session, never the request body, or a caller can claim
keys against someone else's account.
When the charge itself times out
The case keys can't close alone: your call to Stripe times out. No
succeeded, no failed, nothing. It's the frozen card machine again,
and this time your server is the one standing at the counter.
Both guesses are wrong. Assume it failed and retry without the same
key, and a success becomes two charges. Assume it succeeded and ship,
and a decline becomes free stock. Don't guess: leave the payment in
processing and ask the one party that knows.
- Client to API: POST /payments (Key: K)
- API to Provider: charge (idempotencyKey: K)
- Provider to API: 200 succeeded
- API to Client: connection drops
- Client to API: retry (same Key: K)
- API to Provider: poll status (Key: K)
- Provider to API: succeeded
- API to Client: 200 succeeded
On the client, processing is a poll target, not a dead end: keep the
button disabled, show a waiting state, and check the status every few
seconds. Re-POSTing because the wait feels long is the double-click
again, wearing a patience costume.
The webhook needs its own key
Providers redeliver events when you're slow to acknowledge. The event's own ID is the key, with the same unique-constraint trick:
A redelivered event conflicts on event_id, so grantOrderAccess
runs once however many times it arrives, and a declined card now
actually reaches failed instead of sitting in processing forever.
Knowing what happened
Even with every layer above, run reconciliation against the provider's report: it catches whatever slipped past them.
Someone will always tap twice or lose signal at the wrong moment. The goal isn't to stop that; it's that your system always knows what actually happened, even when the person at the counter doesn't.