Guide
Idempotency explained: safe retries, API keys, and distributed systems
An operation is idempotent if running it once produces the same outcome as running it many times. That sounds abstract until a mobile client on a flaky network retries a payment POST and your user is charged twice. Or a webhook fires three times because the sender never got your 200 OK. Or a message queue redelivers a job after a worker crash. Idempotency is the property that makes retries safe — and it is one of the most under-implemented details in production APIs. This guide defines the concept, maps it to HTTP semantics, walks through idempotency-key patterns used by Stripe and others, and shows how to design databases and consumers that survive duplicate delivery without corrupting state.
What idempotency means
Formally: for operation f and state S, applying
f(S) once and applying f(f(S)) yield the same final
state. In software terms, the second identical request should not create a second
charge, a second shipment, or a second row where one was intended.
Idempotency is not the same as “read-only.” Deleting the same resource twice is idempotent — the first delete removes it, the second finds nothing and still succeeds (often with 404 or 204). Creating a user with the same email twice is not idempotent unless you define semantics that collapse duplicates into a single outcome.
Idempotency vs deduplication
Deduplication is the mechanism: storing a fingerprint of processed work and skipping repeats. Idempotency is the guarantee you want callers to rely on. You implement deduplication (unique keys, caches, conditional writes) to achieve idempotent behavior. The terms overlap in conversation but the distinction matters when designing APIs: publish an idempotency contract, implement deduplication behind it.
Why networks force you to care
TCP guarantees delivery attempts, not application-level success. A client sends POST /payments, your server processes the charge, then the response times out before the client reads it. The client retries. Without idempotency, you have two charges and one confused customer.
The same failure modes appear everywhere:
- Mobile and browser clients — users double-tap submit; service workers replay requests.
- Load balancers and gateways — upstream timeouts trigger automatic retries on “safe” methods, sometimes incorrectly including POST.
- Webhooks — providers retry until they receive 2xx; your handler must tolerate duplicates.
- Message queues — at-least-once delivery is the default; exactly-once end-to-end is a distributed-systems myth without careful design.
- Batch jobs and cron — overlapping runs or crash recovery re-process the same file slice.
The fix is not “never retry.” Retries are how reliable systems recover. The fix is making the operation safe to repeat.
HTTP methods and natural idempotency
The HTTP specification assigns idempotency expectations to verbs. Clients, proxies, and libraries use these rules when deciding whether a failed request can be replayed automatically.
| Method | Idempotent? | Typical use |
|---|---|---|
GET, HEAD, OPTIONS | Yes | Read state; must not have side effects |
PUT | Yes | Replace resource at a known URL |
DELETE | Yes | Remove resource; repeat delete is a no-op |
PATCH | Usually no | Partial update; semantics vary by implementation |
POST | No | Create, actions, payments — each call may create new state |
PUT idempotency is powerful: PUT /users/42 with the same
body twice leaves user 42 in the same final shape. Contrast
POST /users, which typically allocates a new ID on every call. That is
why payment and order APIs almost always add explicit idempotency on top of POST —
see our
REST API design
guide for how keys fit into error shapes and status codes.
Safe vs unsafe retries in practice
A safe method (GET) should not change server state. An idempotent method may change state, but repeating it is harmless. POST is neither safe nor idempotent by default. Document which endpoints accept retries and which require an idempotency key header.
Idempotency keys: the Stripe pattern
Payment processors popularized the idempotency key: a unique
string the client generates once per logical operation and sends on every attempt
(usually header Idempotency-Key or X-Idempotency-Key).
The server stores the key with the result of the first successful processing and
returns the cached response for duplicates within a TTL window.
Server-side flow
- Receive request with key
Kand payloadP. - Look up
Kin an idempotency store (Redis, Postgres table). - If found and completed, return stored response (same status + body).
- If found and in-flight, return 409 or block until the first attempt finishes.
- If not found, insert
Kasprocessing, execute business logic inside a transaction, persist result, markcompleted.
Keys should be opaque UUIDs generated by the client — not derived from amount or SKU alone (two different purchases could collide). Scope keys per merchant or user so one tenant cannot probe another's keys. Expire records after 24–72 hours; after expiry, a repeat with the same key may execute again, which is acceptable if the client only retries within minutes.
What to store
Cache enough to reconstruct the HTTP response: status code, headers that matter, and body. For long-running operations, return 202 on first accept and let duplicates poll the same job ID. Hash the request body and reject keys reused with different payloads (400) — otherwise a buggy client could charge $10 then retry the same key with $100.
Database patterns for idempotent writes
Keys at the API edge are not enough if your worker or queue consumer writes directly. Push idempotency to the layer that mutates state.
Unique constraints
Add a unique index on a business-natural key: (order_id, event_type),
external_payment_id, or webhook_delivery_id. The second
insert fails with a constraint violation — catch it and treat as success. This is
the simplest durable deduplication for
SQL
backends.
Upserts and conditional writes
INSERT ... ON CONFLICT DO NOTHING (Postgres) or equivalent upsert
semantics let you record “we processed message X” exactly once.
Conditional updates — “set status = shipped only where status = paid” —
make state transitions idempotent: repeating the ship command on an already-shipped
order changes zero rows and exits cleanly.
Outbox and inbox tables
In event-driven systems, an inbox table stores incoming event IDs before processing. Workers insert the ID first; duplicate deliveries hit the unique constraint and skip. Pair with transactional outbox on the publisher side so you never emit an event you did not commit.
Message queues and at-least-once delivery
Kafka, RabbitMQ, and SQS typically guarantee at-least-once: messages may arrive more than once, especially after consumer crashes between processing and acknowledgment. “Exactly-once” marketing usually means exactly-once processing semantics achieved by idempotent consumers plus transactional offsets — not magic transport.
Design consumers as: parse message, derive idempotency key, attempt side effect inside idempotent boundary, ack. Side effects outside the database (send email, charge card) need the same key stored before the external call. If the process dies after charging but before ack, the redelivery must see the stored key and skip the charge.
Ordering vs idempotency
Partitioning by user_id preserves order per user but not global
order. Idempotency handles duplicates; it does not fix consuming event B before
event A. For strict ordering, combine single-partition keys with version numbers
or state machines that reject stale transitions — topics covered in
distributed systems consistency.
Webhooks and external callbacks
Webhook senders include a delivery ID or event ID in the payload. Your handler should:
- Verify signatures first (see webhook guide) — reject forgeries before dedup.
- Persist the event ID before heavy work.
- Return 200 quickly once durably recorded; process async if needed.
- On duplicate ID, return 200 immediately without re-running side effects.
Returning 200 on duplicates is critical: if you return 500 because “already processed,” the sender retries forever. Idempotent success looks identical to first-time success from the sender's perspective.
Blockchain and payment verification
On-chain payments add another retry surface: clients poll “did my tx
land?” and servers scan mempools. Credit an order exactly once per
transaction signature, not per HTTP request. Store tx_signature as a
unique key before fulfilling digital goods. The same pattern applies when
verifying Solana Pay or wallet transfers — signature is the natural idempotency
key. See
verify Solana payment
for end-to-end flow.
Common mistakes
- Idempotency only in memory — restarts lose the map; duplicates slip through after deploy.
- Same key, different body — must return 400, not silent wrong results.
- Non-atomic check-then-act — two workers both pass the lookup before either inserts; use unique constraints or transactions.
- Caching error responses — if the first attempt failed transiently, some designs delete the key; others cache 5xx and block forever. Prefer short in-flight locks and retryable errors without marking completed.
- Assuming GET is always safe — poorly designed GET endpoints that trigger exports or emails break cache and retry assumptions.
- Ignoring PATCH and custom POST actions — document and implement keys for any mutating endpoint clients retry.
Production checklist
- List all mutating endpoints and webhook handlers; mark which are naturally idempotent.
- Require idempotency keys on POST that create money movement, inventory holds, or irreversible external calls.
- Store keys with response snapshots in a durable store; scope per tenant; TTL aligned with client retry policy.
- Add unique indexes on external IDs at the database layer as a backstop.
- Make queue consumers insert a processed-message row before side effects.
- Test duplicate delivery explicitly — unit tests and chaos-style double-submit integration tests.
- Log and metric duplicate suppressions so you can detect buggy clients without treating them as errors.
Idempotency is invisible when it works and headline news when it does not. Investing once in keys, constraints, and inbox tables pays off across every integration you ship afterward.
Related reading
- REST API design explained — verbs, status codes, and where idempotency keys fit in API contracts
- Webhooks explained — signature verification, retries, and duplicate delivery handling
- Message queues explained — delivery guarantees and idempotent consumer design
- Distributed systems consistency explained — ordering, CAP, and why exactly-once is hard