Filter concepts by levelShowing all levels.

System Design · Section 56

API Idempotency

Level
intermediate
Read
12 min
Concepts
1

GET, PUT, and DELETE are idempotent by HTTP's own definition — repeating them leaves the server in the same state as running them once — but POST is not, so a client retrying a POST after a timeout has no built-in guarantee it won't repeat the side effect (charging a card twice, placing a duplicate order). An idempotency key is a unique value the client generates once per logical operation and sends on the original attempt and every retry; the server stores the outcome of the first successful attempt keyed by it, and returns that same stored outcome for any later request carrying the same key instead of re-running the operation.

System Design overview

What is true here

  1. POST is not idempotent by HTTP semantics — GET, PUT, and DELETE are, which is exactly why only POST/command-style operations need an idempotency key.
  2. The idempotency key is generated by the client, once per logical operation, and reused across every retry of that same operation — never a fresh key per attempt.
  3. The server stores the request's outcome (status + body) keyed by that value on first processing, and replays the stored outcome verbatim for any duplicate submission.
  4. Scope the key to its endpoint and give stored outcomes an expiry — it is a short-lived retry safety net, not a permanent cross-request cache.

What you will be able to do

  • Explain why POST needs an idempotency key when GET/PUT/DELETE do not
  • Design a request/response contract where a client-generated idempotency key's outcome is stored and replayed on retry
  • Avoid double-processing a duplicate submission caused by a client retrying after a lost response
  • Recognize the failure modes of an unscoped or never-expiring idempotency store

Idempotency keys for POST/command requests

Why POST needs its own retry-safety mechanism, and the store-outcome/replay-on-retry contract that provides it.

Idempotency keys for POST/command requests

coreintermediate

GET, PUT, and DELETE are idempotent by HTTP's own definition — running the same request twice leaves the resource in the same state as running it once. POST is not: two identical POST /charges calls are, as far as the server can tell, two separate requests for two separate charges. An idempotency key is a unique value the client generates once per logical operation and sends with every retry, so the server can recognize "this is the same request again" and return the original result instead of repeating the side effect.

Think of it as

Picture a client that sends "charge this card $50" and the connection dies before the response arrives. The client has no idea whether the server never got the request, or got it, charged the card, and the response was what got lost — both look exactly like a timeout. Retrying blindly risks a second charge; not retrying risks never charging a card that was supposed to succeed. An idempotency key turns the retry into "process this exact operation, but only once" — the client can safely resend the identical request as many times as it wants, and the server's stored outcome makes every retry after the first a safe replay instead of a repeat.

http
POST /charges HTTP/1.1
Idempotency-Key: order-482-attempt

{ "amount": 5000, "currency": "usd", "customer": "cus_1" }

--- retry after a timeout, identical key and body ---

POST /charges HTTP/1.1
Idempotency-Key: order-482-attempt

{ "amount": 5000, "currency": "usd", "customer": "cus_1" }
--> 200 OK, same charge id as the first response,
    card is charged exactly once

What we're doing: Show a payment API using a stored idempotency key so a client's retry after a lost response returns the original charge instead of creating a second one.

idempotency-key-handling.txttext
def handle_post_charges(request):
    key = request.headers.get("Idempotency-Key")
    if key is None:
        return process_charge(request.body)  # no key: not retry-safe

    stored = idempotency_store.get(key)
    if stored is not None:
        if stored.request_hash != hash(request.body):
            return error(422, "Idempotency-Key reused with a different request")
        return stored.response  # replay, do NOT charge again

    # Reserve the key before doing any work, so a second request
    # that arrives while this one is still processing waits or
    # fails instead of racing past this check.
    idempotency_store.reserve(key, expires_in="24h")

    response = process_charge(request.body)  # the actual charge
    idempotency_store.save(key, response, request_hash=hash(request.body))
    return response
4
No key means no retry safety — this request is processed as a brand-new operation every time, by design (the caller opted out).
8
Reusing a key with a different body is a client bug, not a duplicate — reject it rather than silently returning a mismatched stored response.
14
Reserving the key before processing closes the race where two copies of the same retry arrive close together and both pass the lookup.
17
The stored response is the entire point: a retry after this line returns exactly what the first, successful attempt returned — same charge_id, same status.

