Idempotency Keys: The One API Pattern That Prevents Duplicate Charges and Phantom Orders
Your user clicks "Place Order." The request hits your server, the charge succeeds, and then — network timeout. The client never got the 200. So it retries. Now you have two charges and one very angry customer.
This is exactly the problem idempotency keys were invented to solve. If you're building any API that mutates state — payments, order creation, email sends — not implementing idempotency isn't just a bug risk, it's a liability.
What Is an Idempotency Key?
An idempotency key is a unique identifier the client generates and includes with every mutating request. The server uses it to detect duplicate submissions and return the same result as the original, without re-executing the operation.
The Stripe API made this pattern mainstream. Pass Idempotency-Key: <uuid> in the header, and Stripe guarantees that retrying the same request never creates a second charge.
Implementing Idempotency on the Server
Here's a minimal but production-ready implementation in Python (FastAPI + Redis):
import hashlib
import json
import redis
from fastapi import FastAPI, Header, HTTPException, Request
from typing import Optional
app = FastAPI()
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
IDEMPOTENCY_TTL = 86400 # 24 hours
@app.post("/orders")
async def create_order(
request: Request,
idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key")
):
body = await request.json()
if idempotency_key:
cache_key = f"idempotency:{idempotency_key}"
cached = r.get(cache_key)
if cached:
# Return the original response — no duplicate processing
return json.loads(cached)
# Process the order for real
order = process_order(body)
result = {"order_id": order.id, "status": "created"}
if idempotency_key:
# Cache the result for future retries
r.setex(cache_key, IDEMPOTENCY_TTL, json.dumps(result))
return result
Key implementation decisions here:
- TTL of 24 hours: Long enough to cover any reasonable retry window.
- Cache after success only: If the request failed before producing a result, don't cache — let the retry actually run.
-
Scope keys per endpoint: A key for
/ordersshouldn't collide with one for/refunds. Prefix with the route or resource type.
The Client Side: Generating and Reusing Keys
The client must generate the key before sending and reuse it on every retry of the same logical operation:
import { v4 as uuidv4 } from 'uuid';
async function placeOrder(cartId, paymentToken) {
// Generate ONCE per logical operation — store it if you need to retry
const idempotencyKey = uuidv4();
const response = await fetchWithRetry('https://api.example.com/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ cartId, paymentToken }),
});
return response.json();
}
async function fetchWithRetry(url, options, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const res = await fetch(url, options);
if (res.ok || res.status < 500) return res; // Don't retry 4xx
await sleep(Math.pow(2, attempt) * 200); // Exponential backoff
} catch (e) {
if (attempt === maxRetries - 1) throw e;
}
}
}
Notice that the same idempotencyKey is passed on every retry call — the key must not be regenerated between attempts or you defeat the purpose entirely.
Edge Cases You Must Handle
1. Mismatched request bodies. If a client sends the same key with a different body, that's likely a client bug. Return 422 Unprocessable Entity with a clear error rather than silently returning the cached response.
2. In-flight requests. If a second request arrives with the same key while the first is still processing, return 409 Conflict with a Retry-After header. Don't start a second execution.
3. Partial failures. If your order creation writes to a database but then fails to charge the card, did you cache a result? No — only cache after the entire operation succeeds.
4. Key exhaustion / replay attacks. Keys should be UUIDs (122 bits of entropy). Don't let clients reuse keys for genuinely new operations.
When You Don't Need Idempotency Keys
GET, HEAD, and OPTIONS are already idempotent by definition — calling them twice has no side effects. You only need explicit keys for POST (and sometimes PATCH) when the operation isn't naturally idempotent.
PUT is usually safe without keys because it's designed to be idempotent: PUT /orders/123 with the same body should produce the same result every time.
Testing Idempotency in Your API Client
Before shipping, you should test three scenarios:
- Send the same key twice — verify identical responses and a single DB record.
- Send the same key with a different body — verify rejection.
- Send concurrent requests with the same key — verify exactly one succeeds.
This is the kind of testing that's easy to forget in the rush to ship but painful to debug in production.
If you want to explore and test idempotency behaviour across your API collection — inspecting request headers, replaying calls, and comparing responses — APIKumo makes it easy to build and share those test scenarios directly alongside your API documentation.
Top comments (0)