Making POST idempotent comes down to four decisions: what the key is scoped to, how you arbitrate two concurrent requests carrying the same key, what goes into the stored response snapshot, and how long keys live. The first decides whether you leak data across tenants, the second whether you double-write under load, the third whether a retry gets the same answer, the fourth whether the client's retry budget fits inside your window. As for the Idempotency-Key header itself, the headline first: the IETF standardization effort has stalled. draft-ietf-httpapi-idempotency-key-header-07 was published on 15 October 2025 and expired on 18 April 2026. The datatracker lists it as expired and archived; it never became an RFC. Whatever you ship today, you are implementing a vendor convention — and the vendors disagree on the details that matter.

The standards situation, precisely

It is tempting to read "expired Internet-Draft" as routine — drafts lapse and get resubmitted all the time. Not here. Four independent signals point at a document that stopped moving:

  1. It failed to exit Working Group Last Call. It entered WGLC on 2024-01-08 and was moved back to plain "WG Document" state on 2024-06-07. A round trip, not progress.
  2. The source repository is quiet. github.com/ietf-wg-httpapi/idempotency has no substantive commits after February 2025, when draft-06 was finalized.
  3. It is not on the working group's charter milestones. The group's still-moving Internet-Drafts include patch-byterange, ratelimit-headers, and rest-api-mediatypes.
  4. Its normative references have rotted. Draft-07 cites RFC 8941 for Structured Fields — obsoleted by RFC 9651 in September 2024 — and RFC 7807 for problem details, obsoleted by RFC 9457 in July 2023. A document being shepherded toward publication does not carry two stale normative references through a fresh revision.

A fifth fact even people who know the draft tend to miss: Idempotency-Key is still not in the IANA HTTP Field Name Registry. Section 3 only proposes adding it; as of this writing the registry holds 257 field-name entries and not one idempotency-related entry. Practically, intermediaries — CDNs, WAFs, API gateways — have no built-in knowledge of this header. Do not assume it survives the path to your origin; test it through your production ingress.

None of this makes the draft worthless — it is a decent summary of what the industry converged on. What you must not do is write "conforms to the IETF standard" in your API docs. There is no standard. Say "follows the widely-adopted Idempotency-Key convention" and document your own semantics in full, because those are the only contract your callers have.

A syntax detail that will bite interop

Section 2.1 specifies Idempotency-Key as an Item Structured Header whose value MUST be a String. Under structured field rules a String is double-quoted, and the draft's example shows that:

Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"

Meanwhile Stripe — which the draft's Implementation Status section lists as a reference implementation — documents the unquoted form:

Idempotency-Key: KG5LxwFBepaKHyUD

In the wild, almost nobody quotes. Accept both — strip surrounding double quotes before comparison and storage. Otherwise one client switching SDKs generates what your system sees as two distinct keys, and the retry that was supposed to be deduplicated executes twice.

Scope: the key is never just the key

The draft says only that "uniqueness of the key MUST be defined by the resource owner," handing you the most dangerous design decision with no guidance. What you store is not a key. It is a tuple:

(tenant_id, operation, idempotency_key)

Each component earns its place differently.

tenant_id is a security boundary, not an optimization. The draft's own Security Considerations name the risk: if an implementation permits low-entropy keys, an attacker can guess keys and fetch cache entries belonging to other clients. Adyen is more direct — it tells you to generate v4 UUIDs specifically "to prevent two API credentials under the same account from accessing each others responses." But guessing is not the only attack surface. Without a tenant dimension you need no attacker at all: one customer using a predictable key like order-1001 and another independently choosing the same string hands one of them the other's response body, PII included. UUIDs reduce the probability; the tenant dimension eliminates the class.

operation prevents semantic crosstalk. Clients reuse one key across a multi-step flow — create the order, then initiate the payment — because to them it is one logical transaction. Without operation in the tuple, the second call looks like a replay of the first, and you return the order-creation body to a payment request.

But think about the granularity. If your intent is "this key may be used for exactly one operation account-wide," putting operation in the primary key defeats that — it gives the client room to reuse one key against two endpoints. To catch client mistakes, key on the route. To forbid cross-endpoint reuse, drop operation from the primary key and store it as a validated column, so a mismatch is an error rather than a second slot.

Whatever tuple you land on must be backed by a unique constraint in the database, not a lookup followed by an insert.

