The client retries a charge because your response timed out. The money must move once. What makes that true?
An idempotency key for retried charges is minted by the client per payment intent, claimed under a unique constraint in the same transaction as the charge, and used to replay the stored response on repeat. The API contract must define retention, in-flight retries and mismatched request bodies.
What the interviewer is scoring
- Does the candidate insist the client mints the key, with the timeout as the reason no server-minted value can work
- Whether the key's lifetime is derived from the client's retry horizon rather than picked as a round number
- That a mismatched body under a known key is refused rather than processed or replayed
- Can they say which request fields the stored fingerprint covers and which are deliberately excluded
- Whether the replayed response reproduces the original status code, not merely the original body
Answer
Short answer
Use an idempotency key that the client creates before the first charge attempt and sends on every retry. The server claims that key with a unique constraint in the same transaction that creates the charge, stores the original status and response, and replays that response for repeats. If the same key arrives with a different payment body, reject it; that is key reuse, not a safe retry.
Only the client can supply the key
Begin with why the obvious alternatives cannot work, because it settles the shape of the API in one step.
The client sent a request and got nothing back. It has no charge id, no server-generated token, no receipt. Anything the server invented during that first attempt is unreachable, so it cannot be used to recognise the retry. Deduplicating on the request body fails too: two genuine identical charges are legitimate, and refusing the second one is as wrong as accepting a duplicate.
That leaves one option. The client generates an identifier before its first attempt, sends it with every attempt, and keeps it until the call resolves. The key names an intent, not an attempt. One payment the client is trying to make. Every retry of that intent carries the same value. A new intent gets a new one.
POST /charges with an Idempotency-Key header is the conventional expression of this. Note what the header is not doing: it is not making POST idempotent in the sense RFC 9110 defines, which is a property of a method. It is a handler-level contract layered on a method that has no such guarantee, and being able to say that distinction out loud is worth marks.
Claim the key in the same transaction as the effect
The mechanism is a unique constraint, not a lookup. A read that checks whether the key exists, followed by a write, is two statements with a gap between them, and two concurrent retries both pass the read.
So the handler inserts the key row and lets the database reject the second insert. That insert and the charge record commit together. If they are separate transactions there is a window where the money moved and the key did not, and the next retry moves it again.
-- The claim IS the concurrency control. A duplicate key raises a
-- unique-violation, which the handler treats as "someone else has this intent".
INSERT INTO idempotency_keys
(key, account_id, endpoint, request_digest, state, created_at)
VALUES (:key, :account, 'POST /charges', :digest, 'in_progress', now());
The row carries a digest of the request, which is what the mismatch rule below depends on, and a state, which is what makes an in-flight retry answerable. When the charge completes the same row is updated with the status code and response body that were returned, so a later repeat can be answered without touching the payment provider at all.
Three states, three answers
A key arrives in one of exactly three conditions, and the API's honesty lives in giving each a different response.
The key is unknown. Claim it, do the work, store the outcome, return it. This is the first attempt.
The key is known and complete. Replay the stored response, byte for byte, including the original status code. A first call that returned 201 Created must replay as 201, not as 200, because a client that branches on the status will otherwise behave differently on a retry than on a first call, which is exactly the difference idempotency exists to remove. Add a response header marking the call as a replay so a client that wants to know, can.
The key is known and still in progress. Do not fabricate a success, and do not start a second attempt. Return a conflict, conventionally 409, with a Retry-After and a machine-readable error code meaning "this intent is being processed, ask again". A fabricated success is the worst of the three possible answers, because the client proceeds as though it has a charge id it never received.
How long the key lives, which nobody specifies until it bites
Here is the part that is usually left as an afterthought and then causes the double charge in production.
The retention has to exceed every path by which the same intent can be resubmitted. Add them up rather than choosing a round number. A client library retrying with exponential backoff might span a couple of minutes. A mobile app that queues the request while offline can resend it hours later, when the phone comes back on a network. A user who saw a spinner and closed the app may return that evening and press pay again with the key still stored on the device. A support agent replaying a failed request from a queue may do so the next morning.
If the key record expires before the last of those, the resubmission arrives at a server with no memory of it, and the money moves twice. Twenty-four hours is a common published figure and it works because it comfortably exceeds all four paths. What matters is that the number is chosen against that list, published in the documentation, and enforced, so an integrator knows how long a retry stays safe.
Then check the cost of holding them, because it is the objection you will get. At three hundred charges a second, twenty-four hours of keys is 300 × 86,400, so roughly twenty-six million rows. That is a bounded, predictable table and it prunes by time. Partition it by day and drop yesterday's partition rather than issuing a delete over tens of millions of rows, which is the operational detail that turns a good idea into one that survives contact with a database.
The same key with a different body
This is the case the question is really about, and the wrong answers are instructive.
Replaying the stored response is wrong: the client asked for something different and you told them it succeeded, which quietly discards a payment. Processing the new body is worse: you have used one key for two intents and defeated the mechanism. Ignoring the difference is the same as the first option with less thought behind it.
The right answer is to refuse. Store a digest of the semantically meaningful fields at claim time, compare on every subsequent arrival, and on a mismatch return a 4xx that is distinguishable from an ordinary validation error, with an error code naming the reuse. Either 409 Conflict or 422 is defensible; what is not defensible is a bare 400 that an integrator will handle as a schema problem. The refusal is a service to the client, because a mismatch means one of two bugs on their side and both need finding: they mutated a request between attempts, or they reused a key across unrelated intents.
Choose the digest fields deliberately. Amount, currency, destination, reference — the things that define the intent. Not headers, not a trace id, not a timestamp the client regenerates on each retry, or every legitimate retry looks like a mismatch and the safety mechanism becomes an outage.
What the guarantee does not cover
Be clear about the boundary, because overclaiming is a mark against you. This makes your endpoint safe to repeat. It does not make the downstream provider safe to call twice, so the key you accept has to be carried into the provider call as their idempotency key too, or reconciled against them by reference. And it does nothing about a user who genuinely presses pay twice in two tabs, because that is two intents by your own definition and it needs a different control, at the interface, closer to the human.
The key must be minted by the client, claimed by a unique constraint inside the transaction that moves the money, and kept longer than the longest path by which the same intent can come back — and when it comes back describing something different, the only safe answer is to refuse it.
© 2026 Preptima. Originally published at preptima.com.
Likely follow-ups
- A mobile client is reinstalled and loses its unsent key. What does a user who taps pay again get, and can the API help?
- Where in your stack is the key claimed if the charge is written by three services in a saga rather than one transaction?
- How would you document the guarantee so an integrator knows the difference between safe to retry and safe to repeat?
- A partner sends one key for a hundred different charges over a month. How does your API make that failure obvious rather than silently deduplicating real payments?
Related questions
- A client times out and retries your POST /payments call. How do you make that endpoint idempotent?hardAlso on retries5 min
- A dependency that normally answers in 80ms starts taking eight seconds. What in your service reacts, and in what order?hardAlso on retries7 min
- Ten million users need a green dot beside their name. What does that cost, and what happens when a phone loses signal without disconnecting?hardAlso on expiry5 min
- A payment API times out and the client retries. How do you guarantee the customer is not charged twice?hardAlso on retries6 min