Idempotency Keys in Payment APIs: Safe Retries Without Double Charges
An idempotency key is a client-generated token attached to a payment request so the server can recognise a retry and return the original result instead of charging again. The part most guides skip: the key has to be enforced inside the ledger transaction that moves money. If it only lives as a cached response at the API gateway, a partial failure between the cache check and the ledger write still double-posts.
The consensus is right and still leaves you double-charging
Every guide on this topic tells you the same true thing: attach a unique idempotency key to each payment request, have the server store the first result against that key, and replay it on any retry. That is correct. It is also where most of them stop, and the stopping point is exactly where the money leaks.
Here is the version I'd defend, from years of watching this fail in ledger and lending systems: *idempotency is a ledger property, not only an API feature.* Teams bolt a cached 200 onto the API gateway — keyed by request ID, twenty lines of middleware — and call the problem solved. But if the settlement write underneath isn't itself idempotent, a partial failure that slips between the cache check and the ledger commit still posts the charge twice. The key has to be enforced at the write that moves money, not only at the edge that answers the client.
The rest of this is why that gap exists, where it opens, and what you actually do about it.
Where the double-charge actually comes from
Start with the failure everyone pictures wrong. The dangerous case is not the request that never reaches your server. That one is harmless — nothing happened, the client retries, the payment goes through once.
The expensive case is the request that arrives, succeeds, moves the money, and then loses its receipt. The client sends POST /payments, your server debits the account and writes the ledger entry, and then the 200 OK dies in a network timeout on the way back. From the client's side, nothing came back. So it does the only sane thing a client can do: it retries. Without a dedup key, that retry is indistinguishable from a brand-new payment, and now the customer has paid twice.
At low volume you can pretend this is an edge case. At the transaction volumes I've worked at — systems supporting 10M+ payment transactions a year, per my own summary of that work — the timed-out-but-succeeded request isn't an edge case. It's Tuesday. Retries are a constant background rate, and every one of them is a double-charge waiting for a missing guard.
How the key fixes it — the part everyone agrees on
The mechanism is genuinely simple, which is why it's easy to implement shallowly.
The client generates a unique key per logical operation — a UUID v4 is the standard choice — and sends it in an Idempotency-Key header. "Per logical operation" is load-bearing: the key identifies this specific intent to pay, so the client must reuse the same key across its retries and never reuse it across genuinely different payments.
The server, on first sight of a key, processes the request, stores the response against the key, and returns it. On any later request carrying that same key, it skips processing and replays the stored response. Two industry conventions ride along with this:
- Payload fingerprinting. Store a hash of the original request body alongside the key. If a later request presents the same key but a different body, that's a client bug or an attack — reject it with
409 Conflictrather than silently returning the wrong result. - A bounded retention window. Keys are kept long enough to cover the client's realistic retry horizon — 24 to 72 hours is the common range for payments — then expired. They are not a permanent transaction log.
None of that is controversial. Stripe, Adyen, and PayPal all expose exactly this shape, and you pass your key downstream to them so even a duplicated call from your own infrastructure gets deduped at the gateway. If your only failure mode were "the same fully-completed request arrives twice, sequentially," you would be done here.
You are not done here.
The key belongs on the write, not the gateway
Picture the shallow implementation, because it's the common one. A piece of middleware sits at the API gateway. It checks a cache: has this key been seen? No — forward the request to the payment service, then cache the response. Yes — return the cached response.
Now walk the partial failure. Request comes in, key is unseen, middleware forwards it. The payment service debits the ledger successfully. Then the payment service crashes — or the pod is evicted, or the network partitions — before the response makes it back to the gateway to be cached. The gateway never records the key. The client times out, retries, and the gateway, still with no record of that key, forwards the retry as new. Second debit. The cached-200 pattern didn't protect you; it just moved the unguarded gap from the client to the space between your own two hops.
The fix is not a better cache. It's putting the idempotency check inside the same transaction as the money movement. The ledger write and the "record this key as processed" write have to commit or roll back together — one atomic unit. If the service dies mid-way, the transaction rolls back as a whole: no debit, no recorded key, and the retry legitimately re-runs from clean state. If it commits, both the money and the key are durable together, and the retry sees the key and replays.
This is the whole of my argument, and it's why I frame idempotency as a ledger property. The gateway cache is a fine optimisation — it saves you re-running work for the easy sequential-retry case. But it is not the guarantee. The guarantee lives exactly where the state changes, or it doesn't exist.
The retry that arrives mid-flight
There's one more failure the sequential mental model hides, and it's the one that survives a naive ledger-level check too.
Two requests with the same key arrive concurrently. Not one-after-another — genuinely overlapping, because the client's retry timer fired while the original was still in-flight, or a load balancer double-delivered. Both threads read the idempotency store, both see "key not present," both proceed to the ledger. Read-then-write has a race in the gap between the read and the write, and you've double-posted despite having idempotency "handled."
The correct primitive is an atomic claim on the key rather than a check. Insert the key first, as the very first step, using a uniqueness constraint — a unique index in the database, or an atomic SET key … IF NOT EXISTS in a store like Redis. Exactly one of the concurrent requests wins the insert and earns the right to proceed to the ledger. The loser gets a constraint violation, which it interprets not as an error but as "someone else owns this operation" — so it waits for the winner's result and replays it. The claim, not the read, is what serialises the two.
Sequential dedup is easy and every guide covers it. The concurrent, still-in-flight retry is where implementations quietly fail, and it's worth writing a deliberate test that fires two identical-key requests in parallel and asserts exactly one ledger entry.
What you'd actually do
If you're building or reviewing a payment path, the checklist is short and opinionated:
- Client generates the key, one per intent, reused across retries. If the server generates it, the server can't tell a retry from a new payment — the client is the only party that knows which requests are the same intent.
- Enforce the key inside the ledger transaction, not as a gateway cache. The "mark key processed" write and the money-movement write commit atomically or not at all.
- Claim the key atomically before processing — insert-first with a uniqueness constraint — so concurrent retries can't both slip through.
- Fingerprint the payload and return
409on a same-key/different-body mismatch. - Bound the retention window to the retry horizon (24–72h), and treat error responses differently from success — you generally don't want to cache a transient 500 against the key and replay it forever.
- Pass the key downstream to your PSP so the same guard holds at the gateway you don't control.
Do the first two and you've closed the gap that the cached-200 pattern leaves open. Skip them and you have, at best, a very convincing idempotency essay.
Straight answers, marked up for Google.
- What is an idempotency key in a payment API?
- A unique, client-generated token (usually a UUID v4) attached to a payment request in an Idempotency-Key header. It lets the server recognise a retry of the same logical operation and return the original result instead of processing the payment again.
- How do idempotency keys prevent double charges?
- The server records the outcome of the first request against the key. Any later request carrying the same key gets the stored result replayed rather than a fresh charge — so a client retrying after a network timeout doesn't create a second payment.
- Where should the idempotency check live — the API gateway or the ledger?
- Inside the ledger transaction. A cached response at the gateway still double-posts if the payment service commits the debit but crashes before the gateway caches the result. Committing the money movement and the 'key processed' record in one atomic transaction closes that gap.
- Should the client or the server generate the idempotency key?
- The client. Only the client knows which requests are the same intent versus two legitimately separate payments that happen to have identical amounts and payloads. A server-generated key can't make that distinction.
- How do I handle two requests with the same key arriving at the same time?
- Claim the key atomically before processing — insert it first using a uniqueness constraint (a unique DB index, or Redis SET IF NOT EXISTS). Exactly one concurrent request wins the claim and proceeds; the others get a constraint violation and replay the winner's result.
- How long should idempotency keys be stored?
- Long enough to cover the client's realistic retry window — commonly 24 to 72 hours for payment APIs — then expired. Keys are a retry guard, not a permanent transaction log.
- What should happen if the same key arrives with a different request body?
- Reject it with 409 Conflict. Store a hash of the original payload alongside the key; a same-key/different-body request is a client bug or an attack, and silently returning the original result would hide it.
- Do I still need my own idempotency handling if I use Stripe or Adyen?
- Yes. Gateways dedupe the call you send them, which protects against your own infrastructure double-sending. But the debit-then-crash gap inside your own service is upstream of the gateway — you still need the key enforced at your own ledger write.
- Founder, Fynarfin — scaled to $1M revenue (2019–2024)
- Apache Fineract — SDE & Solution Architect (ledger, line of credit, loan restructuring)