Idempotency Keys: How to Make Your API Requests Safe to Retry
Networks fail. Servers time out. Users double-click submit buttons. When any of these happen with a non-idempotent API request, you end up with duplicate charges, duplicate orders, or corrupted state — the kind of bug that's infuriating to debug at 2am.
Idempotency keys are the standard solution. They're a small addition to your API design that makes retries completely safe. Here's how they work and how to implement them.
What Is an Idempotency Key?
An idempotency key is a unique identifier the client generates and sends with a request. The server uses it to detect duplicate requests and return the same response instead of processing the operation again.
The client sends:
POST /payments
Idempotency-Key: a8098c1a-f86e-11da-bd1a-00112444be1e
Content-Type: application/json
{"amount": 4999, "currency": "usd", "customer_id": "cus_123"}
If the network drops and the client retries with the same key, the server returns the original response — no second charge.
Why GET Isn't Enough
GET requests are idempotent by definition (reading doesn't change state). The problem is POST, PUT, and PATCH requests that mutate data. A naive retry of POST /payments without an idempotency key will create two payments. Your users will notice.
Implementing Idempotency Keys on the Server
Here's a minimal Express.js middleware that adds idempotency support to any route:
const crypto = require('crypto');
// In-memory store — use Redis in production
const idempotencyStore = new Map();
function idempotencyMiddleware(req, res, next) {
const key = req.headers['idempotency-key'];
// Skip for non-mutating methods
if (!key || ['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
if (idempotencyStore.has(key)) {
const cached = idempotencyStore.get(key);
// If still processing, return 409 Conflict
if (cached.status === 'processing') {
return res.status(409).json({
error: 'A request with this idempotency key is already in progress'
});
}
// Return the cached response
return res.status(cached.statusCode).json(cached.body);
}
// Mark as processing
idempotencyStore.set(key, { status: 'processing' });
// Intercept the response to cache it
const originalJson = res.json.bind(res);
res.json = (body) => {
idempotencyStore.set(key, {
status: 'complete',
statusCode: res.statusCode,
body
});
return originalJson(body);
};
next();
}
app.use(idempotencyMiddleware);
In production, replace the Map with Redis and set a TTL (24 hours is common):
const redis = require('redis');
const client = redis.createClient();
async function getIdempotencyRecord(key) {
const data = await client.get(`idempotency:${key}`);
return data ? JSON.parse(data) : null;
}
async function setIdempotencyRecord(key, record, ttlSeconds = 86400) {
await client.setEx(
`idempotency:${key}`,
ttlSeconds,
JSON.stringify(record)
);
}
Generating Keys on the Client
The client is responsible for generating idempotency keys. Use UUIDs — they're long enough to avoid collisions and widely supported:
import uuid
import requests
def create_payment(amount: int, currency: str, customer_id: str) -> dict:
"""
Creates a payment with automatic retry safety via idempotency key.
The key is tied to the logical operation, not the HTTP request.
"""
idempotency_key = str(uuid.uuid4())
response = requests.post(
'https://api.example.com/payments',
json={
'amount': amount,
'currency': currency,
'customer_id': customer_id
},
headers={
'Idempotency-Key': idempotency_key,
'Authorization': 'Bearer your-token'
}
)
response.raise_for_status()
return response.json()
Critical rule: generate the key once per logical operation, not per HTTP request. Store it before making the call, so that if your process crashes between attempts you can retry with the same key:
# Persist the key BEFORE the first attempt
operation_id = str(uuid.uuid4())
db.save_pending_payment(order_id, operation_id)
# Now retry safely — same key every time
for attempt in range(1, 4):
try:
result = requests.post(
'https://api.example.com/payments',
json=payload,
headers={'Idempotency-Key': operation_id}
)
result.raise_for_status()
break
except requests.RequestException:
if attempt == 3:
raise
time.sleep(2 ** attempt) # exponential backoff
What to Return for Duplicate Requests
Always return exactly the same status code and body as the original response — even if the original returned an error. If the first request failed with a 422 validation error, a retry with the same key should return the same 422, not attempt the operation again.
The one exception: if the original request is still in-flight (a race condition), return 409 Conflict to signal that the client should wait before retrying.
Common Mistakes
Scoping keys too broadly. An idempotency key should be scoped to a single resource type and operation. Don't reuse the same key across POST /payments and POST /refunds — treat them as separate operations.
Expiring keys too quickly. If a user comes back to retry an operation three days later, they expect the same outcome. 24 hours is a minimum; 7 days is safer for most payment flows.
Not validating the request body matches. If a client sends the same key but with a different amount, that's likely a bug. Return a 422 and log it — don't silently process a different operation.
Testing Idempotency
Add an explicit test that fires the same request twice and asserts only one side effect occurred:
test('duplicate payment request returns same result without charging twice', async () => {
const key = 'test-key-' + Date.now();
const payload = { amount: 1000, currency: 'usd', customer_id: 'cus_test' };
const first = await api.post('/payments', payload, { 'Idempotency-Key': key });
const second = await api.post('/payments', payload, { 'Idempotency-Key': key });
expect(first.body.id).toEqual(second.body.id); // Same payment ID
expect(await db.countPayments('cus_test')).toBe(1); // Only one charge
});
Wrapping Up
Idempotency keys are a small addition that prevent a whole class of serious bugs. Any API that accepts money, creates resources, or triggers irreversible actions should support them — and clients should use them by default.
When you're building and testing APIs that require this kind of reliability, APIKumo makes it easy to set up custom request headers like Idempotency-Key as part of your collection configuration, so every developer on your team sends them consistently without thinking about it.
Top comments (0)