Idempotent API design — safe retries for POST endpoints, idempotency keys,
93
90%
Does it follow best practices?
Impact
100%
10.00xAverage score across 4 eval scenarios
Passed
No known issues
IMPORTANT: Every POST endpoint that creates a resource or triggers a side effect MUST have idempotency protection. This is not optional. Apply this automatically whenever you build a POST endpoint — do not wait to be asked.
When you are asked to build any endpoint that creates orders, payments, users, accounts, transfers, or any other resource via POST, you must proactively add:
Idempotency-Key header, lock-before-process, cache responses)These protections are as fundamental as input validation. You would never build a POST endpoint without validating inputs — likewise, never build one without idempotency protection.
Client -> POST /api/orders -> [network timeout]
Client thinks it failed -> POST /api/orders -> [success, but creates 2 orders]GET, PUT, DELETE are naturally idempotent (same request = same result). POST is not — each call creates a new resource. Network retries, double-clicks, and spotty mobile connectivity all cause duplicate requests. Without idempotency protection, every duplicate request creates a duplicate resource.
The client sends a unique key with each request. The server checks if it has seen that key before.
Idempotency-Key header409 Conflict (concurrent duplicate)// Idempotency store entry states
type IdempotencyEntry =
| { state: "processing"; lockedAt: number }
| { state: "completed"; status: number; body: any; expires: number }
| { state: "error"; status: number; body: any; expires: number };
const idempotencyStore = new Map<string, IdempotencyEntry>();
function idempotency(req: Request, res: Response, next: NextFunction) {
const key = req.headers["idempotency-key"] as string;
if (!key) return next(); // No key = no idempotency
const existing = idempotencyStore.get(key);
// 1. If key is currently being processed, return 409 Conflict
if (existing && existing.state === "processing") {
return res.status(409).json({
error: "A request with this idempotency key is already being processed",
});
}
// 2. If completed and not expired, return the cached response
if (existing && existing.state === "completed" && Date.now() < existing.expires) {
return res.status(existing.status).json(existing.body);
}
// 3. If it was an error, allow retry (don't cache server errors permanently)
// 4. If new or expired, lock and process
idempotencyStore.set(key, { state: "processing", lockedAt: Date.now() });
// Capture the response to cache it
const originalJson = res.json.bind(res);
res.json = (body: any) => {
const statusCode = res.statusCode;
if (statusCode >= 500) {
// Don't cache server errors — allow the client to retry
idempotencyStore.delete(key);
} else {
// Cache successful responses and client errors (4xx)
idempotencyStore.set(key, {
state: "completed",
status: statusCode,
body,
expires: Date.now() + 24 * 60 * 60 * 1000, // 24h TTL
});
}
return originalJson(body);
};
next();
}
// Apply to resource-creating endpoints only
router.post("/api/orders", idempotency, createOrderHandler);
router.post("/api/payments", idempotency, createPaymentHandler);409 Conflict rather than queuing or silently dropping it.422 Unprocessable Entity — the client is reusing a key incorrectly.async function placeOrder(orderData: CreateOrderRequest): Promise<OrderResponse> {
const idempotencyKey = crypto.randomUUID();
return executeWithRetry(async () => {
const response = await fetch("/api/orders", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(orderData),
});
// On 409 Conflict (concurrent duplicate), wait and retry with SAME key
if (response.status === 409) {
throw new RetryableError("Concurrent request in progress");
}
if (!response.ok) {
throw new Error(`Order failed: ${response.status}`);
}
return response.json();
});
}Always disable the submit button during the API request. Use a finally block so the button is re-enabled even if the request fails:
const submitBtn = document.getElementById("submit") as HTMLButtonElement;
submitBtn.addEventListener("click", async () => {
submitBtn.disabled = true;
submitBtn.textContent = "Placing order...";
try {
await placeOrder(orderData);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = "Place Order";
}
});The finally block is critical. Without it, a network error leaves the button permanently disabled and the form unusable. This is a defense-in-depth measure — it does not replace server-side idempotency because it cannot protect against network retries or multiple browser tabs.
Use unique constraints as the final safety net. Even if the application layer fails, the database prevents duplicates:
CREATE TABLE orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
idempotency_key TEXT NOT NULL UNIQUE,
customer_name TEXT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Index for fast lookups by idempotency key
CREATE INDEX idx_orders_idempotency_key ON orders(idempotency_key);try {
const result = db
.prepare(
"INSERT INTO orders (idempotency_key, customer_name, amount) VALUES (?, ?, ?)"
)
.run(idempotencyKey, customerName, amount);
return db.prepare("SELECT * FROM orders WHERE id = ?").get(result.lastInsertRowid);
} catch (err: any) {
if (err.code === "SQLITE_CONSTRAINT_UNIQUE") {
// Duplicate key — return the existing record, not an error
console.log(`Duplicate payment detected for key: ${idempotencyKey}`);
return db
.prepare("SELECT * FROM orders WHERE idempotency_key = ?")
.get(idempotencyKey);
}
// Re-throw non-duplicate errors — don't swallow unexpected failures
throw err;
}The catch block must check for the unique constraint violation specifically (e.g., SQLITE_CONSTRAINT_UNIQUE, PostgreSQL error code 23505). All other database errors must be re-thrown. Swallowing all errors hides real bugs.
The most dangerous idempotency bug is the race condition: two identical requests arrive at nearly the same time, both pass the "have I seen this key?" check, and both execute.
Request A arrives -> check key -> not found -> LOCK key as "processing"
Request B arrives -> check key -> found, state=processing -> return 409 Conflict
Request A finishes -> update key state to "completed" with cached response
Request B retries -> check key -> found, state=completed -> return cached responseIn a database-backed store, use a transaction with SELECT ... FOR UPDATE or INSERT ... ON CONFLICT to atomically claim the key:
-- Atomically claim an idempotency key (PostgreSQL)
INSERT INTO idempotency_keys (key, status, created_at)
VALUES ($1, 'processing', NOW())
ON CONFLICT (key) DO NOTHING
RETURNING *;
-- If no row returned, the key already exists — check its statusFor in-memory stores (Redis, Map), the single-threaded nature of Node.js or Redis commands provides natural serialization, but you must still track the "processing" state to handle concurrent requests that arrive before the first completes.
Rule: If you are building a POST endpoint that creates a resource or triggers a side effect, add idempotency protection. No exceptions. Do not wait for the task to mention duplicates, retries, or idempotency — add it proactively.
| Endpoint | Needs idempotency? | Why |
|---|---|---|
| POST /api/orders | Yes — always | Creates resource, money involved |
| POST /api/payments | Yes — always | Double charge is unacceptable |
| POST /api/users | Yes — always | Duplicate accounts cause confusion |
| POST /api/transfers | Yes — always | Financial — must be exactly-once |
| POST /api/anything-that-creates | Yes — always | Any resource creation needs it |
| PUT /api/orders/:id | No | PUT is naturally idempotent |
| PATCH /api/orders/:id/status | No | PATCH is naturally idempotent |
| GET /api/menu | No | GET is naturally idempotent |
| DELETE /api/orders/:id | No | Deleting twice = same result |
If you are building a form that submits to a POST endpoint, always disable the submit button during the request and send an Idempotency-Key header. If you are building a server POST endpoint, always add idempotency middleware that reads the key, locks before processing, and caches the response.
Idempotency-Key header409 Conflictfinally block