Idempotency — why it matters for retries and payments

What idempotent actually means precisely, why network retries make it unavoidable at scale, and the concrete pattern (idempotency keys) that real payment APIs use.

Intermediate

3 min read

What idempotent actually means

An operation is idempotent if performing it multiple times produces the same result as performing it once. This is a precise claim about the end state, not about whether the operation "did something" each time:

# Idempotent — running this 5 times leaves the same end state as running it once
user.is_active = True
 
# NOT idempotent — running this 5 times charges the customer 5 times
account.balance -= 20

Setting a field to a fixed value is naturally idempotent — the fifth call produces exactly the same state as the first. Incrementing or decrementing a value is not — each call changes the state further, so repeating it accumulates.

Why this becomes unavoidable the moment there's a network involved

Client                          Server
  |--- POST /charge $20 ------->|
  |                              | charges the card, succeeds
  |<---- (response lost) -------|
  | (times out, assumes failure)|
  |--- POST /charge $20 ------->|   <- client retries, charges again

The client sent one request. The charge succeeded. The response was lost — a dropped connection, a timeout, a proxy hiccup — so the client has no way to know whether the request failed before or after the charge happened. A reasonable client retries on timeout, because "assume it failed and try again" is the only sane default when the outcome is genuinely unknown. Without idempotency, that reasonable retry double-charges the customer. This isn't a rare edge case — at any real scale, network failures happen constantly, and every one of them creates exactly this ambiguity for the client.

The pattern: idempotency keys

def charge(request):
    key = request.headers["Idempotency-Key"]   # client generates this once, e.g. a UUID
 
    existing = IdempotencyRecord.objects.filter(key=key).first()
    if existing:
        return existing.response   # already processed — return the same result, don't charge again
 
    result = actually_charge_the_card(request.data)
    IdempotencyRecord.objects.create(key=key, response=result)
    return result

The client generates a unique key once, before the first attempt, and sends the same key on every retry of that same logical request. The server stores which keys it has already processed and what the result was; on a retry with a key it's seen before, it returns the stored result instead of charging again. The charge itself is not idempotent — but the endpoint, combined with this key-tracking mechanism, is now safe to retry as many times as needed.

This is exactly how Stripe's API works: every request that creates something (a charge, a payout) accepts an Idempotency-Key header, and Stripe guarantees that retrying the same key returns the original result rather than creating a duplicate.

Which HTTP methods are idempotent by specification, and why that's not the whole story

GET, PUT, and DELETE are specified as idempotent; POST is not. PUT /users/5 {"name": "Ada"} sets the name — repeating it leaves the same end state. POST /users {"name": "Ada"} typically creates a new user each time it's called, which is exactly the non-idempotent pattern from above. This is precisely why creation endpoints — which are necessarily POST — are the ones that need an explicit idempotency-key mechanism: the HTTP method itself provides no idempotency guarantee for them, unlike PUT.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. Why is `account.balance -= 20` NOT idempotent, while `user.is_active = True` is?

2. Why does a lost HTTP response (not a lost request) create the retry-duplication problem?

3. In the idempotency-key pattern, what does the server do when it receives a request with a key it has already processed?

4. Why do creation endpoints (necessarily POST) specifically need an idempotency-key mechanism, while PUT usually doesn't?