Idempotency for Money Movement
An idempotency key is not a header you add. It is a claim about what constitutes the same operation, and almost every double-charge traces to that claim being wrong: the key was generated per attempt instead of per intent, or the deduplication window closed before the retry arrived, or the key covered the request and not the effect.
Idempotency for Money Movement
The short answer. The hard part of idempotency is not storing keys. It is deciding what counts as the same operation, and that decision is where double charges come from. The key must be derived from the intent rather than the attempt, it must be generated by the party that formed the intent rather than the one performing the retry, it must persist longer than any retry path in the system including a human one, and it must cover the effect rather than the request. Most implementations get one or two of the four.
Every payment API documents an idempotency key. Almost every team using one still has a double-charge story.
The gap is not the mechanism. It is that the mechanism answers a question the team never explicitly asked: when are two requests the same operation?
The four properties
One: derived from the intent, not the attempt
The most common defect, and it usually appears as a single line of code that looks right.
Generate the key when you build the request, and every retry produces a new key. Now the server sees distinct operations and processes both, which is exactly the failure the key exists to prevent. The code is not wrong in any way a review catches; it is placed one layer too low.
The key must be created when the intent is formed, which is upstream of any retry loop, and carried down through every attempt. If a retry can produce a different key, you do not have idempotency, you have a header.
The practical test: can two attempts at the same business action ever carry different keys? If yes, name the path.
Two: generated by the party that formed the intent
Related and distinct. If the client generates it, the client controls the identity of the operation, which is right, because the client is the only party that knows whether this is a new intent or a repeat of an old one.
A key generated server-side from request contents is a hash, and hashes collide on legitimately distinct operations. Two identical transfers of the same amount to the same recipient a minute apart are a plausible real scenario, and a content hash cannot distinguish them from a retry. That is a false deduplication, which is the opposite failure: the money does not move when it should have, and the failure is silent.
Both directions are bad. Double execution is loud and gets remediated. Silent non-execution is quiet and is often discovered by the customer.
Three: the window must outlive every retry path
Deduplication records are usually kept for a bounded period. The question nobody asks: is that window longer than the longest retry path in the entire system?
Enumerate them, and the list is longer than expected. The HTTP client retries in seconds. The service wrapping it retries in minutes. The job scheduler retries in hours. A dead-letter queue is replayed the next morning. An operator reruns a failed batch on Monday. Someone restores from a backup and the queue replays.
That last set is where the interesting failures live, because those retries arrive days after the deduplication record expired. The system then processes the operation as new, correctly by its own rules, and the money moves twice.
Pick the window from the longest path, including the human ones, and if that is uncomfortably long, that is information about your retry architecture rather than about your storage costs.
Four: the key covers the effect, not the request
The subtle one, and the one that survives review most easily.
A key that deduplicates at the API boundary protects against two API calls. It does not protect against one API call whose downstream effect is applied twice: the payment processor retried internally, the ledger write and the transfer are not in one transaction, or an event was delivered twice to a consumer that acts on it.
Deduplication has to sit at the boundary where the irreversible thing happens. If the effect is a ledger write, the ledger enforces uniqueness on the key. If the effect is a call to an external processor, the key is passed through to the processor rather than consumed by your service.
The general form: the last component that can still cause the effect twice is the one that must deduplicate. Anything above it is optimisation.
The states you must be able to distinguish
An idempotent endpoint has three responses, and collapsing any two of them creates a failure.
New: performed. The operation ran.
Duplicate: returning the original result. The key was seen, the operation completed before, here is what happened. This must return the same result as the original, which means the result has to be stored alongside the key rather than recomputed.
In flight: not yet resolved. The key was seen, the operation is still running, and the answer is unknown. This is the one usually missing, and it is the state a concurrent retry actually lands in.
Return "duplicate, already done" for an operation still in flight and the caller believes it succeeded when it may yet fail. Return "new" and you execute twice. The honest answer is a distinct response that means "in progress, ask again," and callers must handle it.
What this looks like in the ledger
The safety net beneath all of the above, because idempotency is a prevention mechanism and prevention is never complete.
Every money movement carries the idempotency key into the ledger record. So a duplicate is detectable after the fact by querying rather than by reasoning.
Uniqueness is enforced by the database, not by application logic. A unique constraint is a guarantee; a check-then-insert is a race.
A reconciliation job compares your ledger against the processor's, on a cadence, and alerts on divergence. This is the check that finds what the prevention missed, and it is the subject of its own discipline.
The questions for your next review
Where is the key generated, and can a retry ever produce a different one?
What is the longest retry path in the system, including operator reruns and queue replays, and is the deduplication window longer than that?
At which boundary does the irreversible effect happen, and does deduplication sit there or above it?
What does the endpoint return for an operation that is still in flight?
Can I query the ledger today and find any duplicate keys? If that query has never been run, run it. It is ten minutes and it settles whether this is a theoretical discussion.
The same four properties govern any irreversible action an automated system can take, which is why this pattern is worth borrowing well beyond fintech: an agent issuing an action it cannot undo is in the same position as a payment client, and it retries far more enthusiastically.
FAQ
Why do double charges still happen with idempotency keys? Usually because the key is generated per attempt rather than per intent, so retries carry different keys and the server sees distinct operations. The other common causes are a deduplication window shorter than the longest retry path and deduplication placed above the boundary where the effect actually occurs.
Who should generate the idempotency key? The party that forms the intent, which is normally the client, because only it knows whether this is a new action or a repeat. A server-side hash of request contents cannot distinguish a retry from two legitimately identical transfers, which causes silent non-execution.
How long should idempotency records be kept? Longer than the longest retry path in the whole system, including dead-letter replays, operator reruns, and restores from backup. Those arrive days later, after a short window has expired, and the system then processes the operation as new.
Where should deduplication happen? At the last boundary that can still cause the effect twice. If the effect is a ledger write, the ledger enforces uniqueness on the key; if it is a call to an external processor, the key is passed through rather than consumed. Deduplication above that point is an optimisation, not a guarantee.
What should an idempotent endpoint return for an in-flight operation? A distinct response meaning in progress, ask again. Returning already done risks the caller believing an operation succeeded that may still fail, and returning new causes double execution. This third state is the one most implementations omit.
The five design-review questions as a worksheet with pass conditions you can check: the residual, the three outcomes, the task-level budget, where each check sits on the verifier ladder, and the irreversible-action inventory. Built to be filled in with the team that built the system.
Tell us the system, the stakes, and the date that matters. You get a straight technical reply from the person who would lead the work, within 24 hours.
Bring us the program