API Idempotency Patterns — Building Reliable APIs That Handle Retries Safely
What is idempotency?#
An API operation is idempotent if calling it multiple times with the same input produces the same result as calling it once. The server does the work on the first request and returns a cached result on subsequent retries.
HTTP GET, PUT, and DELETE are idempotent by design. POST is not — and that is where the problems start.
Why idempotency matters#
Networks are unreliable. A client sends a payment request, the server processes it, but the response is lost. The client retries. Without idempotency, the customer gets charged twice.
Idempotency protects against:
- Network timeouts — the response never reached the client
- Client crashes — the client restarts and retries the last request
- Load balancer retries — the infrastructure retries on 502/503 errors
- User double-clicks — the frontend submits the same form twice
Idempotency keys#
An idempotency key is a unique identifier that the client attaches to a request. The server uses it to detect duplicates.
POST /v1/charges
Idempotency-Key: 8a3b1c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d
Content-Type: application/json
{
"amount": 2000,
"currency": "usd",
"source": "tok_visa"
}
The server checks: have I seen this key before? If yes, return the stored response. If no, process the request and store the result.
Client-generated UUIDs#
Clients generate the idempotency key as a UUID v4 before sending the request. This approach has important properties:
- No coordination required — clients generate keys independently
- Globally unique — UUID v4 collision probability is negligible
- Deterministic retries — the client reuses the same key when retrying
Implementation guidelines#
// Client-side: generate key before the request
const idempotencyKey = crypto.randomUUID();
async function createCharge(amount, currency) {
const response = await fetch('/v1/charges', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ amount, currency }),
});
return response.json();
}
// On retry, reuse the SAME idempotencyKey
Key rule: generate the key once per logical operation, not once per HTTP request. If the client retries, it must send the same key.
Server-side deduplication#
The server needs a fast lookup mechanism to check whether a key has been seen before and retrieve the stored response.
The deduplication flow#
- Client sends request with idempotency key
- Server checks the idempotency store for the key
- Key not found — process the request, store the result, return response
- Key found, processing complete — return the stored response
- Key found, processing in progress — return 409 Conflict or retry-after
Handling concurrent requests#
Two requests with the same key can arrive simultaneously. The server must ensure only one executes:
def handle_request(idempotency_key, payload):
# Attempt to acquire a lock on the key
lock = acquire_lock(idempotency_key, ttl=30)
if not lock:
return Response(status=409, body="Request in progress")
try:
# Check for existing result
existing = idempotency_store.get(idempotency_key)
if existing:
return existing.stored_response
# Process the request
result = process_payment(payload)
# Store the result
idempotency_store.set(
idempotency_key,
stored_response=result,
ttl=86400 # 24 hours
)
return result
finally:
release_lock(lock)
Database constraints for idempotency#
For critical operations, use database constraints as the ultimate safety net.
Unique constraint approach#
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
idempotency_key UUID NOT NULL UNIQUE,
amount INTEGER NOT NULL,
currency VARCHAR(3) NOT NULL,
status VARCHAR(20) NOT NULL,
response JSONB,
created_at TIMESTAMP DEFAULT now()
);
The unique constraint on idempotency_key guarantees that even if the application-level check fails, the database rejects duplicates.
Insert-or-return pattern#
-- Attempt insert; on conflict, return existing record
INSERT INTO payments (idempotency_key, amount, currency, status)
VALUES ($1, $2, $3, 'pending')
ON CONFLICT (idempotency_key)
DO NOTHING
RETURNING *;
-- If no rows returned, fetch the existing payment
SELECT * FROM payments WHERE idempotency_key = $1;
Distributed idempotency with Redis#
In distributed systems with multiple application servers, the idempotency store must be shared. Redis is the common choice.
Redis implementation#
import redis
import json
r = redis.Redis(host='localhost', port=6379, db=0)
def check_idempotency(key):
"""Check if request was already processed."""
result = r.get(f"idempotency:{key}")
if result:
return json.loads(result)
return None
def store_idempotency(key, response, ttl=86400):
"""Store the response for future deduplication."""
r.setex(
f"idempotency:{key}",
ttl,
json.dumps(response)
)
def acquire_processing_lock(key, ttl=30):
"""Prevent concurrent processing of the same key."""
return r.set(
f"idempotency:lock:{key}",
"processing",
nx=True,
ex=ttl
)
Redis considerations#
- TTL — expire idempotency records after 24-48 hours to prevent unbounded growth
- Persistence — enable AOF or RDB snapshots so records survive Redis restarts
- Cluster mode — idempotency keys for the same API should hash to the same Redis slot
- Fallback — if Redis is unavailable, fall back to database constraints
Stripe-style implementation#
Stripe's idempotency implementation is the industry reference. Here is how it works:
Request lifecycle#
- Client sends
POST /v1/chargeswithIdempotency-Keyheader - Server looks up the key in the idempotency store
- If the key exists and the request parameters match, return the stored response
- If the key exists but parameters differ, return
400 Bad Request(prevents key reuse across different operations) - If the key is new, create a record with status
started - Process the charge through the payment pipeline
- Update the record with the final response and status
completed - Return the response
Parameter fingerprinting#
Stripe hashes the request body and compares it on retries. If a client reuses an idempotency key with different parameters, the server rejects it:
function validateIdempotencyRequest(storedRequest, incomingRequest) {
const storedFingerprint = hash(storedRequest.body);
const incomingFingerprint = hash(incomingRequest.body);
if (storedFingerprint !== incomingFingerprint) {
throw new Error(
'Idempotency key reused with different request parameters'
);
}
}
Key expiration#
Stripe expires idempotency keys after 24 hours. This balances deduplication coverage with storage costs.
Testing idempotent APIs#
Idempotency bugs are subtle. Build these tests into your CI pipeline.
Essential test cases#
- Basic dedup — send the same request twice, verify only one side effect occurs
- Concurrent requests — send two requests with the same key simultaneously, verify only one processes
- Parameter mismatch — reuse a key with different parameters, verify 400 response
- Expired keys — wait for TTL expiration, verify the request processes again
- Partial failure — crash the server mid-processing, retry, verify correct completion
Example test#
describe('Idempotency', () => {
it('should return the same response on retry', async () => {
const key = crypto.randomUUID();
const payload = { amount: 1000, currency: 'usd' };
const first = await createCharge(payload, key);
const second = await createCharge(payload, key);
expect(first.id).toBe(second.id);
expect(first.amount).toBe(second.amount);
// Verify only one charge was created
const charges = await listCharges();
const matching = charges.filter(c => c.idempotency_key === key);
expect(matching.length).toBe(1);
});
});
Load testing idempotency#
Use tools like k6 or Artillery to send thousands of duplicate requests and verify:
- No duplicate side effects (double charges, duplicate records)
- Consistent response bodies across retries
- Acceptable latency on cached responses vs first requests
- Correct behavior under Redis or database failures
Explore idempotency architectures#
On Codelit, generate an API gateway with idempotency middleware to see how requests flow through deduplication, locking, processing, and response caching. Click on any component to explore the retry and dedup logic.
This is article #377 in the Codelit engineering blog series.
Build and explore API reliability patterns visually at codelit.io.
Try it on Codelit
GitHub Integration
Paste any repo URL to generate an interactive architecture diagram from real code
Related articles
API Backward Compatibility: Ship Changes Without Breaking Consumers
6 min read
api designBatch API Endpoints — Patterns for Bulk Operations, Partial Success, and Idempotency
8 min read
system designCircuit Breaker Implementation — State Machine, Failure Counting, Fallbacks, and Resilience4j
7 min read
Try these templates
Build this architecture
Generate an interactive architecture for API Idempotency Patterns in seconds.
Try it in Codelit →
Comments