Skip to content
C Codeloom
REST APIs

Designing Idempotency Keys for REST APIs

A practical guide to idempotency keys: what they are, how to store them, how to handle replays and conflicts, and how to make POST endpoints safe to retry without duplicates.

·4 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • Why retries are unavoidable
  • How idempotency keys actually work
  • Storing and expiring key records
  • Conflicts and request fingerprinting
  • Edge cases that bite in production

Prerequisites

  • Familiarity with HTTP methods and databases

Networks lie. A client sends a payment, the response never comes back, and now nobody knows if the charge happened. Idempotency keys are the cheapest insurance you can buy against that uncertainty, but only if you design them carefully. The naive version causes the bugs it was supposed to prevent.

What and Why

An idempotency key is a client-supplied identifier attached to a state-changing request. The server records the result of the first request under that key and returns the same result for any future request with the same key. Retrying a request with the same key is safe; the operation runs at most once.

GET, PUT, and DELETE are already idempotent in the HTTP sense. The problem is POST, which creates resources or triggers side effects. Without keys, a client that retries after a timeout might double-charge a card, send two emails, or create two orders. Adding Idempotency-Key to POST lets clients retry confidently.

Mental Model

Think of the key as a receipt the client tears off before walking up to the counter. If the counter never responded, the client walks back with the same receipt. The counter checks: “Have I seen this receipt?” If yes, hand back the same bag. If no, do the work, staple the result to the receipt, and store it.

The receipt has to be unique per logical operation. Reusing it across operations would return the wrong cached result. That uniqueness is the client’s job, usually a UUID per attempted action.

Hands-on Example

A client sends a payment with a key it generated.

POST /v1/charges
Idempotency-Key: 8b7e1d4c-9f2a-4f6e-9b1a-2c5d3e4f5a6b
Content-Type: application/json

{ "amount": 2500, "currency": "USD", "source": "tok_visa" }

The server stores a row keyed by (account_id, idempotency_key) with the request fingerprint and result. A minimal schema:

CREATE TABLE idempotency_records (
  account_id   text not null,
  key          text not null,
  request_hash text not null,
  status_code  int,
  response     jsonb,
  state        text not null, -- 'in_progress' | 'completed'
  created_at   timestamptz default now(),
  primary key (account_id, key)
);

The handler runs this dance:

1. INSERT (state='in_progress', request_hash=H(body))
   - on conflict: read the existing row
2. If existing.state='completed' and same hash: return stored response
3. If existing.state='completed' and different hash: 422 key_reused
4. If existing.state='in_progress': 409 request_in_flight (or wait briefly)
5. Otherwise: do the work, UPDATE to 'completed' with the result
client --POST + key K, hash H--> server
                                |
                        [ key store lookup ]
                        /         |         \
                new key       in_progress    completed
                   |              |              |
                   v              v              v
               do work        409 retry      hash match?
                   |                          /     \
               save result                same      different
                   |                        |          |
                   v                        v          v
               return 201            return cached  422 reuse
Idempotency key flow

The fingerprint matters. If a client reuses a key with a different payload, that is almost certainly a bug on their side; return an error instead of silently returning the old result.

Common Pitfalls

  • Caching only the response, not the request. Without a hash, you cannot detect key reuse with a different body. The server happily returns a stale answer.
  • No “in progress” state. Two concurrent retries both miss the cache, both do the work, and you have duplicate side effects. The insert with a unique constraint plus a state field fixes this.
  • Forgetting expiry. Keys grow forever. Pick a window, usually 24 hours to 7 days, and clean up. Document it.
  • Scoping keys globally. Two tenants generating the same UUID is rare but possible, and key collisions across tenants are a security hole. Scope by account.
  • Treating 5xx as final. If your handler crashed mid-write, the next retry should be able to complete the work, not get stuck on a half-baked record. Use a transaction around the result write.

Practical Tips

Require keys only on endpoints where retries cause real harm: payments, transfers, account creation, anything with external side effects. Accept any opaque string up to a sensible length, but recommend UUIDv4 in your docs. Return the same status code on replay as the original, including the original 4xx errors; that is part of idempotency. Add a Idempotency-Replayed: true response header so clients can tell. For background jobs, the same pattern works with a job-level key instead of an HTTP header.

Wrap-up

Idempotency keys turn unreliable networks into a solved problem, but only when you treat them as a small state machine: insert, in-progress, complete, hashed, scoped, expired. Build that machine once, reuse it on every dangerous endpoint, and your retries stop being scary.