Published 03 Nov 2026

Idempotency-Key: Same Key, Same Outcome

A POST that timed out leaves the caller with nothing safe to do. An Idempotency-Key header, backed by a key derived into the data and a database that rejects duplicates, makes the retry safe.

A caller’s POST /tickets timed out, or the Wi-Fi dropped before the 201 arrived. Did the ticket get created?

If they retry and it did, there are two tickets. If they don’t and it didn’t, there are none. Your API is fine. The caller is stuck, and there is nothing they can safely do.

An Idempotency-Key header closes the hole. It’s an IETF draft, and it’s what Stripe, PayPal, and Adyen have done for years. The caller picks a key per operation and sends it on every retry of that operation. The API promises: same key, same outcome, at most one ticket.

Two layers

  1. The key in the data. Derive the record’s ID from the key, so a retry finds the record the first call made. This holds across restarts and across instances.
  2. A response cache in front. A retry gets back exactly the first call’s response, without running the handler. A reused key with a different body is rejected. So is a second call while the first is still running.

A failed create should not burn the key. If the first attempt failed, the retry should get a real second attempt.

Concurrency needs the database’s help

An in-process lock stops two copies of a request racing on one instance. On three instances, both copies can still look up the record, see nothing, and add it.

With the ID derived from the key, they at least add the same ID, so the database’s primary key turns the second insert into a conflict instead of a duplicate. That is the real guarantee: an insert that fails on a duplicate key (INSERT with a unique constraint, or IfNotExists in Cosmos DB). Not a “look, then add.”

A shared response cache

To make replays work across instances, the response store moves to Redis or the database. The “begin” step becomes an atomic set-if-not-exists with an expiry (SET key value NX EX 86400 in Redis). Store the status, headers, and body, not an IResult object. Objects don’t serialize across machines.

Which keys, and who makes them

The client makes the key, once per operation, not per request. Generate a GUID when the user clicks “Submit,” and send the same one on every retry of that click. A key per HTTP request defeats the point.

A key derived from the content (a hash of the body) is tempting. But then two genuinely identical tickets can never be created.

What about PUT, PATCH, and DELETE?

HTTP already defines PUT and DELETE as idempotent. PATCH isn’t, in general, but an If-Match precondition makes a retried PATCH fail with 412 instead of applying twice. Different route, same safety. POST is the one that needs a key.

The point

“Retry only reads” is the safe default because writes might have happened. An idempotency key is what lets you relax it. A create that carries a key can be retried by the client or by an outbound resilience pipeline, because a repeat can’t create anything new.