Idempotency Keys
June 30, 2026
Table of Contents
Overview
Idempotency keys are a pattern for making unsafe operations safer to retry. They are most commonly used with APIs where a client might send the same request more than once because of a timeout, network failure, browser retry, mobile reconnection, or worker crash.
The classic example is a payment API:
Client sends "charge customer $50"
Network times out
Client does not know whether the charge succeeded
Client retriesWithout idempotency, the user might be charged twice. With an idempotency key, the server can recognize that the retry is the same logical operation and return the original result instead of performing the operation again.
What Idempotency Means
An operation is idempotent if doing it multiple times has the same effect as doing it once.
For example:
PUT /users/123/emailwith body:
{
"email": "sam@example.com"
}This is naturally idempotent. Setting the email to the same value repeatedly leaves the system in the same state.
But this is not naturally idempotent:
POST /paymentswith body:
{
"amount": 5000,
"currency": "USD"
}Calling it twice might create two payments.
An idempotency key gives the server a way to say:
“I have already processed this logical operation. I should return the same answer, not perform it again.”
Why Idempotency Keys Matter
Distributed systems fail in awkward ways.
A client may not know whether a request succeeded because:
- the server completed the operation but the response was lost
- the server timed out after partially doing the work
- the client lost network connectivity
- a retry job crashed and restarted
- a load balancer closed the connection
- a mobile app retried after reconnecting
Idempotency keys let you make retries safe.
They are especially useful for operations that create or mutate state:
- payments
- order creation
- subscription changes
- account provisioning
- email sending
- inventory reservations
- job enqueueing
- webhook processing
The Basic Idempotency Key Flow
A typical flow looks like this:
1. Client generates a unique idempotency key
2. Client sends the key with the request
3. Server atomically checks/reserves the key
4. If this request owns the reservation, server processes the operation
5. Server stores the final response
6. If the same key is retried, server returns the stored responseExample request:
POST /orders
Idempotency-Key: 7e3f3d5c-1d47-4f73-a417-8e25e96a1b7a
Content-Type: application/json
{
"sku": "book_123",
"quantity": 1
}The key should generally be generated by the client, because the client is the one trying to say:
“These retry attempts all belong to the same logical operation.”
Where Idempotency Keys Are Most Useful
| Use Case | Why Idempotency Helps | Nuance |
|---|---|---|
| Payments | Prevents duplicate charges during retries | Must store enough response state to replay the original result |
| Order creation | Prevents duplicate orders from browser/mobile retries | Key should usually be scoped to user or account |
| Webhook processing | Handles providers that deliver the same event multiple times | The provider event ID often acts as the idempotency key |
| Job enqueueing | Prevents duplicate background jobs | May need a unique job key in the queue or database |
| Email sending | Prevents duplicate emails after worker crashes | Some systems prefer dedupe windows rather than permanent dedupe |
What Should Be Stored for an Idempotency Key?
A robust idempotency record usually stores:
-
idempotency key
-
user/account/tenant scope
-
request method and route
-
request fingerprint or hash
-
status:
processingcompletedfailed
-
response status code
-
response body or resource reference
-
creation time
-
expiry time
A minimal record might look like this:
{
"key": "7e3f3d5c-1d47-4f73-a417-8e25e96a1b7a",
"scope": "user_123",
"request_hash": "sha256:...",
"status": "completed",
"response_code": 201,
"response_body": {
"order_id": "ord_456"
},
"expires_at": "2025-12-31T12:00:00Z"
}The key nuance is that the server should not merely remember “this key happened.” It should remember enough to answer:
“Was this retry identical, and what response should I return?”
Request Fingerprinting
A common bug is letting the same idempotency key be reused with different request bodies.
For example:
First request:
Idempotency-Key: abc
amount: $50
Second request:
Idempotency-Key: abc
amount: $500That should not silently return the first response. It should usually return a conflict-style error because the key is being reused for a different operation.
A request fingerprint helps detect this.
import hashlib
import json
def stable_json_hash(payload: dict) -> str:
normalized = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()You can include:
- HTTP method
- route or operation name
- normalized request body
- authenticated user or tenant scope
Do not include unstable values like:
- timestamps generated by the server
- tracing headers
- random request IDs
- header order
Concurrency and Race Conditions
The hardest part of idempotency is not the happy path. It is the race condition where two identical requests arrive at the same time.
Bad implementation:
1. Request A checks key: missing
2. Request B checks key: missing
3. Request A creates payment
4. Request B creates payment
5. Duplicate side effectThe fix is to atomically reserve the key before performing the side effect.
In SQL, that usually means:
INSERT INTO idempotency_keys (scope, key, request_hash, status)
VALUES ($1, $2, $3, 'processing')
ON CONFLICT (scope, key) DO NOTHING;Then:
- if insert succeeds, this request owns processing
- if insert fails, another request already owns or completed it
- the retry should wait, return conflict, or replay the completed response
| Handling Duplicate In-Flight Request | Pros | Cons |
|---|---|---|
| Return 409 Conflict | Simple; avoids blocking server resources | Client must retry later |
| Wait for first request to finish | Better client experience | Consumes server resources; needs timeout handling |
| Return 202 Accepted | Good for async jobs | Client needs polling or callback flow |
Response Replay: Return the Same Result
A strong idempotency implementation does not just prevent duplicate writes. It returns the same response for the same logical operation.
Example:
First request:
POST /orders
Idempotency-Key: abc
Response:
201 Created
{ "order_id": "ord_123" }
Retry:
POST /orders
Idempotency-Key: abc
Response:
201 Created
{ "order_id": "ord_123" }This matters because clients may have lost the original response. The retry is not just asking “please do it again.” It is asking:
“What happened when I asked you to do this?”
TTL and Cleanup Strategy
Idempotency keys should not usually live forever.
A TTL controls how long the server remembers the operation.
Common TTL choices:
- 15 minutes for low-risk form submissions
- 24 hours for payments or order creation
- several days for webhook/event dedupe
- longer if business requirements demand it
| TTL Strategy | Pros | Cons |
|---|---|---|
| Short TTL | Lower storage cost; less retained metadata | Late retries may duplicate work after expiry |
| Long TTL | Better protection for delayed retries and webhooks | More storage; more cleanup work; more retained request metadata |
| Permanent dedupe | Strongest duplicate prevention | Can become expensive and may conflict with privacy/data-retention goals |
A good cleanup process should remove expired keys safely without deleting records still needed for active retries.
Idempotency Keys vs. Natural Idempotency
Some operations are naturally idempotent.
PUT /users/123/profileIf the request says “set profile to this exact value,” repeating it does not create a duplicate resource.
Other operations are not naturally idempotent:
POST /orders
POST /payments
POST /emails/sendThose often benefit from explicit idempotency keys.
| Approach | Best For | Trade-off |
|---|---|---|
| Natural idempotency | Set/update operations with stable resource IDs | Requires API design that makes repeated calls safe by construction |
| Idempotency keys | Create/side-effect operations where retries may duplicate work | Requires storage, TTLs, request hashing, and concurrency handling |
Database Design Example
A simple PostgreSQL table might look like this:
CREATE TABLE idempotency_keys (
scope TEXT NOT NULL,
key TEXT NOT NULL,
request_hash TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('processing', 'completed', 'failed')),
response_code INTEGER,
response_body JSONB,
resource_type TEXT,
resource_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (scope, key)
);Why (scope, key)?
Because idempotency keys should usually be scoped. Two different users might accidentally generate the same UUID-like key. Scoping prevents one user’s key from affecting another user’s request.
Common scopes:
- user ID
- tenant ID
- API key ID
- merchant/account ID
Example Implementation
Here is a simplified Python-style flow:
def create_order(request, db):
scope = request.user.id
key = request.headers.get("Idempotency-Key")
if not key:
raise BadRequest("Missing Idempotency-Key")
request_hash = stable_json_hash({
"method": "POST",
"route": "/orders",
"body": request.json,
})
record = db.get_idempotency_record(scope, key)
if record:
if record.request_hash != request_hash:
raise Conflict("Idempotency-Key reused with different request")
if record.status == "completed":
return Response(record.response_body, status=record.response_code)
if record.status == "processing":
raise Conflict("Request already in progress")
if record.status == "failed":
raise Conflict("Previous attempt failed; retry requires recovery or a new idempotency key")
inserted = db.try_insert_idempotency_record(
scope=scope,
key=key,
request_hash=request_hash,
status="processing",
)
if not inserted:
raise Conflict("Request already in progress")
try:
order = db.create_order(user_id=scope, body=request.json)
response_body = {"order_id": order.id}
db.mark_idempotency_completed(
scope=scope,
key=key,
response_code=201,
response_body=response_body,
)
return Response(response_body, status=201)
except Exception:
db.mark_idempotency_failed(scope=scope, key=key)
raiseA
failedidempotency record needs explicit semantics. Some APIs replay the original failure response. Others require manual recovery, allow retry only if the side effect is known not to have happened, or store a resource reference so the operation can be completed safely. Do not treatfailedas the same thing asprocessing.
In a real implementation, the insert/reservation and the actual business write often need careful transaction boundaries.
The key idea:
- reserve first
- perform side effect once
- persist response
- replay response on retry
Common Pitfalls (and Fixes)
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Checking before inserting | Two concurrent requests both see no record | Use atomic insert / unique constraint to reserve the key |
| Not hashing the request | Same key can be reused for different operations | Store a stable request fingerprint and reject mismatches |
| Only storing “seen” keys | Client retries need the original result | Store response status/body or a resource reference |
| Global key scope | One user’s key can collide with another user’s key | Scope keys by user, tenant, account, or API key |
| Deleting keys too soon | Late retries arrive after TTL expiry | Choose TTL based on retry behavior and business risk |
| Retrying failed partial operations incorrectly | The side effect may have partially completed | Use transactions, resource references, and explicit recovery states |
Conclusion
Idempotency keys are one of the most practical reliability tools in API design. They let clients retry safely when the network lies, timeouts happen, workers crash, or responses disappear. The pattern is simple at the surface—send a unique key with a request—but the production details matter: atomic reservation, request fingerprinting, scoped keys, response replay, TTLs, and careful handling of in-flight duplicates.
Used well, idempotency keys turn dangerous retries into predictable, recoverable operations.
Key Takeaways
- Idempotency keys make unsafe operations safer to retry.
- They are most useful for
POST-style create/side-effect operations. - Always scope keys by user, tenant, account, or API key.
- Use atomic reservation to avoid race conditions.
- Store a request fingerprint to detect key misuse.
- Store enough response information to replay the original result.
- Choose TTLs based on real retry windows and business risk.