Fingerprints: same key, different body

The key answers "is this the same intent?" The fingerprint answers "has that intent's content changed?" You need both.

Section 2.4 offers several constructions: a checksum over the whole payload, over selected elements, field-by-field comparison, or a request digest (for how digests and replay windows are built on the receiving side, see Webhook Signatures and Replay Protection). The algorithm is not where implementations go wrong — canonicalization is:

  • Hashing raw request bytes is simplest and safest against tampering, but brittle: a client upgrading its JSON library and emitting different key ordering has a legitimate retry rejected. Fine for first-party callers with pinned serializers; a support burden for third parties.
  • Hashing canonicalized parsed JSON — sorted keys, normalized numbers — is more forgiving, but floating-point serialization differences will hurt. Represent money as strings or integer minor units and the problem disappears.
  • Exclude anything that legitimately varies: User-Agent, trace IDs, client timestamps, signing nonces. A fingerprint that includes a trace ID rejects every retry, turning your safety mechanism into an outage.

On mismatch the draft says return 422 Unprocessable Content, citing RFC 9110 §15.5.21. Follow it. Format the body as application/problem+json per RFC 9457 — not the draft's obsolete RFC 7807 citation.

One inversion worth stating, because it looks clever and is not: do not derive the key from a payload hash. The key represents client intent, and two legitimate orders with identical amounts and line items are two intents — a customer buying the same coffee twice. Content-hashing collapses them into one and silently drops a real order.

Concurrency: an atomic conditional write, or nothing

This is the only genuinely hard part, and where most implementations are quietly broken:

row = db.get(key) # miss
if row is None:
 result = do_work() # two requests both reach here
 db.put(key, result)

There is a window between read and write: two concurrent requests both see a miss and both execute. An application-level mutex does not fix it either, because it does not span processes or instances. The claim step itself must be atomic.

In Postgres:

INSERT INTO idempotency (tenant_id, operation, key, state, lease_until)
VALUES ($1, $2, $3, 'in_flight', now() + interval '60 seconds')
ON CONFLICT (tenant_id, operation, key) DO NOTHING
RETURNING id;

A returned row means you own execution. No row means someone else owns it or already finished — read that row and act on its state. The Redis equivalent is SET k in_flight NX PX 60000. Either way the primitive is a conditional write the storage engine serializes for you. After the claim, the row is a two-state machine: in_flightcompleted.

What do you return when you lose the race? The draft says 409 Conflict, and Stripe's status table defines 409 as a request that "conflicts with another request (perhaps due to using the same idempotent key)." That is right. But Adyen returns HTTP 422 or 409 with error code 704 for the same situation — one semantic, two status codes across two major providers. A client talking to more than one provider must not branch on 409 alone. Include Retry-After on your own 409s.

An opinion that runs against most people's first instinct: do not block the second request waiting for the first. It feels like better UX — the caller gets a real result instead of an error. What it actually does is convert a client-side retry storm into server-side connection pool exhaustion, since parked requests accumulate with the retry count, each holding a connection and a worker. Fail fast and let the waiting happen on the client.

Leases and crash recovery

The lease_until column exists so a process dying mid-execution does not lock a key forever. Once the lease expires, that record is indeterminate: maybe nothing happened, or maybe the side effect already reached a payment network and the crash preceded recording it. You cannot tell from your own database — that is the entire problem, and no engineering removes the decision that follows:

  • If the downstream operation is itself idempotent — the external API also accepts an idempotency key, or the write is contained in one transaction — a new request can take over the lease and re-execute safely.
  • If the downstream is not idempotent and money or fulfillment is involved, fail closed. Mark the record indeterminate, return 409 or 503, route it to reconciliation. A stuck request a human resolves beats a duplicate charge.

Size the lease to cover P99 processing time plus margin, not the mean. A lease shorter than your tail latency turns slow requests into spurious takeovers — the exact failure the mechanism exists to prevent.

The response snapshot: what to store, and what never to

A replay must return what the first execution returned, so persist a snapshot: status code, a curated set of headers, and the body.

Curated is the operative word. Do not store the full header set and replay it verbatim. Date goes stale, Set-Cookie crosses sessions between users, replayed trace IDs corrupt your distributed tracing because two requests now claim the same span, and live quota headers like RateLimit-* report numbers that were true yesterday. Whitelist Content-Type and genuine business headers; regenerate the rest at replay time.

