API · Rest

REST API Idempotency — A Field Guide

How idempotency keys prevent duplicate side effects from network retries, and why GET, PUT, and DELETE being idempotent by definition still isn't enough for POST.

John Kihiu12 min read

Idempotency in HTTP has a precise meaning: a method is idempotent if making the same request N times has the same effect on server state as making it once. GET, PUT, and DELETE are idempotent by the HTTP spec's definition — a DELETE on an already-deleted resource just returns 404 again, a PUT with the same body sets the same final state again. POST is explicitly not idempotent, which is exactly the problem, because POST is also the method you use to create things, and a network timeout followed by a client retry on a non-idempotent create is how a customer ends up charged twice for one order.

Why PUT and DELETE being idempotent by spec doesn't save you

The HTTP spec's guarantee is about the intended semantics, not your implementation. A PUT /invoices/4471 that appends to an array instead of replacing it, or a DELETE handler that throws an unhandled exception on the second call instead of returning a graceful 404, breaks idempotency in practice even though the method is idempotent in theory. Idempotency has to be verified as a property of your actual handler code, not assumed from the method name — a common bug is a PUT that increments a counter or timestamp as a side effect of the update, which silently makes two identical requests produce different final state.

Idempotency keys: making POST safe to retry

The standard fix for non-idempotent POST is a client-generated idempotency key, sent as a header, that the server uses to deduplicate retried requests. The client generates a UUID once per logical operation (not per HTTP attempt) and sends it on every retry of that same operation; the server stores a record of keys it has already processed and, on seeing a repeat, returns the original response instead of executing the operation again.

HTTP · IDEMPOTENT POST
POST /v1/payments HTTP/1.1
Idempotency-Key: 8f14e45f-ceea-4d5f-9e5a-3b8f1c2a9d01
Content-Type: application/json

{"invoice_id": 4471, "amount_cents": 152000}

# First call: 201 Created, payment processed, key stored with response.
# Retry with same key: 201 Created, identical body, no second charge.
# Different key, same body: treated as a new, distinct payment attempt.
Scope the stored key to the request body, not just the key itself

If a client reuses an idempotency key with a materially different request body (a different amount, say), the safe response is 422 or 409 flagging the mismatch, not silently replaying the original response or silently processing the new body under the old key. Stripe's API, one of the most widely copied idempotency implementations, does exactly this.

How long to remember a key

Idempotency keys need a retention window, not permanent storage — 24 hours is a common default, long enough to cover any realistic retry storm (a client backing off and retrying over minutes, not weeks) without keeping an unbounded table of every key ever issued. Expire and garbage-collect old keys on a schedule, and document the window so client authors know how long a retry is safe.

Concurrent requests with the same key need a lock, not just a lookup

Two requests carrying the same idempotency key arriving within milliseconds of each other — a common pattern when a client's retry logic fires before the first response comes back — will both miss a naive "check if key exists, then process" lookup, because neither has finished writing the key yet when the other checks. The key needs a unique constraint at the database level (or a distributed lock) so the second concurrent request blocks or fails against the constraint, rather than a race where both proceed and duplicate the side effect anyway.

Don't require idempotency keys on genuinely idempotent operations

Forcing clients to generate and track keys for GET, PUT, or DELETE calls adds friction for no benefit — those are already safe to retry by the method's own semantics, assuming your handler actually respects them. Reserve idempotency keys for POST operations that create new state.

Wrapping up

HTTP's idempotency guarantees for GET, PUT, and DELETE only hold if your handlers actually implement them correctly — verify, don't assume. For POST, where the spec offers no guarantee at all, a client-generated idempotency key with a unique constraint on the server and a bounded retention window is the standard, proven way to make "the network timed out and the client retried" a non-event instead of a duplicate charge.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.