REST API 19 ๐ Idempotency in Write Operations (Idempotency Keys)
A client sends POST /payments, the connection times out before the response arrives, and the client retries. Without an idempotency mechanism, the server processes both requests and charges the customer twice. The timeout does not mean the request failedโit means the outcome is unknown. The request may have never reached the server, it may have failed mid-processing, or it may have succeeded with the response lost in transit. The third case is the dangerous one: the operation completed, and the retry duplicates it . Idempotency keys exist to make retries safe by ensuring that a request with the same key produces the same outcome regardless of how many times it arrives.
The Idempotency-Key header has moved from a Stripe-specific convention to an IETF draft standard. The draft specifies that the header carries a client-generated unique value, that the server uses it to recognize retries of the same request, and that a key must not be reused with a different request payload . Stripe, Adyen, Dwolla, Interledger, WorldPay, Yandex, and others already implement the header, and the IETF draft records their implementations as evidence of the pattern’s maturity . The core guarantee is outcome-based, not receipt-based: the server must return the original response for a repeated key, not merely acknowledge that the key was seen before.
This chapter covers the key generation rules, the atomic claim that prevents concurrent duplicates, the request fingerprint that detects key reuse with different payloads, the storage and expiry decisions that trade safety against table growth, and the replay semantics that make retries transparent to the client. It assumes familiarity with HTTP write operations and the distinction between at-least-once delivery and exactly-once effects.
Key point: An idempotency key is a promise about outcomes, not about request receipt. A check-then-process sequence has a gap where two concurrent requests both see “key not found” and both proceed. The fix is an atomic claim on the keyโINSERT ... ON CONFLICT DO NOTHINGโthat exactly one request can win, with the loser either waiting for the winner’s result or returning a conflict response.
Why idempotency keys exist as a protocol concern
Timeouts are ambiguous. A client that sends a request and receives no response within its timeout window cannot know whether the server processed the request. The network may have dropped the request before it arrived, the server may have crashed mid-processing, or the server may have completed the operation and the response may have been lost. Retrying without an idempotency key turns the third case into a duplicate operation. The IETF draft describes this as the core motivation: “Is the resource actually created? Did the timeout occur during sending of the request, or when receiving of the response? Can the client safely retry the request, or does it need to figure out what happened in the first place?” .
Non-idempotent methods need external guarantees. POST is not idempotent by HTTP definition. Sending it twice may create two resources. PUT and DELETE are idempotent by definition, which is why Stripe recommends not sending idempotency keys with GET and DELETEโthese methods are already safe to retry . The idempotency key makes POST and PATCH fault-tolerant by giving the server a way to recognize that two requests are the same logical operation.
The key is bound to a business intent, not a retry attempt. The most common implementation error is generating a new key inside the retry loop. Each retry then presents a new identity, and the server has no way to know it is the same intent. The key must be generated once per business operationโone purchase, one payment, one orderโand reused across all retries of that operation . Stripe’s documentation recommends deriving the key from business intent: create-pi-${orderId}-${attemptId}, not a random UUID minted per HTTP call .
Concurrency is the harder problem. Sequential retries are the easy case: request one times out, request two arrives seconds later, and the server can check whether the key exists. Concurrent duplicatesโboth arriving within millisecondsโare what most implementations get wrong. Both requests check the key store, both find nothing, and both proceed to execute the operation . The fix is an atomic claim: a database INSERT ... ON CONFLICT DO NOTHING or a Redis SET NX that exactly one request can win. The loser must not proceed; it must wait for the winner’s result or return a 409 Conflict indicating that a request with that key is already being processed .
The trade-off. Idempotency adds a storage dependency to every write endpoint. The key store must be durable enough to survive the retry window, fast enough not to bottleneck writes, and cleaned up so it does not grow without bound. The Idempotency-Key header is not a substitute for database constraintsโit is a protocol layer that lets clients participate in deduplication. The server still needs its own atomicity guarantees.
a. Key generation and the fingerprint
The client generates the key. The IETF draft recommends UUIDs or a similar random identifier with enough entropy to avoid collisions . Stripe suggests V4 UUIDs or another random string up to 255 characters and explicitly warns against using sensitive data like email addresses or personal identifiers as keys . A deterministic key derived from business intentโpayment:${orderId}:${attemptId}โis also valid and has the advantage of being stable across client restarts, but it must be unique per logical operation and must not collide with a different operation.
The server must bind the key to the request payload. Without a fingerprint, a client bug that reuses the same key for two genuinely different requestsโa different amount, a different recipientโwould receive the first request’s result silently. The IETF draft defines an optional idempotency fingerprint: a checksum of the entire payload, a checksum of selected fields, or a request digest . Stripe compares incoming parameters to the original request and errors if they differ . The fingerprint should be stored alongside the key and compared on every reuse.
POST /payments HTTP/1.1
Host: api.example.com
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
Content-Type: application/json
{"amount": 5000, "currency": "usd", "recipient": "acct_123"}
The server records the key, the fingerprint of {"amount":5000,"currency":"usd","recipient":"acct_123"}, and the response. A retry with the same key and the same body returns the recorded response. A request with the same key and a different bodyโ{"amount":9999,...}โis rejected with 422 Unprocessable Entity or a 400 error, depending on the server’s documentation .
b. The atomic claim and the state machine
A naive implementation checks whether the key exists, and if not, processes the request and stores the result. The gap between the check and the store is where concurrent duplicates slip through. Both requests check, both find nothing, both process, and one of them overwrites the other’s resultโor both charge the card .
The correct pattern is a two-phase state machine with an atomic claim. The first phase inserts a row with status processing using an atomic operation that fails if the key already exists. The request that wins the insert owns the operation and proceeds to execute the business logic. The request that loses the insert reads the existing row and acts according to its status: if completed, it returns the stored response; if processing, it returns 409 Conflict or waits for completion; if failed, it may retry with the same key .
-- Atomic claim: exactly one request can insert this row.
INSERT INTO idempotency_keys (key, request_hash, status, created_at)
VALUES ($1, $2, 'processing', now())
ON CONFLICT (key) DO NOTHING
RETURNING key;
If the insert returns a row, the request owns the operation. It executes the business logic, then updates the row to completed with the response. If the insert returns no row, another request has already claimed the key. The losing request queries the existing row and handles it according to its status .
The IETF draft specifies 409 Conflict for the case where the original request is still being processed: “If the request is retried, while the original request is still being processed, the resource SHOULD reply with an HTTP 409 status code” . The error body should explain that a request with that key is outstanding. Some implementations instead wait for the original request to complete and return its result, which is more transparent to the client but requires the loser to hold a connection open or poll.
The state machine must handle crashes. If the server crashes after claiming the key but before completing the operation, the row remains in processing forever unless a staleness check or TTL releases it. Stripe’s implementation saves results only after endpoint execution begins, and concurrent conflicts do not save a result because no endpoint initiated the execution . A robust implementation includes a lock_ttl or processing timeout: a row in processing older than the TTL is treated as abandoned and eligible for a new claim, with the understanding that the original operation’s outcome may be unknown.
c. Storage, expiry, and replay semantics
The storage backend determines durability and latency. Redis is fast and suitable for retry windows measured in hours to days, but it is volatileโif Redis crashes without persistence, keys are lost and retries become new operations . A database unique constraint is durable and provides an audit trail, at the cost of a write per request. Stripe keeps keys for at least 24 hours and automatically prunes older entries; a key reused after pruning is treated as a new request . The expiry window must be at least as long as the client’s retry window. If the client retries after 25 hours and the key was pruned at 24, the retry duplicates the operation.
-- Schema for database-backed idempotency.
CREATE TABLE idempotency_keys (
key VARCHAR(255) PRIMARY KEY,
request_hash VARCHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'processing',
response JSONB,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP DEFAULT NOW() + INTERVAL '24 hours'
);
The replay must return the original response, byte for byte where practical, including the original status code. A client retrying a request expects the same acknowledgement it would have received the first time. Returning a different status or a different body shape on replay produces client-side bugs that only appear during retries, which makes them rare and hard to reproduce . Stripe’s documentation is explicit: “Subsequent requests with the same key return the same result, including 500 errors” . The exception is the 409 Conflict for an in-flight request, which is not a replay of a completed operation.
The server must also document which error scenarios are retryable. The IETF draft distinguishes between errors that mean the request was processed and failed (which should be replayed) and errors that mean the request was rejected before processing (which the client must correct before retrying) . A 400 Bad Request for malformed input is not idempotent in the sense that retrying with the same malformed input will produce the same rejection; the client must fix the request. A 500 Internal Server Error that occurred after the operation started is replayed, because the operation may have partially completed.
Complete Example Session
# ============================================
# PART 1: INITIAL REQUEST
# ============================================
POST /payments HTTP/1.1
Host: api.example.com
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
Content-Type: application/json
{"amount": 5000, "currency": "usd", "recipient": "acct_123"}
# Server:
# 1. Compute fingerprint of payload.
# 2. INSERT (key, fingerprint, 'processing') ON CONFLICT DO NOTHING.
# 3. Insert succeeds โ own the operation.
# 4. Execute payment.
# 5. UPDATE status='completed', response=... WHERE key=...
HTTP/1.1 201 Created
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
Content-Type: application/json
{"id": "pay_abc", "status": "succeeded", "amount": 5000}
# ============================================
# PART 2: RETRY WITH SAME KEY AND BODY
# ============================================
POST /payments HTTP/1.1
Host: api.example.com
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
Content-Type: application/json
{"amount": 5000, "currency": "usd", "recipient": "acct_123"}
# Server:
# 1. Fingerprint matches.
# 2. INSERT fails (key exists).
# 3. SELECT status='completed', response.
# 4. Replay response.
HTTP/1.1 201 Created
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
Content-Type: application/json
{"id": "pay_abc", "status": "succeeded", "amount": 5000}
# ============================================
# PART 3: CONCURRENT DUPLICATE
# ============================================
# Request A and Request B arrive within 10ms.
# A: INSERT succeeds โ status='processing'.
# B: INSERT fails (key exists).
# B: SELECT status='processing'.
# B returns 409 Conflict, not 200.
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://api.example.com/idempotency",
"title": "A request is outstanding for this Idempotency-Key",
"detail": "A request with the same Idempotency-Key is being processed or is outstanding."
}
# B should retry after a delay, or poll the original response.
# ============================================
# PART 4: KEY REUSE WITH DIFFERENT BODY
# ============================================
POST /payments HTTP/1.1
Host: api.example.com
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
Content-Type: application/json
{"amount": 9999, "currency": "usd", "recipient": "acct_123"}
# Server:
# 1. Fingerprint of new body โ stored fingerprint.
# 2. Reject. Do not replay the 5000 response.
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://api.example.com/idempotency",
"title": "Idempotency-Key reused with different payload",
"detail": "This key was already used for a request with a different body."
}
# ============================================
# PART 5: THE TWO-PHASE STATE MACHINE
# ============================================
# processing โ completed (success)
# processing โ failed (error; client may retry)
# processing โ abandoned (TTL expired; new claim possible)
#
# 'failed' should replay the error on retry.
# 'abandoned' is treated as a new request.
# ============================================
# PART 6: STORAGE CHOICE
# ============================================
# Redis:
# Fast (~1ms), TTL support, volatile.
# Best for short retry windows (hours to days).
#
# Database:
# Durable, auditable, slower (~5-20ms).
# Best for payments requiring audit trail.
#
# Hybrid:
# Redis as first check, DB as authoritative store.
# ============================================
# PART 7: ERROR SCENARIOS (IETF DRAFT)
# ============================================
# 400/422: key reused with different payload.
# Client must correct before retrying.
#
# 409 Conflict: original request in flight.
# Client retries later; no correction needed.
#
# 500: original request failed after starting.
# Replay the 500 on retry.
# ============================================
# PART 8: KEY GENERATION RULES
# ============================================
# โ
V4 UUID, 255 chars max.
# โ
Deterministic from business intent:
# "payment:{orderId}:{attemptId}"
# โ Random UUID generated inside retry loop.
# โ Timestamp alone (collisions).
# โ Email/phone as key (PII in logs).
# ============================================
# PART 9: EXPIRY
# ============================================
# Stripe: 24 hours, then pruned.
# Key reused after pruning โ new request.
# Window must exceed client retry window.
# Document the window in API docs.
# ============================================
# PART 10: THE COMPLETE FLOW
# ============================================
# 1. Client generates key once per intent.
# 2. Sends Idempotency-Key header on POST/PATCH.
# 3. Server atomically claims key.
# 4. Winner processes; loser returns 409 or replays.
# 5. Winner stores response + status.
# 6. Retry with same key + body โ replay.
# 7. Retry with same key + different body โ 422.
# 8. Key expires after documented TTL.
Quick Reference
Key Lifecycle
| Step | Action | Failure Mode |
|---|---|---|
| Generate | Client creates unique key | Random per retry โ duplicates |
| Send | Idempotency-Key header | Missing โ no dedup |
| Claim | INSERT ... ON CONFLICT DO NOTHING | Check-then-insert โ race |
| Process | Execute business logic | Crash โ stuck processing |
| Store | Save response + status | Lost response โ retry duplicates |
| Replay | Return stored response | Different body โ 422 |
Error Scenarios
| Status | Meaning | Client Action |
|---|---|---|
422 | Key reused with different body | Correct the request |
409 | Original request in flight | Retry later |
500 (replayed) | Original failed after starting | Same error on retry |
201 (replayed) | Original succeeded | Same success response |
Storage Comparison
| Backend | Latency | Durability | Best For |
|---|---|---|---|
| Redis | ~1ms | Volatile | Short retry windows |
| Database | ~5โ20ms | Durable | Audit trail, payments |
| Hybrid | Mixed | Both | High-volume + compliance |
Key Generation Rules
| Rule | Correct | Incorrect |
|---|---|---|
| Unique per intent | payment:order:123:attempt:1 | uuid4() per retry |
| No PII | UUID or business ID | user@example.com |
| Length | โค255 chars | Arbitrary length |
| Entropy | V4 UUID or ULID | Timestamp alone |
Best Practices
โ Do This:
# Send Idempotency-Key on POST and PATCH. // โ
POST /payments
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
# Generate once per business intent, reuse on retry. // โ
key = f"payment:{order_id}:{attempt_id}"
# Atomically claim the key before processing. // โ
INSERT ... ON CONFLICT (key) DO NOTHING RETURNING key;
# Store the request fingerprint alongside the key. // โ
request_hash = sha256(json.dumps(payload))
# Replay the original response on retry. // โ
return stored_status, stored_body
โ Don’t Do This:
# Generate a new key inside the retry loop. // โ
for attempt in range(3):
key = uuid4() # New identity each time; no dedup.
# Check-then-process without atomic claim. // โ
if not key_exists(key): # Race window.
process() # Both concurrent requests pass.
# Replay a response for a different request body. // โ
# 5000 response returned for a 9999 request โ correctness bug.
# Use PII as the key. // โ
Idempotency-Key: "user@example.com" # Leaks in logs.
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Double charge on retry | New key generated per retry | Generate once per intent |
| Concurrent duplicates | Check-then-insert race | Atomic INSERT ... ON CONFLICT |
| Wrong response on replay | No fingerprint comparison | Store and compare request hash |
| Key store grows unbounded | No TTL | Expire after documented window |
Stuck processing forever | Crash without cleanup | lock_ttl or staleness check |
| PII in logs | Email/phone as key | Use UUID or business ID |
Real-World Examples
1. Stripe-Style Header
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
2. Atomic Claim (PostgreSQL)
INSERT INTO idempotency_keys (key, status) VALUES ($1, 'processing')
ON CONFLICT (key) DO NOTHING RETURNING key;
3. Request Fingerprint
fingerprint = hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
4. 409 for In-Flight
HTTP/1.1 409 Conflict
{"title": "A request is outstanding for this Idempotency-Key"}
5. 422 for Key Reuse
HTTP/1.1 422 Unprocessable Entity
{"title": "Idempotency-Key reused with different payload"}
6. Deterministic Key
key = f"payment:{order_id}:{attempt_id}"
7. Two-Phase State
UPDATE idempotency_keys SET status='completed', response=$2 WHERE key=$1;
8. Redis TTL
SET idempotency:key "response" EX 86400
9. Database Schema
CREATE TABLE idempotency_keys (
key VARCHAR(255) PRIMARY KEY,
request_hash VARCHAR(64) NOT NULL,
status VARCHAR(20) DEFAULT 'processing',
response JSONB
);
10. Retry After 409
if response.status == 409:
time.sleep(1)
retry_same_key()
Visual
The Ambiguous Timeout
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ CLIENT TIMEOUT โ
โ โ
โ Client sends POST /payments โ
โ โ โ
โ โโโ Request never arrived โ safe retry โ
โ โโโ Server crashed โ safe retry โ
โ โโโ Server succeeded, response lost โ
โ โ โ
โ โผ โ
โ Retry without key โ DOUBLE CHARGE โ
โ โ
โ The timeout does not tell the client โ
โ which case occurred. โ
โ โ
โ Idempotency key makes all three cases safe. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Atomic Claim vs Check-Then-Process
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ RACE CONDITION โ
โ โ
โ Check-then-process: โ
โ A: key exists? no โ process โ
โ B: key exists? no โ process โ
โ Both process. Duplicate. โ
โ โ
โ Atomic claim: โ
โ A: INSERT key โ succeeds โ process โ
โ B: INSERT key โ fails โ read status โ
โ Exactly one processes. โ
โ โ
โ The INSERT is the lock. โ
โ ON CONFLICT DO NOTHING is the release. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
State Machine
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ IDEMPOTENCY STATES โ
โ โ
โ (new key) โ
โ โ โ
โ โผ โ
โ processing โโโโ success โโโโโบ completed โ
โ โ โ โ
โ โ โโโ replay โ
โ โ โ
โ โโโโโ error โโโโโบ failed โ
โ โ โ โ
โ โ โโโ replay error โ
โ โ โ
โ โโโโโ TTL โโโโโบ abandoned โ
โ โ โ
โ โโโ new claim allowedโ
โ โ
โ Concurrent duplicate on 'processing' โ 409 โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Fingerprint Mismatch
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ KEY REUSE WITH DIFFERENT BODY โ
โ โ
โ Stored: โ
โ key: abc123 โ
โ hash: sha256({"amount":5000}) โ
โ response: 201 pay_abc โ
โ โ
โ Retry: โ
โ key: abc123 โ
โ hash: sha256({"amount":9999}) โ differs โ
โ โ
โ Action: 422, not replay. โ
โ โ
โ Without fingerprint: client receives โ
โ pay_abc for a 9999 request โ silent โ
โ correctness failure. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| Header | Idempotency-Key (IETF draft) |
| Key generation | Client, V4 UUID or deterministic |
| Key scope | One per business intent, reused on retry |
| Atomic claim | INSERT ... ON CONFLICT DO NOTHING |
| Fingerprint | Hash of request body, compared on reuse |
| In-flight duplicate | 409 Conflict |
| Key reuse, different body | 422 (or 400) |
| Replay | Original status + body, byte for byte |
| TTL | โฅ client retry window (Stripe: 24h) |
| Storage | Redis (fast) or database (durable) |
Key takeaways:
- Timeouts are ambiguous; idempotency keys make retries safe. The timeout does not reveal whether the request was never received, failed mid-processing, or succeeded with a lost response. The key lets the server recognize a retry and return the original outcome .
- The key must be generated once per business intent, not per retry. A key created inside the retry loop presents a new identity on every attempt, defeating deduplication entirely .
- The atomic claim is the concurrency fix. Check-then-process has a race window where both concurrent requests see no key and both proceed.
INSERT ... ON CONFLICT DO NOTHINGensures exactly one request owns the operation . - The fingerprint prevents silent correctness failures. Without it, a reused key with a different payload returns the first request’s response for a different operation. Store a hash and reject mismatches .
- Replay the original response, including errors. Stripe returns the same status and body for a repeated key, including
500errors. A different response on replay produces client bugs that only appear during retries . - The TTL must exceed the client retry window. A key pruned before the client’s last retry becomes a new request. Stripe’s 24-hour window is a reasonable default for most flows, but the server must document it .
- Use the key only on non-idempotent methods.
POSTandPATCHneed it.GETandDELETEare idempotent by definition, and sending the header has no effect .
Remember: An idempotency key is a protocol for making retries safe, not a database uniqueness check. The client generates it once per business intent and reuses it across every retry. The server atomically claims it, executes the operation, stores the response, and replays that response for any subsequent request with the same key. The request fingerprint ensures the key is bound to the same payload, so a reused key with different data is rejected rather than silently replaying the wrong result. The state machine handles concurrency: one request wins the claim and processes, while duplicates receive a conflict or a replay. The storage choiceโRedis for speed, database for durabilityโdetermines the failure modes, and the TTL determines how long retries remain safe. Get the atomic claim right, and the double charge disappears.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!