Then mark the replay. Stripe sets Idempotent-Replayed: true. Copy this. It is the only way a client — or your observability stack — distinguishes a real execution from a cached one. Without it your QPS, latency, and success-rate metrics blend two different things.

Which failures belong in the snapshot

This judgment call separates a working implementation from an incident:

Deterministic business failures — store them. Card declined, insufficient funds, business rule violation. Retrying produces the same outcome by definition, so caching is honest. Stripe is explicit: once an API method has begun execution the result is cached regardless of what it was, and a request that returned 400 returns the same 400 to a retry with the same key. Changing the request requires a new key.

Infrastructure failures — never store them. 429, 401, 502/503/504 are transient. Pinning them into the snapshot means the client can never retry back to success, converting a five-second blip into a permanent failure. Stripe corroborates the reasoning: their rate limiter runs before the idempotency layer, so the same key can produce a different result on a 429, and likewise for a 401 from a missing API key.

5xx — the hard case. Stripe caches 500s. The logic is sound: since you cannot know whether the side effect occurred, a stable 500 is safer than blind re-execution. But that argument only holds if you can distinguish a 500 your business logic deliberately returned from a process that died halfway. The first can be snapshotted. The second never gets the chance — it leaves an in_flight row with an expired lease, which is the fail-closed path above, not a cached response. If you cannot tell these apart, do not cache 5xx at all.

Two boundaries worth setting explicitly:

  • Endpoints with large or streamed bodies should not support idempotency keys. Your snapshot store quietly becomes an object store with none of the properties of one. Set a byte ceiling and document the endpoint as out of scope.
  • For 202 Accepted plus async work, snapshot the 202 and job ID — nothing more. The key covers "did I enqueue this?"; the job ID covers "what happened to it?" Conflating them means a retry five minutes later returns stale in-progress state as if fresh.

Where the middleware sits

The 429 rule is really a placement constraint, and getting it wrong invalidates everything above. The idempotency layer must sit after authentication (you need tenant_id for the storage tuple — put it earlier and you are back to the cross-tenant leak that opened this article), after rate limiting (otherwise 429s land in your snapshot store — see API Rate Limiting: RateLimit Headers, Algorithms, and Redis for what that layer should emit), and before business logic.

Lifetime: TTL must exceed the client's retry budget

The draft says only that a resource "MAY require time based idempotency keys" and "SHOULD define such expiration policy" — which is to say, nothing. Real implementations diverge sharply:

Retention Max length Scope
Stripe removable after at least 24 hours 255 characters within your account
Adyen valid at least 7 days after first submission 64 characters company account level

A 7x spread in retention and 4x in key length. Two consequences if you aggregate providers: your effective retry window is the shortest one downstream, not the one you configured, and your maximum key length is the smallest one downstream — generate 255-character keys and your Adyen calls fail validation.

For your own service, TTL must be at least (max client retries × max backoff interval) plus headroom for a human retrying from a dashboard an hour later. 24 hours is a defensible floor; anything touching money should lean toward 7 days. Note also Adyen's caveat that cross-regional endpoints do not deduplicate across regions — in an active-active deployment the replication lag of your idempotency store is your double-write window. Either route a given key to a fixed partition, or accept the window knowingly and cover it in reconciliation.

Finally: do not accept idempotency keys on GET or DELETE. Both are already idempotent under RFC 9110, and accepting the header implies a guarantee you are not providing. Stripe says the same — the header has no effect there.

Implementation checklist

  • Unique constraint on (tenant_id, operation, key), not an application-level read-modify-write
  • Claim with INSERT ... ON CONFLICT DO NOTHING or SET NX, always with a lease expiry
  • Concurrent same-key returns 409 plus Retry-After; never block and wait
  • Fingerprint covers business-semantic fields only; mismatch returns 422 with RFC 9457 problem+json
  • Snapshot response headers by whitelist; mark replays with Idempotent-Replayed: true
  • Cache deterministic 4xx; never cache 429, 401, or 503
  • Middleware order: after auth, after rate limiting, before business logic
  • Parse the key value accepting both quoted and unquoted forms
  • TTL exceeds the client's total retry budget; publish the exact number and scope
  • Document it as "the widely-used Idempotency-Key convention," never as an IETF standard