Idempotent API Design: How to Build Bulletproof Distributed Systems That Never Process the Same Request Twice
Duplicate payments, ghost orders, and corrupted state — they all trace back to APIs that weren't built idempotent. Learn how to engineer idempotent APIs that survive retries, network failures, and distributed chaos at production scale.
Quick Answer (TL;DR): Idempotent API design ensures that executing the same request multiple times produces exactly the same outcome as executing it once — no duplicate charges, no ghost records, no corrupted state. The core mechanism is an idempotency key (a client-generated unique token) stored server-side with the result of the first successful execution. Every retry that carries the same key gets the cached response immediately, without re-executing business logic. Implement this at the infrastructure layer, not the application layer, and you eliminate an entire class of distributed system bugs permanently.
Why Idempotent API Design Is the Difference Between a Trustworthy System and a Liability
Every distributed system lies to you. Networks drop packets. Load balancers time out. Mobile clients retry on reconnect. Your payment gateway fires a webhook twice because of a transient 502. In every one of these scenarios, if your APIs are not built around idempotent API design, you are one bad network day away from charging a customer twice, creating duplicate orders, or corrupting your database state in ways that are brutally difficult to reverse.
This is not a theoretical edge case. Stripe, Braintree, and PayPal all mandate idempotency keys on their mutation endpoints. The Stripe API documentation explicitly states that without idempotency keys, retrying a failed payment request can result in double charges. AWS, Twilio, and virtually every production-grade API platform operating at scale has baked idempotency into their core contract with developers. If you're building a SaaS product, a payment-critical backend, or any system that handles money, inventory, or user state — idempotent API design is not optional. It is the baseline.
At Apargo, we engineer production systems where correctness is non-negotiable. Whether it's a multi-tenant SaaS platform or the backend powering AI Greentick's WhatsApp automation workflows, idempotency is wired into our API contracts from day one — not retrofitted after an incident. This article is a deep technical breakdown of exactly how to build it right.
The Core Problem: Why Distributed Systems Produce Duplicate Requests
Before implementing idempotent API design, you need to understand precisely why duplicates happen in the first place. There are three primary failure modes:
1. Client-Side Retries on Timeout
A mobile app sends a POST request to create an order. The server processes the request, writes to the database, but the response is lost in transit. The client receives a timeout error and retries. The server has no memory of the first request — it processes again. You now have two orders for the same intent.
2. Proxy and Load Balancer Retries
Modern infrastructure layers — NGINX, AWS ALB, Envoy — are configured to retry upstream requests on 5xx responses. If your application server crashes mid-write (after committing to the database but before returning a response), the proxy retries, your app processes again, and you have a duplicate write with no client involvement whatsoever.
3. Message Queue At-Least-Once Delivery
Kafka, RabbitMQ, SQS — every major message queue guarantees at-least-once delivery, not exactly-once. A consumer that processes a message and crashes before acknowledging it will receive the same message again on restart. If your consumer is not idempotent, you process the same event multiple times.
These three vectors alone cover the vast majority of production duplicate-processing incidents. Idempotent API design is the single architectural decision that neutralizes all three simultaneously.
The Idempotency Key Pattern: How It Works at the Infrastructure Level
The canonical implementation of idempotent API design is the idempotency key pattern. Here is the precise flow:
- The client generates a unique key (UUID v4 is standard) before sending the request.
- The key is attached to the request as a header:
Idempotency-Key: <uuid>. - On receiving the request, the server checks a fast-access store (Redis is the industry standard) for the key.
- If the key does not exist: The server processes the request, stores the result in Redis keyed by the idempotency key, and returns the response.
- If the key exists with a completed result: The server returns the stored response immediately — zero business logic re-execution.
- If the key exists but is in a "processing" state: The server returns a
409 Conflictor a202 Acceptedwith a retry hint, preventing concurrent duplicate processing.
The key insight here is that the idempotency store acts as a distributed lock AND a response cache simultaneously. This is what makes the pattern so powerful.
Production Implementation: Idempotent API Design in Node.js + Redis
Below is a production-grade implementation of an idempotency middleware for a Node.js/Express API. This is the exact pattern we use in Apargo-engineered backends.
// idempotency.middleware.js
// Production-grade idempotency middleware using Redis
// Handles: concurrent requests, TTL expiry, partial failures
const redis = require('ioredis');
const crypto = require('crypto');
const redisClient = new redis({
host: process.env.REDIS_HOST,
port: 6379,
// Use Redis Cluster in production for HA
enableReadyCheck: true,
maxRetriesPerRequest: 3,
});
const IDEMPOTENCY_TTL_SECONDS = 86400; // 24-hour window (Stripe uses 24h too)
const PROCESSING_SENTINEL = '__PROCESSING__';
async function idempotencyMiddleware(req, res, next) {
// Only apply to mutating HTTP methods
const MUTATING_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
if (!MUTATING_METHODS.includes(req.method)) {
return next();
}
const idempotencyKey = req.headers['idempotency-key'];
// If no key provided, reject the request (enforce the contract)
if (!idempotencyKey) {
return res.status(400).json({
error: 'MISSING_IDEMPOTENCY_KEY',
message: 'All mutating requests require an Idempotency-Key header.',
});
}
// Namespace the key per user to prevent cross-user key collisions
const userId = req.user?.id || 'anonymous';
const redisKey = `idempotency:${userId}:${idempotencyKey}`;
// Attempt to set the key with NX (only if Not eXists) — atomic operation
// This is the distributed lock acquisition
const acquired = await redisClient.set(
redisKey,
PROCESSING_SENTINEL,
'EX',
IDEMPOTENCY_TTL_SECONDS,
'NX' // SET only if key does not exist
);
if (acquired === null) {
// Key already exists — check if processing or completed
const storedValue = await redisClient.get(redisKey);
if (storedValue === PROCESSING_SENTINEL) {
// Another request is currently processing this key
return res.status(409).json({
error: 'CONCURRENT_REQUEST',
message: 'A request with this idempotency key is currently being processed.',
retryAfter: 2, // seconds
});
}
// Key has a completed response — return it directly
const cachedResponse = JSON.parse(storedValue);
res.set('Idempotency-Replayed', 'true'); // Signal to client this is a replay
return res.status(cachedResponse.statusCode).json(cachedResponse.body);
}
// Key was freshly acquired — intercept the response to cache it
const originalJson = res.json.bind(res);
res.json = async (body) => {
// Only cache successful responses (2xx)
if (res.statusCode >= 200 && res.statusCode < 300) {
const responseToCache = JSON.stringify({
statusCode: res.statusCode,
body: body,
cachedAt: new Date().toISOString(),
});
// Overwrite the PROCESSING sentinel with the actual response
await redisClient.set(redisKey, responseToCache, 'EX', IDEMPOTENCY_TTL_SECONDS);
} else {
// On failure, delete the key so the client can retry with the same key
await redisClient.del(redisKey);
}
return originalJson(body);
};
next();
}
module.exports = idempotencyMiddleware;
A few critical engineering decisions embedded in this implementation worth calling out explicitly:
- Atomic SET NX: Using Redis
SET key value EX ttl NXis a single atomic operation. This eliminates the race condition that would exist in a GET-then-SET approach where two concurrent requests could both see an empty key and both proceed to process. - User-namespaced keys: Idempotency keys are scoped per user. This prevents a scenario where two different users happen to generate the same UUID (statistically improbable but architecturally correct).
- Failure cleanup: If the business logic returns a 4xx or 5xx, the idempotency key is deleted from Redis. This allows the client to retry with the same idempotency key after fixing the underlying issue — which is the correct UX contract.
- Replay header: The
Idempotency-Replayed: trueheader tells the client they received a cached response, which is useful for debugging and observability.
Database-Level Idempotency: When Redis Isn't Enough
Redis is fast (sub-millisecond reads) and perfect for the idempotency layer, but it is not durable by default. In scenarios where your Redis instance restarts and loses its in-memory state, a window opens where duplicate requests could slip through. For payment-critical or compliance-sensitive systems, you need a second line of defense at the database layer.
Unique Constraint on Idempotency Key
Persist the idempotency key directly in your primary database as a unique constraint:
-- PostgreSQL: Idempotency keys table
-- Provides durable deduplication even if Redis is unavailable
CREATE TABLE idempotency_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
idempotency_key VARCHAR(255) NOT NULL,
user_id UUID NOT NULL,
endpoint VARCHAR(255) NOT NULL, -- e.g., 'POST /payments'
request_hash VARCHAR(64) NOT NULL, -- SHA-256 of request body
response_status INT NOT NULL,
response_body JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
-- Composite unique constraint: key is unique per user per endpoint
CONSTRAINT uq_idempotency UNIQUE (idempotency_key, user_id, endpoint)
);
-- Partial index for fast lookups on active (non-expired) records
CREATE INDEX idx_idempotency_active
ON idempotency_records (idempotency_key, user_id)
WHERE expires_at > NOW();
-- Automatic cleanup of expired records (run via pg_cron or a scheduled job)
DELETE FROM idempotency_records WHERE expires_at < NOW();
The Request Hash Guard
Notice the request_hash column. This is a SHA-256 hash of the request body. When a retry comes in with the same idempotency key, you compare the hash of the new request body against the stored hash. If they differ, it means the client is trying to use the same idempotency key for a different request — which is a violation of the contract. Return a 422 Unprocessable Entity with a clear error message. This is exactly how Stripe handles this edge case.
Idempotent API Design for Webhook Consumers
Webhooks are one of the most overlooked areas where idempotent API design is critical. Every major webhook provider — Stripe, GitHub, Twilio, and the webhook engine inside AI Greentick's WhatsApp automation platform — guarantees at-least-once delivery. Your webhook consumer MUST be idempotent.
The pattern is identical: use the webhook's event ID (e.g., Stripe's evt_xxx) as your idempotency key. Before processing, check if you've already handled this event ID. If yes, return 200 OK immediately (this is important — returning a non-2xx causes the provider to retry again). If no, process and record.
// webhook.handler.js — Idempotent webhook consumer
async function handleStripeWebhook(req, res) {
const eventId = req.body.id; // e.g., "evt_1OqBxxx"
const eventType = req.body.type;
// Check if already processed — using the DB layer for durability
const alreadyProcessed = await db.query(
`SELECT 1 FROM processed_webhooks WHERE event_id = $1`,Related Articles
Explore more insights from our engineering and product teams.
