12k
All articles

Idempotency Explained and What It Means for Your API

Idempotency keys explained: prevent duplicate API requests, fix race conditions, and safely retry POSTs with atomic claims and transactions.

OpenReplay Team
OpenReplay Team
Idempotency Explained and What It Means for Your API

An operation is idempotent when performing it multiple times leaves the system in the same state as performing it once. Send the same request twice and nothing extra happens: no second order, no second charge, no second account.

The word usually turns up in one of two places: a duplicate charge in production, or a payment API that asks for an Idempotency-Key header without saying much about what the server does with it. This article covers both halves of that contract: what the client does with the key, and what the server does to make a repeated request harmless rather than usually harmless.

Key Takeaways

  • An idempotency key has three states, not two: absent, in flight, and complete. A request that finds the key in flight should receive a 409 Conflict, not a second execution.
  • Checking whether a key exists and then writing it afterward is the race condition; the only safe claim is a single atomic insert before any work begins.
  • The stored outcome must commit in the same database transaction as the business change; committing them separately just moves the race.
  • The client generates the key before the first attempt, reuses it on every retry, and never derives it by hashing the request body.
  • Test by firing two identical requests at the same instant. A duplicate test that runs them one after the other passes even when the code is racy.

What Does Idempotency Prevent?

Idempotency protects you from duplicate requests, and duplicate requests are ordinary, not exotic. A user double-clicks submit before the page reacts. A client library times out waiting for a response and resends. A proxy or service mesh retries on a dropped connection without the application knowing. In every case the first request may have succeeded, so the second one, processed naively, creates a second order or moves money twice. Session replays of duplicate-submission bugs usually show the mundane version: the user pressing submit again while the spinner is still on screen, which is the client-side half of the exact problem the key solves on the server.

Why Retries Keep Arriving

A retry is not a malfunction. HTTP clients, mobile apps, and infrastructure in between all resend requests after timeouts by design, because a lost response is indistinguishable from a lost request. You cannot stop retries arriving; you can only make your endpoint safe to receive the same request twice.

RFC 9110 defines PUT and DELETE as idempotent while POST is not, and PATCH, defined in RFC 5789, is not idempotent either. The method label never makes your handler safe on its own; idempotency is a property of your implementation, not the verb.

The Client’s Half of the Contract

The client generates the idempotency key before the first attempt, sends the identical key on every retry of that operation, and uses a fresh key only for a genuinely new operation. A high-entropy random string such as a UUID works. Stripe’s guidance is a V4 UUID, a ceiling of 255 characters, and nothing sensitive inside the key itself, no email addresses or other personal identifiers, because keys show up in logs.

Never derive the key by hashing the request body. Two orders that happen to be identical, such as the same customer buying the same item twice in a row, would hash to the same value and get merged into one. A hash tells you whether two payloads match; a key tells you which operation the caller meant. Those are separate jobs. Deriving the key from something stable that the user is already acting on, such as a cart ID, works fine, because the cart stands for the operation.

Note that the Idempotency-Key header is an industry convention, not a ratified standard. The IETF httpapi working group’s draft expired at revision 07 without becoming an RFC, so each provider defines its own semantics.

The Server’s Half: Claim the Key Atomically

An idempotency key has three states, not two: absent, in flight, and complete. Most broken implementations model only two. They check whether the key exists, run the handler, then save the result. That leaves a window in which two concurrent retries both see nothing and both execute. The fix is to claim the key with a single atomic insert before any work begins:

INSERT INTO idempotency_keys
  (tenant_id, idem_key, fingerprint, state, locked_until)
VALUES
  ($1, $2, $3, 'in_flight', now() + interval '90 seconds')
ON CONFLICT (tenant_id, idem_key) DO NOTHING
RETURNING id;

In PostgreSQL, ON CONFLICT DO NOTHING skips the insert and RETURNING yields no row for a conflicting key, so zero rows returned means another request owns it. Read the existing row: if its state is complete, replay the stored outcome; if it is still in_flight, return 409 Conflict rather than executing a second time. This matches documented provider behavior: Stripe returns 409 Conflict when a key is reused while the first request is still running, and it does not record that conflict against the key, so the client is free to come back later. Stripe also labels a replayed response with an Idempotent-Replayed: true header, a cheap courtesy worth copying.

Commit the Outcome With the Business Change

