Library
9 min read

Idempotency keys

Published

You pay for a Zomato order in a lift. The spinner spins, the screen says "Something went wrong", and you are left with the worst question in distributed systems: did it happen? Maybe the request never reached the server. Maybe the server charged you and the reply got lost on the way back. From your phone, those two look identical.

The only safe thing a client can do after a timeout is try again. So the server must make trying again safe. An operation is idempotent if doing it twice has the same effect as doing it once. An idempotency key is how you make a non idempotent operation, like "charge ₹499", behave that way.

Your answer

A payment request times out. The app retries automatically. How does the server know the second request is a retry of the first and not a genuine second purchase of the same thing?

Try it#

Pay ₹499 for an order, on a bad connection
1
  1. appAttempt 1: POST /payments (no key)
  2. serverCharge ₹499
  3. netReply lost on the way back (lift, tunnel, weak 4G)
  4. appAttempt 2: timed out waiting, so retry
  5. serverCharge ₹499
  6. app200 OK received. Show "Order placed".
Customer charged₹998 (2 times)
The customer pressed Pay once (or the app retried on its own) and was charged ₹998. Every retry was a perfectly reasonable thing for the app to do, because a timeout does not tell you whether the server acted. Turn the key on.

Some operations are idempotent already#

  • Reading is idempotent: GET /orders/8812 twice changes nothing.
  • Setting a value is idempotent: "set the address to X" twice leaves it at X. HTTP PUT and DELETE are defined as idempotent for this reason.
  • Adding, creating and sending are not: "add ₹100", "create an order", "send an SMS" twice does it twice. That is most of POST.

So the first trick is design: prefer operations that are naturally idempotent. "Mark order 8812 as paid" is safe to repeat; "increment paid orders" is not. Where you cannot, add a key.

How an idempotency key works#

The client generates a unique key, usually a UUID, once per user intent (when the user taps Pay), and sends it with the request and with every retry of it. The server records each key with the result it produced. A request with a key it has already completed gets the stored result back, without doing the work again.

1/9 Generated once, saved locally with the pending order, reused on every retry.
sql
CREATE TABLE idempotency_keys (
  key            text PRIMARY KEY,
  user_id        bigint NOT NULL,
  request_hash   text NOT NULL,          -- detect the same key with a different body
  status         text NOT NULL,          -- in_progress | completed
  response_code  int,
  response_body  jsonb,
  created_at     timestamptz NOT NULL DEFAULT now()
);
-- A nightly job deletes rows older than 24 hours.

The details that bite#

Generate the key per intent, not per attempt. A key made inside the retry loop is a new key every time, and protects nothing. Store it with the pending action so it survives an app restart.

Claim the key atomically. "Check if the key exists, then insert it" races: two requests both see nothing and both proceed. Use a unique constraint (or Redis SET key NX) so exactly one wins. The loser gets 409 while the first is still running.

Same key, different request is a client bug. Store a hash of the body and reject a mismatch with 422, rather than replaying a response for a different payment.

Pass it downstream. If your service calls a payment gateway, send the same (or a derived) key to the gateway. Stripe, Razorpay and Adyen all accept one. Otherwise your own retry of the gateway call can double charge even though your API was idempotent.

Decide on expiry. Keys are kept for a window, Stripe keeps them for 24 hours, long enough to cover any sane retry.

Crashes in the middle. If the server dies after charging but before marking the key completed, the key is stuck in_progress. Either put the effect and the key update in one database transaction (possible when the effect is a local write), or have a recovery job that checks the downstream system (did the gateway charge with this key?) and completes the record.

UPI does this with a transaction reference

Every UPI payment carries a unique transaction reference generated by the payer's app. Banks and NPCI use it to recognise repeats, and a status check API lets an app ask "what happened to this reference?" after a timeout instead of paying again. The UPI design walks through it.

ChoiceWhat you gainWhat you payPick it when
Naturally idempotent design (PUT, set state)Nothing to store. Repeats are harmless by construction.Not every action can be expressed as setting a state.Updates, status changes, deletes. Prefer it wherever it fits.
Client supplied idempotency keyMakes any POST safe to retry, works across services and gateways.A table or cache of keys, careful concurrency, a retention window.Payments, orders, transfers, sending messages, any create with side effects.
Dedupe on a natural business idNo extra header, uses data you already have (order id, UPI reference).Only works when such an id exists before the request.Event consumers, webhook handlers, imports.
Names you will hear
  • StripeIdempotency-Key header, keys kept 24 hours
  • RazorpayIdempotency on payouts and orders
  • RedisSET key NX EX for fast key claiming
  • PostgreSQLUnique constraint + ON CONFLICT DO NOTHING

In the interview#

Interviewer
Your payment service calls the bank and the call times out. What do you do?
The core question of payments interviews. The wrong answer is "retry".
You
I do not know whether the bank processed it, so I must not simply resend a new payment. Every call to the bank carries an idempotency key or transaction reference tied to this payment. I retry with the same reference, which the bank deduplicates, or I call its status API with the reference first. Our own API also takes an Idempotency-Key from the client, so the app's retries after its own timeout return the stored result. The payment stays in a pending state until we have a definite answer, and a reconciliation job settles anything stuck.
Ambiguity named, keys at both hops, status check, pending state, reconciliation.
Interviewer
Two identical requests with the same key arrive at the same millisecond on different servers.
You
Both try to insert the key; the unique constraint lets exactly one succeed. The other sees the key in progress and returns 409, and the client retries a moment later and gets the stored result.
Idempotent as is?
PUT /users/42/address with the full address
POST /wallet/42/credit {"amount": 100}
DELETE /cart/items/7
Sending an OTP SMS

Checkpoint#

Checkpoint

1. When should the client generate the idempotency key?

2. Why use a unique constraint instead of "SELECT then INSERT" to claim a key?

3. Your API is idempotent, but customers still get double charged. Likely cause?

Worth memorising
  • A timeout means "unknown", not "failed". Clients must retry, so servers must make retries safe.
  • GET, PUT, DELETE are idempotent. POST is not. Prefer setting state over incrementing it.
  • One key per intent, reused on every retry.
  • Claim atomically (unique constraint), 409 while in progress, 422 if the body differs, replay when done.
  • Pass keys downstream and reconcile anything stuck.
Say this in 60 seconds

After a timeout a client cannot know whether the server acted, so the only safe move is to retry, which means the server must make retries harmless. Reads, PUTs and deletes are naturally idempotent; creates, increments and sends are not. For those, the client generates an idempotency key once per user intent and sends it with every retry. The server atomically claims the key with a unique constraint, does the work, and stores the response under the key; a repeat with a completed key gets the stored response, one that is still in progress gets a 409, and the same key with a different body is rejected. Keys are kept for a window such as 24 hours. The key must also be passed to downstream systems like payment gateways, and crashes between the effect and recording it are handled by a transaction or a reconciliation job that checks the downstream status. UPI's transaction reference and status check are the same idea.