Backend
Idempotency in API Design
Why retrying a failed request can silently charge a customer twice — and the idempotency-key pattern that makes retries safe.
Idempotency is a simple promise with a precise definition: calling an operation multiple times has exactly the same effect as calling it once. Ideally it also returns the same result every time. It sounds like a minor technicality, but it's one of the load-bearing ideas behind every reliable API — the difference between a client that can safely retry a failed request and one that's gambling with your data every time the network hiccups.
The problem retries create
Suppose a client sends a request to charge a customer's card. The server receives it, processes the charge, and starts writing the response — and right then, the connection drops. From the client's point of view, the request timed out. But the client has no way to know where it failed. Did the server never receive the request? Did it receive it but crash before charging the card? Or did it charge the card successfully and only fail to deliver the response? All three look identical from the outside: no response arrived.
This is the fundamental problem: a client cannot distinguish "my request never arrived" from "my request arrived and succeeded, but the response got lost." A naive client that just retries on timeout will, in the second case, submit the exact same charge a second time. Multiply that across a payments platform processing millions of requests a day, and "just retry it" turns into a steady trickle of double-charged customers and angry support tickets.
Timeouts don't mean failure
A request timeout tells you the client didn't get a response in time. It tells you nothing about whether the server processed the request. Treating a timeout as "this definitely didn't happen" is the root cause of most duplicate-processing bugs.
What the HTTP spec already gives you
HTTP actually bakes idempotency into the semantics of several methods. GET, PUT, and DELETE are all defined as idempotent: calling GET /orders/42 a hundred times just reads the same order a hundred times. Calling PUT /orders/42 with the same body twice leaves order 42 in the same final state either way — the second call simply overwrites with identical data. Calling DELETE /orders/42 twice deletes it once and then finds nothing to delete the second time; the end state (order 42 gone) is unchanged.
POST is deliberately excluded from that guarantee. POST typically means "create a new thing," and creating a new thing twice usually means two things now exist where you wanted one — two orders, two charges, two emails sent. The spec doesn't forbid POST from being safe to retry; it just refuses to promise it for you. That promise is something you have to build yourself.
The fix: client-generated idempotency keys
The standard pattern is for the client to generate a unique idempotency key — typically a UUID — for each logical operation it intends to perform, and to send that key with the request (commonly in an Idempotency-Key header). The key represents the intent "charge this customer $50 for this order," not the individual HTTP attempt. If the request times out and the client retries, it reuses the same key rather than minting a new one — that's what tells the server "this is the same operation I already tried, not a second charge."
On the server side, the check is straightforward: before processing, look up whether that key has been seen before. If it has, skip processing entirely and return the result that was stored the first time. If it hasn't, process the operation normally and store the result under that key so any future retry can be answered without re-running the logic.
def handle_create_payment(request):
idempotency_key = request.headers.get("Idempotency-Key")
if not idempotency_key:
raise BadRequest("Idempotency-Key header is required")
scope = f"create_payment:{request.merchant_id}:{idempotency_key}"
# 1. Have we seen this exact operation before?
existing = idempotency_store.get(scope)
if existing is not None:
# Same key, same operation -> return the original result.
# Do NOT touch the payments table again.
return existing.response, existing.status_code
# 2. First time we've seen this key: process for real.
try:
result = charge_customer(request.body)
response, status_code = to_response(result), 201
except Exception as exc:
response, status_code = to_error_response(exc), 422
# 3. Store the outcome under the key before replying,
# with a bounded TTL (e.g. 24 hours) rather than forever.
idempotency_store.set(
scope,
{"response": response, "status_code": status_code},
ttl_seconds=60 * 60 * 24,
)
return response, status_codeTwo details in that sketch matter more than they look. First, the key is scoped — create_payment:{merchant_id}:{key} — rather than global, so two different merchants (or two different endpoints) can't collide on the same key by coincidence. Second, the key has a bounded TTL. Idempotency keys exist to cover the retry window right after a request, typically minutes to a day, not to be a permanent record — keeping every key forever turns your idempotency store into an unbounded, ever-growing table for no real safety benefit.
Store the result, not just a flag
It's tempting to store just a boolean — "this key has been used" — but that's not enough. A legitimate retry needs the same response the original request would have produced (the same order ID, the same confirmation number), not a generic "already processed" error. Store the full response and status code alongside the key, and replay it exactly on a repeat.
Where this shows up in practice
Idempotency keys are how payment processors like Stripe let you retry a charge without fear, how message queues avoid double-firing side effects when a consumer redelivers a message, and how mobile clients on flaky networks can retry aggressively without a backend engineer losing sleep over duplicate rows. It's a small amount of extra plumbing — one lookup table, one header — in exchange for being able to answer "what happens if this request gets sent twice?" with "nothing bad," instead of "we'll find out."
Key takeaways
- Idempotent means calling an operation multiple times has the same effect as calling it once — a timeout never tells the client whether the server actually processed the request.
- GET, PUT, and DELETE are idempotent by definition in the HTTP spec; POST explicitly is not, since it usually means 'create a new thing.'
- Client-generated idempotency keys make POST-style operations retry-safe: the client reuses the same key across attempts of the same logical operation.
- The server looks up the key before processing; a key it has already seen returns the stored result instead of reprocessing.
- Store the full response under the key, not just a seen/unseen flag, so a retry gets back the exact original result.
- Scope keys to a specific operation and give them a bounded TTL — they exist to cover the retry window, not to live forever.
