A payment endpoint with no idempotency looks correct in every manual test and still double-charges in production, because a double-click, a timed-out request, and a redelivered webhook all replay the exact same call. A client-generated idempotency key, forwarded to the payment provider's own idempotency mechanism, closes all three at once. The part that still needs care after that is the call that times out with a genuinely unknown outcome.
Payments are the one place where "just retry it" can cost someone money. Everywhere else, a duplicate write is a bug. A duplicate charge is a support ticket, a chargeback, and a customer who doesn't trust you with their card again.
That asymmetry is why it's worth walking through what actually goes wrong before reaching for the fix.
You've already felt the underlying problem, just not in code. A card machine freezes for a few seconds after you tap, so you tap again. Either it just charged you twice, or the first tap never went through and you're about to walk out with a free coffee. That few seconds of not knowing is the whole problem this post is about. It's just moved from a card reader to an HTTP request.
Start with the version that looks fine
Here's a payment endpoint that passes every manual test you'd think to run. It validates the amount, calls the provider, records the result.

Click Pay once, on a good connection, and this works. Nothing here looks wrong. That's exactly the problem: every scenario that breaks it looks identical to the client and identical in the code path taken.
One thing this endpoint gets right for free: it only ever sees an amount. The card number itself never reaches this server. Stripe's own hosted fields collect it directly, which is what keeps this code out of PCI DSS scope in the first place. Everything below is about the charge, never about handling a card number safely, because this server never does.
The double-click
The user clicks Pay, the network feels slow, they click it again. Two
requests reach createPayment a few hundred milliseconds apart: a
textbook race condition.
Nothing in the function above knows it's happening. Both run to
completion:
The retry after a timeout
The user clicks Pay once. The request reaches the server, the card gets charged, and the response is lost somewhere on the way back (a proxy timeout, a dropped connection, a phone losing signal). The client sees nothing, waits, and retries, because from where it's standing that's the only correct thing to do. It never got an answer.
The webhook, redelivered
Say this endpoint also has a webhook handler that grants order access once Stripe confirms the charge:
Providers redeliver a webhook event if your server is slow to acknowledge it, or on their own transient failures. That's a documented part of the contract, not a bug on their end.
Three different triggers (a person, a network, a delivery guarantee) collapse to the same shape: the same logical operation runs the function body more than once. None of them are exotic. They're the default behavior of clients, networks, and webhook providers.
Disabling the button isn't the fix
The obvious instinct is to disable the Pay button after the first click. It's good UX and worth doing, but notice it only touches the first trace above. It does nothing for a reload, a second tab, or the client's own timeout retry. And it does nothing at all for the webhook. The fix has to live where all three traces actually collide: the server.
Fixing it: idempotency keys, at two layers
Idempotent just means: do it once, or do it five times, the outcome is the same. That's the one property missing from every trace above. And the fix borrows something you already use for other things: a receipt number. Book a flight twice with the same confirmation code and the airline doesn't sell you two seats. It just shows you the same booking again.
The client generates one such key per checkout attempt (when the form is shown, not per HTTP request) and sends it on every attempt of that payment, including the double-click and the retry. The server claims that key once and treats every later attempt as a replay rather than a new request.
That alone would stop the first two traces. The reason it needs a
second layer is scenario two: if the process crashes after calling
Stripe but before recording the result, the server's own claim on
the key doesn't tell it what Stripe actually did. So the same key also
has to be forwarded as #stripe's own
Idempotency-Key, which is the layer most write-ups skip:
Re-run the double-click trace against this version: request A claims the key and proceeds; request B's insert conflicts, finds A's row, and replays its status instead of calling Stripe a second time. Re-run the timeout retry: the retry carries the same key as the original attempt, so it's the same replay path, not a fresh charge.
The key is a dedupe token, not a password
One assumption is doing a lot of work above: account_id comes from
the authenticated session, never from anything the client sends. Take
it from the request body instead and the whole scheme stops meaning
anything. An attacker who can also choose which account they're
claiming a key against can collide with, or read the replayed result
of, someone else's payment.
The sharper gap is in the code itself. Nothing above checks that a
replayed key belongs to the same request. Reuse K with a
different amount and the handler happily returns the first payment's
stored status without ever charging the new one:
The fix is to check the stored request actually matches before replaying, and refuse the replay outright if it doesn't:

