Idempotency Key
An idempotency key is an identifier a client attaches to a request so that the server can recognise a repeat of it as the same operation rather than a new one. It exists to solve one specific problem: a client that sent a request and never received a response cannot tell whether the server acted.
Standards describe the property, not the mechanism. RFC 9110 defines a method as idempotent “if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request,” and names “PUT, DELETE, and safe request methods” as the idempotent ones. A create or a charge is typically neither, so the client is left in the dilemma above.
The same specification then leaves the door open, and this is the sentence the mechanism is built on. A client “SHOULD NOT automatically retry a request with a non-idempotent method unless it has some means to know that the request semantics are actually idempotent, regardless of the method, or some means to detect that the original request was never applied.” An idempotency key is the usual way to supply the first of those means.
Distinguish this from the property itself: idempotency is what you want, and a key is one way to get it where the operation does not have it naturally.
What the server has to do
Accepting a key is easy; honouring it correctly is five decisions.
- Store the outcome, not just the key. On a repeat you must return the original result — the same identifier, the same status — because a client that gets a fresh “created” for a second call cannot tell it did not create a second thing. Storing the response means storing it durably and in the same transaction as the effect, or the crash window simply moves.
- Compare the request, not only the key. The same key arriving with a different body is a client bug, and the useful behaviour is to reject it rather than silently return the earlier result — otherwise a key collision quietly discards an operation. A hash of the meaningful request content, stored alongside the key, is enough.
- Scope the key. Per caller at minimum, usually per caller and operation. A global key space means one client’s identifier can collide with another’s.
- Decide how long it is honoured, and say so in the contract. Records cannot be kept forever, and a client retrying after expiry will create a duplicate — so the window should comfortably exceed the longest retry schedule any client is likely to use.
- Handle the concurrent case. Two requests with the same key can arrive at once, which is exactly what an aggressive retry produces. Something has to serialise them — a unique constraint on the key, or a lock — and the second one should wait for or reflect the first rather than proceeding in parallel.
Four outcomes, not two
The most consequential design question is what a stored record says when the operation did not cleanly succeed — and the tempting answer is dangerous. Cache permanent failures, do not cache transient ones so the retry can succeed sounds right and can charge a customer twice, because an HTTP error is not evidence that nothing happened. A payment can be captured and the response then fail with a 500.
So the record needs four states rather than two.
| State | What is known | What a repeat should do |
|---|---|---|
| Complete | The effect happened and the response was recorded | Return the stored response |
| In progress | Another attempt holds the key right now | Wait, or answer in progress — never start a second attempt |
| Confirmed not applied | The operation was rejected before any effect — validation failed, the request never reached the dependency | Safe to execute |
| Indeterminate | An attempt was made and the outcome is unknown: a 5xx, a timeout, a lost connection | Do not re-execute. Resolve the outcome first, then answer from it |
The fourth row is the one that gets collapsed into the third, and Stripe’s published behaviour is a useful reference precisely because it refuses to. Its idempotency layer “caches the result of POST mutations that result in server errors (specifically 500s, which are internal server errors), so retrying them with the same idempotency key usually produces the same result.” The guidance to clients is explicit about why a fresh key is the wrong move: “the client can retry the request with a new idempotency key, but we advise against it because the original key may have produced side effects.” And the instruction for how to treat such a result is one sentence worth adopting verbatim — “you should treat the result of a 500 request as indeterminate.”
The same reasoning covers the network case, which is the original motivation for the whole mechanism. When a connection fails, clients “are usually left in a state where they don’t know whether or not the server received the request. To get a definitive answer, they should retry such requests with the same idempotency keys and the same parameters until they’re able to receive a result from the server.” Retrying with the same key is how the ambiguity is resolved; retrying with a new one destroys the only handle on it.
Which leaves the server with a real obligation rather than a caching rule: an indeterminate record has to become determinate. Three routes do that — query the dependency for the operation’s own identifier, wait for its callback, or pick it up in a periodic reconciliation against the dependency’s records. Until one of them resolves it, the honest answer to a repeat is that the outcome is not yet known, and a client that receives it should keep asking with the same key rather than acting on either assumption.
Caching a permanent, pre-effect rejection is still reasonable and saves repeated work. The distinction that matters is not transient versus permanent — it is whether an effect may have occurred.
One boundary to state while defining the states: the key protects the operations you record under it. If the handler also charges an external processor, sends an email, or enqueues a downstream job, each of those needs to sit inside the same recorded decision or carry its own de-duplication. A key that guards the local row while the external call runs twice has moved the double-charge rather than prevented it.
Note also what a key does not make idempotent. RFC 9110 points out that the property “only applies to what has been requested by the user,” since “a server is free to log each request separately, retain a revision control history, or implement other non-idempotent side effects.” A replayed request may legitimately produce a second log entry; what it must not produce is a second charge — and any notification or downstream call triggered on the way needs its own de-duplication.
What the client has to do
Two rules, and the first is the one that gets broken.
Generate the key before the first attempt, and reuse it for every retry of that operation. A key generated per attempt provides nothing — each retry looks like a new operation, which is the situation you were trying to avoid. That means the key belongs to the business action, so it usually has to be created where the action is initiated and carried through whatever retry machinery sits below.
Never reuse a key for a different operation. A key derived from something stable about the action — an order identifier, a request record in your own database — is more reliable than a random value held in memory, because it survives the client restarting mid-retry.
The surrounding retry behaviour still needs care, and RFC 9110 has two lines worth heeding: “a proxy MUST NOT automatically retry non-idempotent requests,” and “a client SHOULD NOT automatically retry a failed automatic retry.” Spacing the attempts is the other half — see Retry-After and backoff — since a key makes repeats safe for correctness, not free for the server.
Four ways it is implemented wrongly
- The key is recorded but the response is not. The repeat is rejected as a duplicate with an error, and the client — which still does not know whether the original succeeded — treats the error as failure.
- The key is written after the effect, in a separate step. A crash between them leaves the effect applied and the key unrecorded, so the retry duplicates it. The two writes have to commit together.
- The key is optional and rarely sent. A mechanism only clients who read the documentation use protects only them. Where the operation charges money or creates something visible, requiring the key is the safer contract.
- The expiry is undocumented. Clients retry on their own schedules, and one retrying an hour later against a ten-minute window creates the duplicate the mechanism was bought to prevent.
Where an operation is naturally idempotent, none of this is needed: a PUT to a known identifier, or a create whose uniqueness is enforced by a business key the client already knows, is safe to repeat without extra machinery. The key earns its complexity for operations that have no such identifier before the server assigns one — which is why creates and payments are where it appears. How this fits with the rest of an interface’s obligations is worked through in You Cannot Un-publish an API.
References: RFC 9110, HTTP Semantics, Idempotent Methods; Stripe Documentation, Advanced error handling (checked September 2026).
Discover more from Insightful Data Lab
Subscribe to get the latest posts sent to your email.
