Idempotency in payment systems is the property that sending the same instruction twice moves money once. It matters because retries are not an edge case — they are how distributed systems work, and a payment API call that times out leaves you in the one state you cannot act on safely: you don't know whether it worked.
The short answer
You generate a unique key for each payment instruction and send it with the request. The provider records that key against the result. If the same request arrives again — because your client retried, a queue redelivered, or a user double-clicked — the provider recognises the key and does not execute the operation a second time.
That's the mechanism. It is also where most explanations stop, and where the actual problems begin.
Why it's harder than that
Three things complicate the picture.
The HTTP definition is not the payments definition. RFC 9110 defines a method as idempotent if "the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request." Note what that says: the effect on the server. It says nothing about the response you get back, and nothing about what happens when you never receive a response at all. PUT and DELETE are idempotent by definition. POST — which is how essentially every payment is submitted — is not, which is why the key exists.
The dangerous window is the one where you learn nothing. If a request fails cleanly with a 400, you know it didn't happen. If it returns 200, you know it did. The problem is the timeout, the dropped connection, the 504 from a gateway that may or may not have forwarded your request downstream. You have no information, and the naive response — retry with a fresh request — is exactly how a customer gets debited twice.
"Idempotency key" does not mean one thing. Providers implement it in meaningfully different ways, and code written against one provider's semantics can be unsafe against another's. This is the part worth getting right.
How payment providers implement idempotency keys
There are two distinct behaviours wearing the same name.
Replay the stored response. Stripe's API v1 saves the status code and body of the first request made with a given key, once endpoint execution has begun, and returns that saved response to any retry — including 500 errors. That last detail surprises people: a retry does not get another attempt, it gets a replay of the failure. Stripe also compares the incoming parameters against the original and errors if they differ, which stops a key being accidentally reused for a different payment.
Reject the duplicate and make you look it up. Several custody and banking APIs do the opposite. Fireblocks' externalTxId and BitGo's sequenceId reject a second submission carrying a key they have already seen, rather than replaying the original result. Recovery is a separate call: find the transfer by my own reference. If your integration assumes replay, your retry path throws an error and you are back to not knowing.
Both are defensible designs. Neither is wrong. But "we use idempotency keys" tells you almost nothing until you know which of the two you are dealing with — and if you integrate more than one provider, you need an abstraction that normalises them.
The standard that never landed
Worth knowing, because it explains the inconsistency: Idempotency-Key is not an HTTP standard. The IETF draft, draft-ietf-httpapi-idempotency-key-header, reached version 07 in October 2025 and expired in April 2026 without becoming an RFC.
It is a good document and worth reading as a statement of intent — it covers POST and PATCH, forbids reusing a key with a different payload, and defines three responses: 400 when a required key is missing, 422 when a key is reused with different content, and 409 when a retry arrives while the original is still in flight. But it is an expired draft. Nothing obliges a provider to follow it, and many don't. Read each provider's documentation; do not assume the header behaves the same way twice.
What makes a usable idempotency key
Generated by the client, not the server. If the server assigns the identifier, nothing can be retried safely — you need the key to exist before the first attempt, precisely because the first attempt might vanish.
Deterministic for the same logical operation. The key must identify the business intent — this customer's withdrawal of this amount to this destination — not the HTTP attempt. A fresh UUID per retry defeats the entire mechanism. Generate once, at the point the user confirms, and carry it through every retry.
Random enough not to collide. Stripe suggests v4 UUIDs or similar, and accepts keys up to 255 characters. Do not derive keys from sensitive data such as email addresses or account identifiers; they end up in logs and support tools.
Written to your own database before the network call. This is the part that is genuinely easy to get wrong and expensive to get wrong. The sequence must be: record the intent locally, commit, then call the provider. If you call first and record afterwards, a crash in between leaves you with money possibly moved and no record that you tried.
1. Write intent { intentId, account, asset, amount, destination, status: RECORDED }
2. Commit
3. Call provider with intentId as the idempotency key 4. Record the outcome against intentId
The expiry trap
Provider idempotency is time-boxed, and the window is shorter than people assume. Stripe prunes keys after they are at least 24 hours old, and a key reused after pruning is treated as a brand new request. Fireblocks' general idempotency header behaves similarly.
Now consider a transfer that stalls — stuck in a queue, pending manual review, waiting on a chain. A recovery job finds it three days later and retries. The provider has forgotten the key entirely, so the retry executes as a fresh instruction.
This is the strongest argument for your own ledger being the record rather than a convenience. Your intent outlives their idempotency window. The local record is what lets you ask "did this already happen?" long after the provider has stopped answering that question for you.
The state you need and probably don't have
Most payment state machines model success and failure. They need a third terminal-ish state for we asked and could not tell — call it UNKNOWN.
Mapping ambiguity onto FAILED is how double spends happen, because "failed" invites a retry and the original may well have succeeded. UNKNOWN should take the record out of the automated path and into a reconciliation job that resolves it by querying the provider, not by resubmitting. It is better to be loudly uncertain than quietly wrong.
In practice this means idempotency is never only a client-side concern. It is a key, plus a durable local record, plus a state machine honest enough to represent doubt, plus a sweeper that resolves it. Miss any one and the key alone will not save you.
For a worked example of carrying this through a system with more than one provider behind it — where the two behaviours above have to be normalised into one interface — see designing provider-agnostic crypto custody.
Key takeaways
- Idempotency in payment systems means the same instruction moves money once, however many times it is submitted. POST is not idempotent by default, which is why a key is needed.
- Providers split into two camps: some replay the stored response on a duplicate key, others reject the duplicate and require a separate lookup. Check which before you write the retry path.
Idempotency-Keyis not a standard. The IETF draft expired in April 2026 without becoming an RFC, so behaviour varies by provider.- Generate the key before the first attempt, write it down before the network call, and keep it longer than the provider does — their window is typically about 24 hours.
- Model uncertainty explicitly. A timeout is not a failure; mapping it to one is how retries take money twice.
Written from public documentation and general practice. Does not describe the systems of any employer or client. Views my own.
