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.
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 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
uniqueconstraint 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
5xxas 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.
Related articles
- Backend Idempotency Keys Explained: Safe Retries Made Simple
Learn how idempotency keys make APIs safe to retry, with patterns for storage, expiry, conflict handling, and common pitfalls.
- REST APIs REST API Design Best Practices: A Practical Guide
Apply REST design best practices for resources, naming, status codes, pagination, and versioning to build clean, durable APIs.
- REST APIs Designing Bulk Operations in REST APIs
How to design REST endpoints that accept many items at once: request shape, partial success, error reporting, idempotency, and limits that keep your API healthy under load.
- REST APIs REST API Error Handling Conventions
Design clear, consistent error responses for REST APIs using HTTP status codes, problem details, and error envelopes that clients can actually handle.