pickuma.
Dev Knowledge

Idempotency, Explained Through the Retry That Doesn't Double-Charge

A practical look at idempotency keys: why a retried payment request shouldn't charge a card twice, how the pattern works, and where it quietly breaks in production.

7 min read

You click “Pay,” the spinner hangs, and nothing happens. So you click again. Behind the scenes, the first request actually succeeded — your bank approved the charge — but the response never made it back to your phone because the connection dropped. Your second click fires an identical request. Without protection, you just paid twice.

This is the problem idempotency solves. The word sounds academic, but the failure it prevents is concrete: the same operation running more than once and changing your data more than once. We’ll walk through it using the payment example because it’s the one where the cost of getting it wrong is measured in real dollars and refund tickets.

What “idempotent” actually means

An operation is idempotent if running it once produces the same result as running it ten times. The classic distinction lives in HTTP. GET /account/123 is naturally idempotent — reading your balance a hundred times doesn’t change it. DELETE /session/abc is idempotent too: the session is gone after the first call, and the next nine calls find nothing to delete and leave the world unchanged.

The trouble is POST. POST /charges creates a new charge every time it runs. That’s the correct default for creating things — you usually want a second POST to make a second resource. But for a payment, a second charge is exactly the bug. The operation is not idempotent by nature, so you have to make it idempotent on purpose.

How an idempotency key works

The pattern that became the industry standard is the idempotency key, popularized by Stripe’s API. The client generates a unique value — typically a UUID — and attaches it to the request, usually in an Idempotency-Key header. Critically, the client generates it before sending and reuses the same key on every retry of that one logical operation.

The server’s job is then mechanical:

  1. Read the key from the incoming request.
  2. Look it up in a store of keys it has already processed.
  3. If the key is new, process the charge, then save the key alongside the response it produced.
  4. If the key already exists, skip the work entirely and return the saved response from the first time.

That fourth step is the whole trick. The retried request gets back the original “charge succeeded” response — same status code, same charge ID — so the client sees success and stops retrying. The card is never touched a second time.

AspectNo idempotency keyWith idempotency key
Client retries after timeoutSecond charge createdOriginal response replayed
Where uniqueness livesNowhereClient-generated key, stored server-side
Cost of a dropped responseDuplicate side effectSafe, no extra effect
CleanupManual refundsNone needed

The key has to be generated by the client, not the server, and it has to be tied to the user’s intent — one checkout, one key. If you generate a fresh key on each retry, you’ve defeated the entire mechanism: every retry looks new, and you’re back to double-charging.

Where idempotency quietly breaks

The concept is simple. The production failures are where it gets interesting, because they hide in the gaps between “check the key” and “do the work.”

The race between check and write. Two retries can arrive at nearly the same instant. Both look up the key, both find nothing, both proceed to charge. The fix is to make the key reservation atomic — a unique constraint on the key column, or an atomic “insert if not exists.” The first request wins the insert; the second hits a conflict and waits for or reads the first one’s result instead of charging.

Storing the key after the side effect instead of before. If you charge the card and then save the key, a crash in between leaves you with a completed charge and no record that the key was used. The next retry sees a fresh key and charges again. Reserve the key first, do the work, then record the result against the reserved key.

Caching errors as if they were successes. If the first request fails with a transient 500, you generally do not want to replay that 500 forever. Most implementations only persist the response for successful or definitively-failed operations, and let genuinely transient failures be retried. Decide this deliberately — it’s the difference between a retry that recovers and one that’s permanently stuck.

Reusing a key for a different request body. A robust server fingerprints the request payload alongside the key. If the same key arrives with a different body, that’s a client bug, and returning the old response would be wrong. Stripe, for instance, rejects a key reused with mismatched parameters rather than silently replaying.

If you’re building this into a payment or order flow and want a second pair of eyes on the check-then-write race or the key lifetime, an AI pair-programmer that reads your whole repo can catch the non-atomic lookup before it ships.

Cursor

An AI code editor that reads across your codebase, useful for tracing where an idempotency key is generated, stored, and checked — and flagging the gaps between those steps.

Free tier; Pro around $20/month

Try Cursor

Affiliate link · We earn a commission at no cost to you.

The mental model worth keeping: a retry is not a new intention, it’s the same intention asking again. Idempotency is how your server tells the difference. Get the key generated on the client, reserve it atomically before the side effect, and replay the stored result — and the retry that used to double-charge becomes a non-event.

FAQ

Is idempotency the same as a database transaction?
No. A transaction makes a single operation all-or-nothing. Idempotency makes a repeated operation safe to run again. You often use both: a transaction to atomically reserve the key and apply the change, and the key itself to deduplicate retries across separate requests.
Where should I store idempotency keys?
Anywhere that supports an atomic uniqueness check and can hold the saved response — a relational table with a unique constraint on the key, or a key-value store with set-if-not-exists. The store needs to outlive your retry window, so an in-memory cache that resets on deploy is risky for payments.
Do GET requests need idempotency keys?
No. Reads don't change state, so repeating them is already safe. Idempotency keys are for state-changing requests — POSTs that create charges, orders, or transfers — where a duplicate would cause a duplicate effect.

Related reading

See all Dev Knowledge articles →

Get the best tools, weekly

One email every Friday. No spam, unsubscribe anytime.