The stored outcome and the business change must commit in the same database transaction. Writing them separately does not remove the race, it shifts it into the gap between the two commits. If the process dies after the charge but before the key is updated, the money has moved while the row still reads in_flight.

BEGIN;

INSERT INTO orders (tenant_id, customer_id, total_cents)
VALUES ($1, $2, $3);

UPDATE idempotency_keys
SET state = 'complete', status_code = 201, response_body = $4
WHERE tenant_id = $1 AND idem_key = $5;

COMMIT;

Either both rows exist or neither does, which is the entire point.

What Should You Store Against the Key, and for How Long?

Store whatever the handler produced, including its failures. Under Stripe’s idempotency rules, the status code and body of the first attempt are kept and returned again on reuse, error responses and 500s included. Replaying a real error is more honest than quietly running the operation a second time. The real boundary is anything rejected before the handler runs. Rate limiting and authentication sit in front of the idempotency layer, so those responses never attach to the key and stay retryable.

Three storage options, briefly:

  • Full response. Simplest to replay exactly; storage grows with payload size.
  • Resource reference. Store the created order’s ID and rebuild the response; lighter, but needs an extra lookup.
  • Marker plus request fingerprint. Minimal storage; only viable when the response is recomputable, and the fingerprint becomes mandatory rather than optional.

Four rules apply regardless of option. Put the unique constraint on (tenant_id, key) rather than on the key by itself, so one tenant cannot collide with another tenant’s keys or go fishing for them. Set an expiry: Stripe clears keys out once they pass the 24-hour mark, and the principle is to outlive the retry window without letting the table grow forever. Put a lease on in-flight rows (the locked_until column above) so a process that dies mid-request cannot block retries for good. And reject any retry whose fingerprint does not match the stored one. The same key with a different body points to a client bug, and serving back an unrelated response would be the worse outcome.

How Do You Test Idempotency Properly?

Fire two identical requests with the same key at the same instant, then assert that exactly one resource exists. Running the duplicates one after the other proves nothing, because the first finishes before the second one looks, so racy code passes.

KEY=$(uuidgen)
for i in 1 2; do
  curl -s -o "resp_$i.json" -w "%{http_code}\n" \
    -X POST http://localhost:3000/orders \
    -H "Idempotency-Key: $KEY" \
    -H "Content-Type: application/json" \
    -d '{"cart_id":"c_42","total_cents":1900}' &
done
wait

Assert one row in orders for that cart, and that the two status codes are one 201 plus either a replayed 201 or a 409. If both came back 201 with different order IDs, you have the check-then-write race.

Three Things to Get Right

The header only gives two systems a shared name for one operation. The safety itself comes from three things in your database: a unique constraint, an atomic claim, and a transaction boundary. Get those right and your endpoint survives any client that retries, which is every client. The same approach carries over to message consumers, where at-least-once delivery means a dedupe key doing this job under a different name. Start with your most dangerous POST endpoint, add the key table, and write the concurrent test before you trust it.

FAQs

Do GET and PUT requests need idempotency keys?

Usually not. RFC 9110 defines GET as safe and PUT and DELETE as idempotent, so a retried PUT that fully replaces a resource leaves the same state without a key. Keys matter for POST, where each request creates something new. The exception is a PUT or DELETE handler with side effects, such as sending an email or firing a webhook, which still needs server-side deduplication.

What is the difference between an idempotency key and a request ID?

They move in opposite directions across retries. A request ID or correlation ID identifies a single HTTP attempt for logging and tracing, so every retry gets a new one. An idempotency key identifies one intended operation, so every retry reuses the same one. A client that generates a fresh idempotency key for each retry defeats deduplication entirely, and the server executes the operation twice.

Can I store idempotency keys in Redis instead of PostgreSQL?

Yes for the atomic claim: SET with the NX flag claims a key in one atomic step, matching the insert-on-conflict pattern. What Redis cannot give you is a single transaction that commits the key outcome together with a business row stored elsewhere. A crash between the Redis write and the database commit reopens the race, so keeping keys in the business database is safer.

What happens if a client retries after the idempotency key has expired?

The server treats the retry as a brand-new request and executes it again, which can create a duplicate. Stripe, for example, clears keys once they pass 24 hours, so a key reused after that window runs the operation a second time. Set your retention period longer than the longest retry delay any client, queue, or batch job can plausibly produce.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.