Idempotency

Idempotency keys, storing the first response, the race between two identical requests, and the retry that must not charge twice.

6 min read🔗 REST API Engineering

A client sends a POST /charges, the connection times out at four seconds, and the client retries. The first request arrived. The customer is charged twice.

This is not a rare failure. It is the normal behaviour of an unreliable network meeting a method that HTTP says is not idempotent, and the fix is a protocol you have to build.

The failure, in two requests

plaintext
$ curl -X POST /charges -d '{"amount":2000}'
{"chargeId":"ch_1","amount":2000,"totalChargesSoFar":1}
 
$ curl -X POST /charges -d '{"amount":2000}'
{"chargeId":"ch_2","amount":2000,"totalChargesSoFar":2}

Two charges, two thousand each. The server has no way to tell that the second request was a retry of the first rather than a customer deliberately paying twice — because nothing in it says so. Same method, same path, same body: those are equally consistent with both stories.

The client is the only party that knows. It knows it sent one logical request and is now sending it again. So the client must say it, and the mechanism is a header.

The Idempotency-Key header

The client generates a unique key per logical operation — not per HTTP attempt — and sends it with every retry of that operation:

plaintext
POST /charges
Idempotency-Key: 4f1c-9ab2
Content-Type: application/json
 
{"amount": 2000}

The server keeps a record of keys it has completed, along with the response it sent:

plaintext
$ curl -i -X POST /idempotent-charges -H 'Idempotency-Key: 4f1c-9ab2' -d '{"amount":2000}'
HTTP/1.1 201
{"chargeId":"ch_3","amount":2000,"totalChargesSoFar":3}
 
$ curl -i -X POST /idempotent-charges -H 'Idempotency-Key: 4f1c-9ab2' -d '{"amount":2000}'
HTTP/1.1 200
Idempotent-Replay: true
{"chargeId":"ch_3","amount":2000,"totalChargesSoFar":3}

One charge. The second request got the first request's response, byte for byte, and totalChargesSoFar did not move.

Three details in that exchange are deliberate:

  • The replay returns the stored response, not a fresh computation. The client gets the same chargeId and can proceed exactly as if its first attempt had succeeded — which, from the server's point of view, it did.
  • The status differs — 201 then 200 — because the resource was created once. Some APIs return 201 both times to keep the client simple. Either is defensible; say which in your documentation.
  • Idempotent-Replay: true is not required by anything. It is a courtesy that makes debugging and support far easier, and it costs one header.

Storing the response is the hard half

The key alone is not enough. A record has to hold what happened, because the point is to replay it:

ColumnWhy
keythe client's key, unique
endpoint + methodso one key cannot be reused across operations
request_fingerprinta hash of the body
statusIN_PROGRESS / COMPLETED
response_status, response_bodywhat to replay
created_atfor expiry

The request fingerprint is what stops a real bug. If a client reuses a key with a different body, that is not a retry — it is a mistake, and replaying the old response would hide it. The correct answer is 422 (or 409) saying the key was used with different parameters. Silently returning the old response there is worse than not having idempotency at all, because it looks like success.

The race nobody tests

Two identical requests arriving at the same moment, on two instances, is the case that makes naive implementations wrong:

plaintext
instance A: look up key 4f1c  -> not found
instance B: look up key 4f1c  -> not found      <- both saw nothing
instance A: charge, insert record
instance B: charge, insert record               <- two charges again

Checking then acting is a race, and load is exactly when retries are most common. Two fixes, and the first is the one to reach for:

Let the database decide. A unique index on key, and insert the record before doing the work, in state IN_PROGRESS. The second instance's insert fails on the constraint, and that failure is the signal — it means somebody else owns this key. This is the same principle as the validation lesson's note about uniqueness: the index is the only thing that actually decides.

Then handle the in-flight case. If the record exists and is still IN_PROGRESS, the first request has not finished. Return 409 Conflict — "this operation is already running, retry shortly" — rather than waiting, which turns one slow request into two.

Expiry, and what the key is for

Records cannot live forever. Twenty-four hours is the common window and the reasoning is simple: it must be longer than any client will plausibly retry, and short enough that the table stays small. Stripe uses 24 hours; matching a convention clients already know is worth something.

After expiry, the same key is a new operation. That is correct — nobody retries yesterday's request — but it means the key must be genuinely unique per operation, which is why the client generates it fresh for each new intent, typically a UUID. A key derived from the request body is a trap: two customers deliberately buying the same item for the same amount would collide.

Which endpoints need this

Not all of them, and knowing which is most of the design:

  • GET, HEAD, PUT, DELETE are already idempotent by HTTP's definition. PUT /orders/42 with the same body twice leaves the same state.
  • POST that creates something — an order, a charge, a message — needs a key.
  • POST that is really a search does not.

And there is a design answer that removes the problem instead of solving it: let the client choose the id. PUT /orders/{clientGeneratedId} is idempotent for free, with no key table and no expiry policy. It does not suit every domain — you are trusting the client with your identifier space — but where it fits it is less machinery than everything above.

Progress is saved on this device and to your account when signed in.