Why this works: The server cannot tell "the client never got my 200 OK" apart from "the client's request never arrived" — both look identical from the client's side as a timeout. Storing the outcome keyed by a client-generated idempotency key removes the ambiguity: the client can always safely retry with the same key, and the server always returns the one true result of the operation it actually performed.

No idempotency key at all — a network timeout on a successful charge leads a naive retry to double-charge the customer

Wrong

text
def handle_post_charges(request):
    # No idempotency key, no stored outcome —
    # every POST is processed as brand new.
    return process_charge(request.body)

# Client side:
try:
    response = post("/charges", body)
except Timeout:
    response = post("/charges", body)  # retries blind
    # if the FIRST request actually succeeded and only
    # the response was lost, this charges the card twice

Better

text
def handle_post_charges(request):
    key = request.headers["Idempotency-Key"]
    stored = idempotency_store.get(key)
    if stored is not None:
        return stored.response
    response = process_charge(request.body)
    idempotency_store.save(key, response, expires_in="24h")
    return response

# Client side:
key = f"charge-{order_id}"  # same key across every retry
try:
    response = post("/charges", body, headers={"Idempotency-Key": key})
except Timeout:
    response = post("/charges", body, headers={"Idempotency-Key": key})
    # server recognizes the key -> returns the original charge,
    # does not charge the card a second time

What you see: A customer's statement shows two charges for one order placed during a slow network window — support has no way to explain it because, from the server's logs, both POST requests look like two entirely legitimate, independent charge attempts; nothing in the request marks them as "the same operation, retried."

Why: A POST with no idempotency key gives the server no way to distinguish a genuine second purchase from a client blindly retrying a request whose response it never saw. The fix is not "retry less" (that reintroduces the risk of silently losing a charge that failed for real) — it is giving the server the information it needs to tell the two cases apart.

A retried POST returns the stored outcome instead of charging twice
Client
API server
Idempotency store
  1. 1. POST /charges, Idempotency-Key: k1
  2. 2. lookup(k1) -> not found
  3. 3. process charge, store outcome under k1
  4. 4. 200 OK { charge_id: "ch_1" } (response lost in transit)
  5. 5. timeout -> retry: POST /charges, Idempotency-Key: k1
  6. 6. lookup(k1) -> found, outcome stored
  7. 7. 200 OK { charge_id: "ch_1" } (replayed, no new charge)
  1. Client → API server: POST /charges, Idempotency-Key: k1
  2. API server → Idempotency store: lookup(k1) -> not found
  3. API server → API server: process charge, store outcome under k1
  4. API server → Client: 200 OK { charge_id: "ch_1" } (response lost in transit)
  5. Client → API server: timeout -> retry: POST /charges, Idempotency-Key: k1
  6. API server → Idempotency store: lookup(k1) -> found, outcome stored
  7. API server → Client: 200 OK { charge_id: "ch_1" } (replayed, no new charge)

Why POST needs an idempotency key and GET/PUT/DELETE do not

Why POST needs an idempotency key and GET/PUT/DELETE do not
MethodIdempotent by HTTP semantics?Repeating it twice
GET /orders/42YesReads the same order again — no state change either time
PUT /orders/42YesOverwrites the order with the same representation — same end state
DELETE /orders/42YesSecond call finds nothing to delete — same end state (gone)
POST /chargesNoEach call is a new instruction to "create a charge" — two calls, two charges
POST /charges + Idempotency-KeyMade idempotent by the appSecond call with the same key returns the first call's stored outcome

Remember: POST is not idempotent by HTTP semantics the way GET/PUT/DELETE are, so a retried POST needs its own safety net: a client-generated idempotency key, the outcome stored under that key on first processing, and every retry with the same key returning the stored outcome instead of repeating the side effect. Scope the key to its endpoint and give it an expiry — it is a short-lived retry safety net, not a permanent cache.

See also: idempotency implementation · idempotent consumer design · at most least exactly once

Advertisement