The idea: a retry you cannot avoid

A client sends POST /v1/charges for $50 and waits. No reply comes. Was the request lost, or was the card charged and only the reply lost? From the client's side the two look the same. Giving up leaves the order unpaid, so the client must retry. But a plain retry of a POST charges the card a second time (Demo: reply lost, retry without a key).

Two sequence diagrams of a client, a Payment API and a card. Left, no key: the first POST charges ch_1, the reply is lost, the retry charges ch_2, so the card is charged $100. Right, with Idempotency-Key k-7f3a: the first POST stores the key and charges ch_1, the reply is lost, the retry with the same key finds the key and replays 201 ch_1, so the card is charged $50
The same lost reply: without a key the retry is a second charge; with the same key the server answers from its table and charges once.

An idempotency key fixes this. The client makes a unique id once per operation (one purchase), sends it in the Idempotency-Key header, and sends the same key on every retry. The server remembers each key with its result. A request whose key it has already finished is not done again; the server sends back the result it saved. An operation is idempotent when doing it twice has the same effect as doing it once, and the key makes a POST idempotent (Demo: reply lost, retry with the same key).

On the canvas, each hop between the client and the API takes one tick; the client gives up waiting after 3 ticks and tries at most 3 times. The charges table on the right is what the customer sees on the card statement.

What the server stores

The Payment API keeps a table idempotency_keys: the key, a fingerprint of the request (here, the amount), a status and the saved response. The key column is unique. For each request with a key it does one of four things:

the key is…the API…reply
not in the tableINSERTs it as in progress, charges, saves the response, marks it completed201 Created
completed, same requestreplays the saved response, no charge201 (header Idempotent-Replayed: true)
in progressdoes nothing; the first attempt is still running409 Conflict
there, but for another requestrefuses; a key belongs to one request422 Unprocessable Content
The life of one key row: a new key is inserted as in progress, then the charge runs, then it becomes completed with response 201 ch_1. A repeat while in progress gets 409 Conflict. A repeat after completed gets the saved 201 replayed. A repeat with a different amount gets 422
One row per key: in progress while the work runs, completed with the saved answer afterwards; every repeat is answered from the row.

Inserting the key before the work matters. If two copies of a request arrive at the same moment, the unique column lets only one INSERT win; the other sees the row and stops. Saving the response in the same row means a replay is byte-for-byte the first answer, including the charge id ch_1.

Same key, still running: 409

A retry can arrive while the first attempt is still working, for example when the card processor is slow (Demo: slow charge, the retry gets 409). The row says in progress, so the API answers 409 Conflict and charges nothing. The client waits a little and retries again; by then the row is completed and the saved 201 comes back. The first attempt's own 201 goes to a connection the client already gave up on. Without a key, the same timing gives two charges.

Same key, different request: 422

The key is bound to the request it first came with. A client bug that sends the same key for a new $60 order must not get the $50 answer back, and must not be charged either. The server compares the fingerprint, sees a different body and answers 422 (Demo: same key, different amount (422)). Stripe answers this case with a 400 idempotency_error.

Where the key comes from

The client makes the key, usually a random UUID v4, when the user starts the operation, and stores it with the order so a retry after a crash or a page reload sends the same one. A new key on every attempt is a common bug: each retry looks new to the server and is charged again (Demo: a new key on every retry (bug)).

  • Keys are kept only for a while: Stripe keeps them for 24 hours. A retry after that is a new request.
  • Only failures that leave no doubt about the outcome should be stored as final; a 5xx that might have charged is kept as in progress or retried by the server itself.
  • GET, PUT and DELETE are idempotent by definition (RFC 9110), so they need no key. POST and PATCH are not.

See also Delivery Semantics for the same idea in a message queue (a dedup key in the consumer), Transactional Outbox and Inbox for an inbox table that skips repeated events, Monolith vs Microservices for a lost reply between services, and Saga vs Two-Phase Commit for why every saga step must be safe to retry.

How long to wait before each retry, and why to add jitter: see Retry, Backoff and Jitter.

What the page leaves out

Retry timing (exponential back-off and jitter); a server crash in the middle of the work, which needs the charge and the key row in one transaction or a recovery step that finishes in progress rows; passing the key on to the card processor so its side deduplicates too; locking instead of a 409; how the request fingerprint is computed (a hash of the body); key expiry and clean-up jobs; and keys spread over several API servers, which need one shared store.