Every team I have worked with eventually hits the same incident. A client times out, retries, and a customer ends up with two charges, two emails, or two of whatever the endpoint creates. The post-mortem proposes “we should add idempotency keys.” The work gets scoped, deprioritized, and then the next incident happens.
Idempotency is not a retrofit. It is part of the contract. Treating it as a feature you ship later means every consumer downstream of your API is silently coupled to the assumption that retries are unsafe, and that assumption leaks into queues, jobs, and human runbooks.
The HTTP spec defines idempotency in terms of side effects: making the same request N times has the same observable effect as making it once. GET, PUT, and DELETE are idempotent by definition. POST is not, and that is where most of the pain lives.
Making POST safely retriable requires the server to recognize “I have seen this request before” and return the original result instead of doing the work a second time. That recognition needs:
Skip any of these and the behavior is a footgun.
The convention that has held up best is an Idempotency-Key header carrying an opaque, client-generated value (typically a UUIDv4 or v7). Two notes from experience:
(tenant_id, idempotency_key), never just the header value.The server-side record needs three fields: the key, a fingerprint of the request body, and the response (status code plus body) of the first successful processing. On a repeat request:
409 Conflict. The client is reusing a key for a different operation, which is a bug they want to know about.409 or block briefly. The right choice depends on whether your clients prefer “fail fast” or “the second call wins.”Lifetime is the part most designs get wrong. Twenty-four hours is too short for some workflows (batch retries the next day) and too long for others (the key table grows unbounded). Pick a value tied to your retry policy, document it in the API reference, and make the TTL visible in the response (Idempotency-Replayed: true, or similar).
The idempotency layer protects the API surface. It does not, by itself, protect the database. Two patterns work well together:
(customer_id, external_ref), one user per email). The constraint is the last line of defense if the idempotency layer fails, the cache is empty, or a buggy client bypasses the header.POST triggers an email, a webhook, or a queue message, those should be derived from the committed row, not produced inline. Otherwise a retry that hits the cache will skip the side effect, and a retry that misses the cache will duplicate it.PUT as automatically safe. PUT is idempotent in terms of state, but the side effects can still fire on each call. If PUT /users/123 sends a welcome email on creation, you need the same idempotency logic.If your API has any POST that creates a resource, sends money, or triggers an external effect, treat idempotency as part of v1 of that endpoint. The cost is a header, a small table, and a few branches in the handler. The cost of adding it after the fact is every consumer’s retry logic and a handful of customer-visible incidents.
It is one of the highest-leverage things you can put in an API contract. Worth doing once, properly, up front.