How do you design an idempotent API endpoint, and why does it matter for payment retries?
An idempotent endpoint produces the same result no matter how many times the same request is sent, so a client that times out and retries never causes a duplicate side effect, like charging a customer’s card twice for one purchase. The standard pattern is an idempotency key: the client generates a unique token per logical operation (not per HTTP attempt) and sends it in a header, and the server stores the outcome of the first request against that key so any retry with the same key just returns the original result instead of re-executing the operation.
Why retries are unavoidable
Networks fail in ways that leave the client unable to tell whether the server actually processed the request or not, the request could have succeeded and the response got lost. Since the client can’t distinguish these cases, it has to retry to be safe, which means the server has to be the one guaranteeing safety, not the client.
Best practice
Store the idempotency key, the request’s result, and its status in a fast, durable store (a database row or Redis with an appropriate TTL) keyed by that token, before returning a response. On a retried request, look up the key first, if found, return the stored result immediately without touching the underlying business logic (like charging a card) again.
Edge case interviewers probe for
What happens if a retry arrives while the first request is still in-flight (not yet finished)? A naive check-then-act lookup lets both requests slip through and double-execute; the fix is to atomically claim the key first (an insert with a uniqueness constraint, or a Redis SETNX) so the second request sees it’s already claimed and waits for or returns the first request’s eventual result instead of racing it.
Common mistake
Making the endpoint’s business logic itself naturally idempotent (like a database update, which is; running it twice sets the same final value) is not the same thing as making the whole HTTP operation idempotent, non-idempotent operations like “charge $50” or “send one email” need this explicit key-based pattern since re-running them isn’t naturally safe.
What the interviewer is checking
Whether you think about distributed systems in terms of failure modes (timeouts, retries, partial failures) rather than assuming the happy path, and whether you know a concrete, standard mechanism (idempotency keys) rather than a vague “we’d handle duplicates somehow.”
Imagine mailing a check, not hearing back for weeks, and not knowing if it got lost or if it arrived and the reply is just slow. If you mail a second check “just in case,” you risk paying twice if the first one actually arrived. An idempotency key is like writing the same reference number on both checks: the bank sees the second check has the same reference number as one it already cashed, so it simply ignores the duplicate instead of cashing it again.
That’s exactly what happens when an app’s network request times out. The app doesn’t know if the payment went through, so it wants to safely retry. By sending the same unique “reference number” (the idempotency key) with the retry, the server can recognize it already handled this exact request and just resend the original result, instead of charging the customer a second time.
Why interviewers ask this
Payment and order APIs are a common real-world system where getting this wrong directly costs the company money, so it tests practical distributed-systems judgment, not just theory.
What a strong answer signals
That you reason about network failure explicitly (timeouts don’t mean failure), and know the standard idempotency-key mechanism rather than inventing an ad-hoc duplicate check.
Common follow-ups
- How long should an idempotency key be valid for?
- How would you handle a retry arriving while the original request is still processing?
- Is GET naturally idempotent, and why doesn’t it need this pattern?
Advanced variation
“What if the client itself crashes and restarts, does it still know its own idempotency key?” expects discussing client-side persistence of the key, generated once per logical action rather than regenerated on every attempt.
A checkout API originally re-charged customers’ cards whenever a mobile client’s flaky connection caused it to silently retry a slow request, producing a wave of duplicate-charge support tickets. Adding a required `Idempotency-Key` header, generated once per checkout attempt and stored client-side until the flow completed, let the server recognize retries and return the original charge result instead of creating a new one, eliminating the duplicate charges entirely without needing to slow down or restrict legitimate retries.
# Atomically claim the key before doing anything, so concurrent retries can't both slip through
def charge(idempotency_key, amount, customer_id):
existing = db.execute(
"INSERT INTO idempotency_keys (key, status) VALUES (%s, 'processing') "
"ON CONFLICT (key) DO NOTHING RETURNING key",
[idempotency_key],
)
if existing is None:
# Key already exists: another request owns it, return its stored result
return db.fetch_result(idempotency_key)
result = payment_gateway.charge(amount, customer_id)
db.execute(
"UPDATE idempotency_keys SET status='done', result=%s WHERE key=%s",
[result, idempotency_key],
)
return result- 1Network timeouts don’t tell the client whether the server actually processed the request.
- 2An idempotency key, generated once per logical operation, lets the server recognize and safely ignore retries.
- 3Claiming the key must be atomic to prevent two concurrent retries from both executing the operation.
- 4Naturally idempotent operations (like setting a value) don’t need this; non-idempotent ones (charges, emails) do.
- 5Store the key alongside the original result so retries return exactly what the first request would have.