A client times out and retries your POST /payments call. How do you make that endpoint idempotent?
Take an idempotency key from the client and store it in a uniquely-constrained row written in the same transaction as the effect, so the dedupe record and the work commit together. Replay the stored response on a repeat, refuse a retry arriving mid-flight, and expire keys past the retry horizon.
What the interviewer is scoring
- Does the candidate put the dedupe record in the same transaction as the effect, rather than in a cache beside it
- Whether a unique constraint enforces the deduplication, or a SELECT that races under concurrency
- That they separate what RFC 9110 declares about a method from what their handler does with it
- Whether an in-flight duplicate gets a distinguishable answer instead of a fabricated success
- Does key expiry come up unprompted, along with the same key arriving with a different payload
Answer
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;
-- The unique constraint IS the deduplication. A concurrent duplicate
-- blocks here on the index and then fails, rather than proceeding.
INSERT INTO idempotency_keys (key, endpoint, request_hash, created_at)
VALUES ('ik_9f2c1a', 'POST /payments', '3b1e77', now());
INSERT INTO payments (id, account_id, amount_minor, currency)
VALUES ('pay_7qk4', 4412, 250000, 'GBP');
-- Persist the response so the replay is byte-identical, not recomputed.
UPDATE idempotency_keys
SET response_status = 201,
response_body = '{"id":"pay_7qk4","status":"succeeded"}'
WHERE key = 'ik_9f2c1a';
COMMIT;
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.
Likely follow-ups
- How does this change when the effect is a call to a third-party payment provider you cannot enrol in your transaction?
- Should the client or an edge gateway generate the key, and what breaks if a client reuses one across logically different requests?
- How would you make a consumer of an at-least-once queue idempotent when there is no header to carry a key?
- What does the idempotency table cost you on the write path, and how would you prune it without a long-running delete?
Related questions
- A payment API times out and the client retries. How do you guarantee the customer is not charged twice?hardAlso on idempotency and retries6 min
- Walk me through an order's journey from placement to delivery. How would you model its status?hardAlso on idempotency6 min
- Clients are asking for page 4,000 of your /orders collection. How is that endpoint paginated, and what would you change?mediumAlso on http4 min
- What is the difference between a queue and a log, and what does a broker mean when it claims exactly-once?hardAlso on idempotency6 min
- A worker picks up the job you queued and cannot find the row it was told about. What went wrong?hardAlso on transactions5 min
- Walk me through everything that happens when you type a URL into the browser and press enter.mediumAlso on http6 min
- The monthly bill run dies two thirds of the way through and the cycle closes tomorrow. What do you do?hardAlso on idempotency5 min
- A fill arrives for an order your system has already marked cancelled. What do you do?hardAlso on idempotency5 min