Back to blogPayment idempotency: prevent duplicate charges

Payment idempotency: prevent duplicate charges

Fintech·July 23, 2026·9 min read·By CodeDecoders Engineering

Payment idempotency is the property that sending the same charge request twice produces one charge, not two. You get it by having the client attach a unique key to each payment intent, and having the server remember what it did with that key. The next time the same key arrives, the server replays the original result instead of moving money again.

If your payment endpoint does not do this, you already have a duplicate charge bug. It just has not fired for a customer who matters yet. This is a how-to for adding payment idempotency to a real endpoint: how to design the key, how to store the result, how to handle two requests racing on the same key, and how to verify the whole thing actually holds.

Why a retry turns into a double charge

Picture the simplest charge flow. The client POSTs to /charges, your server calls the acquirer, the money moves, and you return 200. Now drop the response on the floor. The acquirer succeeded, but the client's HTTP call timed out at 30 seconds, or the mobile network dropped, or your load balancer killed a slow connection. The client did the reasonable thing and retried. Your server charged the card again.

This is not a rare edge case. It is the normal behavior of every network under load. Timeouts, 502s from a proxy, a user double-tapping "Pay", a job runner retrying a failed step, and now agent-driven traffic that retries on any non-200: every one of these produces two identical requests where you intended one. The client cannot tell "the charge failed" apart from "the charge succeeded but the response was lost", so a correct client retries both.

The fix is not "retry less". Retries are how distributed systems stay available. The fix is to make the second, third, and fourth attempt safe, so that only the first one moves money. That is what payment idempotency buys you.

The idempotency contract

The mechanism is a single request header. The client generates a unique key per logical operation and sends it on the charge request. The emerging IETF Idempotency-Key HTTP header draft formalizes exactly this: a client attaches Idempotency-Key to a non-idempotent method like POST, and the server is responsible for the key's lifecycle. Stripe's idempotent requests are the same idea in production, and they are a good reference for the details.

Three rules make the contract work:

First, the client owns key generation, not the server. Do not derive the key from a hash of the request body. Two genuinely different payments can hash-collide in the ways that matter (same amount, same customer, same minute), and a legitimate second purchase would get silently deduplicated into the first. Have the client mint a fresh UUID v4 per checkout attempt and reuse that same value across its own retries.

Second, the key identifies a logical operation, not a transport call. The client generates it once, at the top of the operation, and holds it for every retry of that operation. If it generates a new key on each retry, you are back to duplicate charges.

Third, the server stores the outcome against the key and replays it. Stripe saves the status code and body of the first request and returns the identical result on any later request with the same key, including replaying a 500. That last part matters: replaying the original error is correct, because it tells the client the true outcome of the operation it started rather than starting a new one.

Who generates itServerClientClient
Two real purchases collide?Yes, silently droppedNoNo
Cross-user leak riskPossiblePossibleNo
RecommendedNoOKYes

Scope the stored key by more than the raw string. A good storage key is the idempotency key combined with the authenticated user or account ID and the operation type. That prevents one tenant's key from ever colliding with another's, and it stops a recycled client-side UUID from matching a payment it has nothing to do with.

Implementing it on a payment endpoint

The naive version ("look up the key, if missing then charge, then save") has a race in it that this walkthrough closes. Two requests with the same key can both pass the initial lookup before either has written a result, and both then charge. Here is the sequence that is actually safe.

Idempotent charge, step by step

Step 1 of 6

Read the key

Require the Idempotency-Key header on POST /charges. Reject the request with 400 if it is missing, so you never charge without one.

Two properties in that sequence are load-bearing, and both are easy to get wrong.

Atomicity is the first. The charge and the idempotency record must commit together. If you charge the acquirer and then crash before saving the record, the next retry sees no record and charges again. Write both in the same database transaction. If the acquirer call sits outside your database, use the outbox pattern: record the intent inside the transaction, then reconcile against the acquirer before releasing the result. This is the same ledger discipline that underpins double-entry accounting for fintech systems, where every money movement has to be recorded exactly once or the books stop balancing.

Durability is the second. Your idempotency store has to survive a restart, because a miss on that store is a duplicate charge. Redis with a default eviction policy is the classic trap: under memory pressure it evicts your keys, and the "cache" you relied on to prevent double charges quietly forgets them mid-incident. Use a durable store (a real table in your primary database, or Redis with persistence and eviction turned off for this keyspace), and treat idempotency records as system-of-record data, not as a cache.

Pitfalls that quietly reintroduce double charges

The header is easy. The failure modes are where teams lose money.

Reusing keys with different payloads. A buggy or lazy client might send the same idempotency key for a $10 charge and later a $500 charge. If you blindly replay the first result, the customer never gets charged the $500. If you blindly charge, the key did nothing. The right answer, and what Stripe does, is to compare the incoming parameters against the stored request and reject the mismatch (a 422), so the conflict surfaces loudly instead of corrupting state.

Caching in-flight failures as final. When a request fails validation or collides with a concurrent request on the same key, do not persist that as the final outcome. Stripe explicitly does not save a result in these cases, so the client can retry. Only persist once the operation reached a terminal state.

No expiry policy, or the wrong one. Idempotency records need a TTL. Twenty-four hours is a common baseline and matches Stripe's window. Too short and a client retrying after a long outage double charges. Too long and you store unbounded state. Publish the window so clients know how long a key is safe to reuse, which the IETF draft requires servers to do.

Forgetting the downstream blast radius. A duplicate charge does not stay contained to one row. It flows into settlement files as a phantom transaction, and it becomes an unmatched or duplicated record that your ops team has to chase. If you have read our guide to building a payment reconciliation engine, the "unknown in settlement" bucket is often just an upstream idempotency bug wearing a disguise. Idempotency at the edge is what keeps reconciliation tractable downstream.

Ignoring machine callers. Agents and job runners retry far more aggressively than humans, in tight loops. An autonomous payer with wallet access and a retry bug is an expensive incident, which is why idempotency is table stakes for the agentic payment rails we have written about. If you expose a paid endpoint to machine traffic, assume every request arrives at least twice.

How to verify it actually holds

Do not trust an idempotency layer you have not tried to break. Three tests catch almost every regression.

Fire the same key twice, sequentially. Send a charge, capture the response, send the identical request with the same key, and assert you get the byte-identical response and exactly one charge row in the database. This is the happy path and should be a permanent integration test.

Fire the same key concurrently. Launch two or more requests with the same key at the same instant and assert that exactly one charge occurs and every caller receives a consistent result (the winner's result, or a 409 that resolves to it on retry). This is the test that catches a missing lock, and it is the one most teams skip.

Kill the process mid-charge. Inject a crash between the acquirer call and the commit, then replay the request. Assert there is still exactly one charge. This proves your atomicity story is real and not just a comment in the code, and if you cannot pass it, your charge and your idempotency record are not committing together.

Payment idempotency is a small amount of code and a large amount of discipline. Get the key ownership, the lock, and the atomic write right, and duplicate charges stop being a bug you can ship. If you are hardening a charge path and want a second set of eyes on the failure modes, our payment rails engineering team does exactly this work, and you can get in touch to talk it through.

Newsletter

New posts, in your inbox

Get an email when we publish a new deep-dive. No spam, unsubscribe anytime.

Start a Project

Let's build something extraordinary together.

Free consultation·Response within 24h·No commitment

info@codedecoders.io