This is exactly what Stripe's own idempotent-request handling does: reuse one of their idempotency keys with different parameters and you get an error back, not a silent replay of the old response. A key proves "you already asked me this," nothing more, so treat any mismatch as a hostile or buggy caller, never as a shortcut.
The charge that times out
This is the trace the two fixes above don't fully close, and it's the
one that actually breaks systems in production: the call to Stripe
itself times out. Not the response to the client, the call to the
provider. You don't get succeeded, you don't get failed, you get
nothing. It's the frozen card machine from the start of this post,
just moved from the counter to a network call. And this time it's your
server standing there not knowing either.
Both instincts here are wrong. Assume it failed and let something retry the charge without the same key reaching Stripe, and a succeeded charge becomes two. Assume it succeeded and mark the order paid, and a failed charge means you've shipped a product you were never paid for.
The only correct answer is to not guess. The payment sits in
processing, and you resolve it by asking the one source of truth
that actually knows: the provider, either by polling the
PaymentIntent
status or by waiting for its webhook. A timeout on your side is not
evidence of anything on their side.
- 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 isn't a dead end, it's a poll target. Keep
the Pay button disabled, show a waiting state, and check the payment's
status every couple of seconds until it leaves processing. Never
re-POST just because the wait feels long. That's the double-click from
the start of this post, wearing a patience costume.
Which puts the webhook handler back in the frame, and it needs the same fix as the payment endpoint: dedupe by the event's own ID before acting on it.
Re-run the webhook trace against this version: the redelivered event's
own insert conflicts on event_id, so grantOrderAccess runs exactly
once no matter how many times Stripe resends it. And a declined card
now actually reaches failed instead of sitting in processing
forever, which the naive version never handled either.
Reconciliation is the seatbelt, not the fix
Even with both layers of idempotency key and a webhook handler that
dedupes correctly, run a periodic job that pulls the provider's
transaction list for the last few hours and diffs it against your own
payments table. Every real payment system has one. It exists for the
case the code above doesn't cover: a bug, a manual database fix, a
webhook that never arrived and nobody polled for. It should page a
human on a mismatch, not silently correct it. Money moving without a
person noticing is its own risk.
- 1
Keying on the amount and card instead of a client-generated key
A customer buying the same item twice in a row is not a duplicate. The key has to be an explicit per-attempt identifier the client owns, not something derived from the payload.
- 2
Stopping at your own idempotency table
Dedupes the request, but a crash between calling the provider and recording the result still leaves the provider call unprotected. Forward the same key as their idempotency header.
- 3
Treating a client-side timeout as a failure
The request may have succeeded on the server before the response was lost. Resolve
processingby asking the provider, never by what the client's own network call returned. - 4
Trusting a webhook to arrive exactly once
Providers redeliver on their own retries. Dedupe by the event ID with the same unique-constraint pattern as the payment itself.
- 5
Replaying a key without checking the request matches
A key only proves "you already asked me this." Reuse it with a different amount and a naive replay hands back the old, smaller charge's
succeededstatus for the new, larger request. Compare the stored request before replaying, and reject the mismatch. - 6
Scoping the key by anything the client can set
account_idhas to come from the authenticated session. Accept it from the request body and an attacker can claim a key against someone else's account.
The frozen card machine from the start of this post never goes away. Someone somewhere is always going to tap twice or lose a connection at the wrong moment. What changes is that the system behind it always knows what actually happened, even when the person standing at the counter doesn't: an idempotency key stops the double-click and the timeout retry outright, forwarding it to the provider survives a crash mid-call, and asking the provider instead of guessing settles the one trace that survives both.

