Idempotency keys: how to make a retried POST do its job once A developer outlined a four-branch server-side implementation of the IETF draft Idempotency-Key HTTP header, which lets retried POST requests execute only once by having clients send a stable key per logical operation and servers store and replay the original response. The approach requires an atomic key claim to prevent duplicate execution across instances, returns 409 Conflict with Retry-After while an original request is still in flight, and rejects same-key/different-body retries with 422 after comparing a canonicalized body hash. The developer noted that AI agents retry more aggressively than human-written clients, making idempotency handling more pressing for APIs they call. The client did nothing wrong, which is the part that took me a while to accept. It sent a request, got no answer, and sent it again, which is exactly what every HTTP client is told to do. The problem is that "no answer" covers two situations that look identical from the client's side: either the request never arrived, or it arrived, did its work, and the response got lost on the way back. The client can't tell which. So it retries, which is correct, and the server does the work twice, which is not. Timeouts and flaky networks have always caused this. What's newer is AI agents calling your API, and in my experience they retry harder than any human-written client, because a model that sees an error will usually just try again with the same arguments until something works. An operation is idempotent if doing it once and doing it five times leave the system in the same state and give the client the same answer. "Set the user's email to X" is idempotent. "Add 5 to the balance" is not. HTTP sorts its methods this way already. GET, HEAD, PUT and DELETE are defined as idempotent. Deleting something already gone returns 404 rather than 204, so the response changes between calls, but the state doesn't, and the state is what the definition is about. POST has no such guarantee, and can't really, because POST means "process this" and the server decides what processing means. Create an order, send an email, charge a card. The awkward thing is that the operations that actually matter in an API, the ones involving money or messages, are nearly all POSTs, so the protocol gives you nothing where you need it most. The fix has been in payment APIs for years and is now an IETF draft, "The Idempotency-Key HTTP Header Field," from the HTTPAPI working group. It's still a draft and hasn't become an RFC, but the design is simple enough that it's the convention regardless. The client generates a unique key, usually a UUID, for each logical operation. "Charge this customer $5 for this order" gets one key, and every retry of that operation sends the same key in an Idempotency-Key header. The server uses it to recognise a request it has already seen and return the original answer instead of doing the work again. The word that matters there is "logical." The key identifies what the user is trying to accomplish, not the individual HTTP request. I've seen clients generate a fresh key inside the retry loop, so every attempt carries a different one, and at that point you've implemented the header without any of the behaviour. Each unsafe request with a key lands in one of four states, and the implementation is basically these four branches. GET and HEAD skip the whole thing; they're already idempotent and storing their responses would just be a cache with worse semantics. First arrival: claim the key, run the handler, store the response status and body. The claim has to be atomic, because two copies can arrive within the same millisecond on two instances, and if the claim is a read followed by a write, both read "nothing here" and both run. Retry after the first finished: return the stored response, run nothing. I add an Idempotent-Replayed: true header so I can tell real responses from replays in logs. Retry while the first is still running: there's nothing to replay yet. Return 409 Conflict with Retry-After and let the client ask again in a second. This feels like it should be rare and it isn't, for reasons I'll get to in testing. Same key, different body: something is wrong on the client. Hash the body on the first request, store the hash, compare on every retry. A mismatch gets 422 Unprocessable Content, which is what the draft recommends. Hash a canonical form of the body, with object keys sorted, or a client that re-serialises {"a":1,"b":2} as {"b":2,"a":1} on retry gets a 422 it didn't earn. Keys expire. 24 hours is the usual window, long enough for any realistic retry schedule and short enough that the store doesn't grow forever. The middleware depends on a small interface over Redis rather than the ioredis client directly, so tests can swap in a fake. It also makes it obvious the whole thing needs three commands. python import { createHash } from "node:crypto"; import type { Request, Response, NextFunction } from "express"; declare global { namespace Express { interface Request { userId?: string; // set by auth middleware idempotencyKey?: string; // set here, for handlers to forward downstream } } } // The subset of ioredis we use. Lets tests pass a fake. export interface KeyStore { set key: string, value: string, ...args: string | number : Promise<"OK" | null ; get key: string : Promise