REST API Idempotency Keys: A Guide
A customer clicks "Place Order" once, but a flaky mobile network causes the request to fire twice. Without protection, that's two charges, two orders, and one angry support ticket. This is exactly the problem REST API idempotency keys are designed to solve, and if your API handles payments, orders, or any operation with side effects, you need this pattern in production.
Idempotency means that performing an operation multiple times produces the same result as performing it once. GET, PUT, and DELETE are naturally idempotent by the HTTP spec, but POST is not — every retry of a POST creates a new resource unless you explicitly design around it. That's where idempotency keys come in: a client-generated unique token attached to a request that lets your server recognize and safely handle duplicate submissions.
How Idempotency Keys Work
The client generates a unique identifier — typically a UUID v4 — before sending a request, and includes it in a custom header, usually Idempotency-Key. The server stores the key alongside the response it generated for that request. If the same key arrives again within a defined window, the server returns the cached response instead of re-executing the operation.
The flow looks like this:
- Client generates a UUID and sends it with the POST request.
- Server checks if that key already exists in its idempotency store.
- If it's new, the server processes the request normally and stores the key with the result.
- If it's a duplicate, the server returns the stored response without re-running any side effects.
- Keys typically expire after 24 hours, since retries beyond that window are unlikely to represent the same user action.
This pattern is why Stripe, Shopify, and most mature payment APIs require an idempotency key on write operations — a network timeout should never mean a customer gets charged twice.
Implementing Idempotency in a Node.js API
A production implementation needs three things: a place to store keys (Redis is ideal for its built-in TTL support), middleware to intercept requests, and careful handling of the race condition where two identical requests arrive at nearly the same instant.
const redis = require('./redisClient');
async function idempotencyMiddleware(req, res, next) {
const key = req.header('Idempotency-Key');
if (!key) {
return res.status(400).json({ error: 'Idempotency-Key header required' });
}
const storeKey = `idem:${key}`;
const existing = await redis.get(storeKey);
if (existing) {
const cached = JSON.parse(existing);
return res.status(cached.status).json(cached.body);
}
// Reserve the key immediately to guard against concurrent duplicate requests
const locked = await redis.set(storeKey, JSON.stringify({ status: 'processing' }), 'NX', 'EX', 86400);
if (!locked) {
return res.status(409).json({ error: 'Request already in progress' });
}
const originalJson = res.json.bind(res);
res.json = async (body) => {
await redis.set(storeKey, JSON.stringify({ status: res.statusCode, body }), 'EX', 86400);
return originalJson(body);
};
next();
}
module.exports = idempotencyMiddleware;
The NX flag on the Redis SET command is doing the real work here — it atomically reserves the key only if it doesn't already exist, which closes the race condition where two near-simultaneous requests with the same key both slip past a naive "check then write" implementation.
Scoping Keys Correctly
A key mistake teams make is scoping idempotency keys globally instead of per-endpoint or per-user. If two different users happen to generate the same UUID (astronomically unlikely but not impossible with poor randomness sources) or if the same key is reused across different operations, you want isolation.
- Scope the storage key by combining the user ID, the endpoint, and the client-supplied key:
idem:{userId}:{endpoint}:{key} - Never accept an idempotency key without pairing it to an authenticated identity when the endpoint requires auth
- Validate that the key is a well-formed UUID before touching the store, to reject malformed or missing headers early
- Return a
422if a client reuses the same key with a different request body — that signals a client-side bug, not a legitimate retry
Handling Response Consistency
The stored response must include the full status code and body, not just a success flag. If the original request failed validation and returned a 400, a retry with the same key should return that same 400 — not silently succeed. This is a common bug: teams cache only successful responses, so a failed request gets retried and reprocessed indefinitely, defeating the purpose of the key.
Also decide explicitly how long keys live. Twenty-four hours is the industry-standard window used by Stripe and most payment processors — long enough to cover mobile retries and network partitions, short enough to keep the store from growing unbounded.
Common Mistakes to Avoid
- Making the key optional on critical endpoints. If idempotency is optional, some client integration will eventually skip it, and you'll get duplicate charges anyway.
- Storing only in application memory. A single-instance in-memory cache breaks the moment you scale horizontally or restart the process — always use a shared store like Redis or a database table.
- Not handling the "in-flight" state. Without a reservation step, two concurrent identical requests can both pass the "does this key exist" check before either finishes writing.
- Forgetting to hash or scope the key. Storing raw client keys without a namespace invites collisions across unrelated endpoints or tenants.
Frequently Asked Questions
Do I need idempotency keys on GET requests? No. GET is already idempotent by definition — repeating it doesn't change server state, so there's nothing to protect against.
What should the idempotency key format be? A UUID v4 is the standard choice. It's effectively guaranteed unique, doesn't require coordination between clients, and is easy to generate in any language or client SDK.
How long should idempotency keys be stored? 24 hours is the common default, matching what Stripe and similar payment APIs use. Choose a shorter window only if your retry logic guarantees faster resubmission, and a longer one only if you have evidence clients retry after longer delays.
What HTTP status code should a duplicate request return?
Return the same status code and body that the original request produced. If the original succeeded with a 201, the duplicate should also get a 201 with the same resource data — not a new 200 or an error.
Conclusion
Idempotency keys aren't an edge-case nicety — they're a required piece of infrastructure for any REST API that processes payments, orders, or other operations with real side effects. Implement the Redis-backed reservation pattern shown above, scope keys per user and endpoint, and always cache the full response rather than a partial success flag, so a network retry never turns into a duplicate charge or a duplicate order in production.