What the client has to give you
You cannot deduplicate a retry you cannot recognise, and a timed-out client looks identical to a new one at the socket level. So the first move is a contract: the client generates a unique value per logical operation, sends it in a header, and reuses that same value on every retry of that operation. Idempotency-Key is the conventional name, popularised by payment APIs rather than mandated by a standard, and a UUIDv4 per user action is a perfectly good generator.
The key must be minted at the point the intent is formed, not at the point the HTTP call is made. If your retry wrapper generates a fresh key on each attempt, you have built a very elaborate way of charging someone twice. This is worth saying out loud in an interview because it is the part that lives in client code, outside the endpoint you were asked about, and it is where real systems break.
Idempotent methods versus an idempotent implementation
RFC 9110 declares PUT, DELETE, and the safe methods idempotent, and POST not. Candidates often stop there, which misses what the specification is actually saying. Idempotency in RFC 9110 is a property of the intended effect on the server of sending a request more than once, and the spec is explicit that this is about what the client requested, not a promise the origin server keeps. Nothing stops you writing a PUT handler that does balance = balance + delta, and nothing about the method name will save you. The declared idempotency of PUT is a licence for intermediaries and clients to retry it automatically; honouring that licence is your job.
The corollary is the useful half. POST is non-idempotent by declaration, and that declaration is about the method, not your endpoint. An idempotent POST implementation is entirely legitimate, and a keyed create is usually a better design than contorting the operation into a PUT just to inherit a label. Note also that idempotency constrains state, not responses: a second DELETE correctly answering 404 is still idempotent, because the resource is gone either way.
Where the dedupe record lives
Put it in the same database as the effect, in a table with a unique constraint on the key, and write it in the same transaction that performs the work. Everything else about the design follows from that one decision.
The reason is what happens when the process dies between the two writes. Split them across stores — key in Redis, payment in Postgres — and you have two orderings, both wrong. Record the key first and crash before the insert, and the retry is suppressed against an effect that never happened; the payment is silently lost, and the client's own retry logic can no longer rescue it. Perform the effect first and crash before recording the key, and the retry sails through and charges twice. A single transaction collapses both failures: either the effect and its dedupe record are both durable, or neither exists and the retry is free to try again.
BEGIN;
idempotency_keys (key, endpoint, request_hash, created_at)
(, , , now());
payments (id, account_id, amount_minor, currency)
(, , , );
idempotency_keys
response_status ,
response_body
key ;
;
Store a hash of the request body alongside the key. When the same key arrives with a different payload, that is a client bug rather than a retry, and the correct behaviour is to reject it with a 4xx rather than to serve the earlier unrelated response.
Check-then-insert is not a lock
The single most common broken implementation is SELECT the key, and if absent, do the work and insert. Under any real concurrency both duplicates read an empty result before either writes, and you have reintroduced exactly the double-effect you were hired to prevent. The unique index is the only primitive here that is safe, because uniqueness is decided at write time by a component that serialises writers. Catch the constraint violation and treat it as "someone else already did this" — the happy path is the exception handler.
The retry that arrives mid-flight
In the single-transaction design, a duplicate's INSERT blocks on the unique index until the first transaction resolves, then either fails with a violation (first request committed, so replay the stored response) or succeeds (first request rolled back, so proceed). Blocking is correct but you must bound it: set a short lock timeout and, when it fires, return 409 Conflict with a machine-readable error saying the operation is in progress and safe to retry.
What you must never do is invent an answer. Returning 200 with an empty body, or 202 with a fabricated identifier, converts a recoverable ambiguity into a client that believes something exists which may not. Stripe frames concurrent conflicts the same way, as an error the caller is explicitly permitted to retry, precisely because no result was saved.
When the effect is not transactional — an external gateway call — you cannot have the atomicity, so you buy an approximation. Commit the key in a pending state first, carrying a lease and an owner, then perform the call, then commit the outcome. A duplicate arriving during the lease gets 409 immediately without blocking. The price is that a crashed request leaves a pending row that a reaper must reclaim once the lease expires, and the reconciliation is against the provider's own idempotency key, not yours.
Expiry
Keys must expire, or the table grows without bound and its index eventually dominates your write latency. The window is not arbitrary: it has to outlive the longest retry horizon any client has, including a mobile app that retries a queued request the next morning. Stripe's published behaviour is to remove keys automatically once they are at least 24 hours old, which is a reasonable default to quote. Expire earlier than your clients retry and you have an endpoint that is idempotent in testing and duplicating in production.
State the consequence plainly, because it is what a staff-level answer adds: idempotency here is a bounded guarantee, not an absolute one. Past the retention window the endpoint is exactly as duplicative as it was before you started, so anything needing permanent uniqueness needs a business-level constraint too, such as a unique index on the invoice number.
The dedupe record and the effect must commit or vanish together, which means they belong in one transaction against one store, with a unique constraint doing the deduplication. Split them across two stores and you have not built idempotency, you have chosen which of double-charging and silent loss you would rather explain.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).