How to Build Idempotent APIs in Node.js (With Real Examples)
If you've ever seen a user get charged twice because they clicked "Pay" and their browser retried the request, you know why idempotency matters. In this guide, you'll learn how to make your Node.js APIs safe from duplicate requests — a critical pattern for payment processing, order creation, and any operation that shouldn't happen twice.
What Is Idempotency?
An API endpoint is idempotent if making the same request multiple times produces the same result as making it once. GET, PUT, and DELETE are naturally idempotent by design. POST is not — and that's where things get dangerous.
Consider this scenario:
- User clicks "Place Order"
- Request times out (but the server processed it)
- Client retries automatically
- User now has two orders
This isn't theoretical. As of February 2026, Stripe, PayPal, and virtually every major payment API requires idempotency keys for exactly this reason.
The Idempotency Key Pattern
The solution is simple: the client sends a unique key with each request. The server checks if it's already processed that key and returns the cached response instead of processing it again.
POST /api/orders
Idempotency-Key: ord_a1b2c3d4e5f6
Content-Type: application/json
{"product_id": "prod_123", "quantity": 1}
Here's the flow:
- Client generates a unique
Idempotency-Key(typically a UUID) - Server receives the request and checks if it's seen this key before
- If new: process the request, store the result, return it
- If seen: return the stored result without reprocessing
Implementation: Express + Redis
We'll use Express 5 and Redis for storing idempotency records.
Step 1: Project Setup
mkdir idempotent-api && cd idempotent-api
npm init -y
npm install express@5 ioredis@5 uuid@11
Step 2: The Idempotency Middleware
// middleware/idempotency.js
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
const IDEMPOTENCY_TTL = 86400; // 24 hours
export function idempotent(options = {}) {
const { ttl = IDEMPOTENCY_TTL, headerName = 'idempotency-key' } = options;
return async (req, res, next) => {
const key = req.headers[headerName];
if (!key) return next();
if (key.length > 255) {
return res.status(400).json({
error: 'idempotency_key_too_long',
message: 'Idempotency key must be 255 characters or less',
});
}
const cacheKey = `idempotency:${req.method}:${req.path}:${key}`;
try {
const cached = await redis.get(cacheKey);
if (cached) {
const { statusCode, body, headers } = JSON.parse(cached);
res.set('X-Idempotent-Replayed', 'true');
Object.entries(headers || {}).forEach(([k, v]) => res.set(k, v));
return res.status(statusCode).json(body);
}
const lockKey = `${cacheKey}:lock`;
const locked = await redis.set(lockKey, '1', 'EX', 30, 'NX');
if (!locked) {
return res.status(409).json({
error: 'request_in_progress',
message: 'A request with this idempotency key is already being processed',
});
}
const originalJson = res.json.bind(res);
res.json = (body) => {
const record = { statusCode: res.statusCode, body, headers: { 'content-type': 'application/json' } };
redis.set(cacheKey, JSON.stringify(record), 'EX', ttl)
.then(() => redis.del(lockKey))
.catch(console.error);
return originalJson(body);
};
next();
} catch (err) {
console.error('Idempotency middleware error:', err);
next();
}
};
}
Step 3: Using the Middleware
// server.js
import express from 'express';
import { idempotent } from './middleware/idempotency.js';
import { v4 as uuidv4 } from 'uuid';
const app = express();
app.use(express.json());
const orders = new Map();
app.post('/api/orders', idempotent({ ttl: 3600 }), async (req, res) => {
const { product_id, quantity } = req.body;
await new Promise((resolve) => setTimeout(resolve, 100));
const order = {
id: uuidv4(), product_id, quantity,
status: 'created', created_at: new Date().toISOString(),
};
orders.set(order.id, order);
res.status(201).json({ data: order });
});
app.get('/api/orders/:id', (req, res) => {
const order = orders.get(req.params.id);
if (!order) return res.status(404).json({ error: 'not_found' });
res.json({ data: order });
});
app.listen(3000, () => console.log('Server running on :3000'));
Step 4: Client-Side Usage
import { v4 as uuidv4 } from 'uuid';
async function createOrder(productId, quantity) {
const idempotencyKey = uuidv4();
const makeRequest = async (retries = 3) => {
try {
const res = await fetch('http://localhost:3000/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ product_id: productId, quantity }),
});
if (res.headers.get('X-Idempotent-Replayed') === 'true') {
console.log('Response was replayed from cache');
}
return await res.json();
} catch (err) {
if (retries > 0) return makeRequest(retries - 1);
throw err;
}
};
return makeRequest();
}
Handling Edge Cases
1. Request Body Mismatch
import { createHash } from 'node:crypto';
function fingerprint(body) {
return createHash('sha256').update(JSON.stringify(body)).digest('hex');
}
// Inside middleware, after finding cached response:
const cachedFingerprint = await redis.get(`${cacheKey}:fingerprint`);
const currentFingerprint = fingerprint(req.body);
if (cachedFingerprint && cachedFingerprint !== currentFingerprint) {
return res.status(422).json({
error: 'idempotency_key_reused',
message: 'This idempotency key was used with a different request body',
});
}
2. Don't Cache 5xx Errors
res.json = (body) => {
if (res.statusCode < 500) {
redis.set(cacheKey, JSON.stringify({ statusCode: res.statusCode, body }), 'EX', ttl);
} else {
redis.del(lockKey);
}
return originalJson(body);
};
3. Database Transactions for Payments
import { Pool } from 'pg';
const pool = new Pool();
app.post('/api/payments', idempotent(), async (req, res) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const existing = await client.query(
'SELECT * FROM payments WHERE reference = $1',
[req.headers['idempotency-key']]
);
if (existing.rows.length > 0) {
await client.query('ROLLBACK');
return res.json({ data: existing.rows[0] });
}
const result = await client.query(
'INSERT INTO payments (reference, amount, status) VALUES ($1, $2, $3) RETURNING *',
[req.headers['idempotency-key'], req.body.amount, 'completed']
);
await client.query('COMMIT');
res.status(201).json({ data: result.rows[0] });
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
});
How the Big Players Do It
| Provider | Header | TTL | Notes |
|---|---|---|---|
| Stripe | Idempotency-Key |
24h | Required for all POST |
| PayPal | PayPal-Request-Id |
72h | All mutation endpoints |
| Square | Idempotency-Key |
24h | Required for Create |
| Shopify | Idempotency-Key |
60s | High-volume commerce |
Testing
# First request — creates the order
curl -s -X POST http://localhost:3000/api/orders \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: test-key-123' \
-d '{"product_id": "prod_1", "quantity": 2}'
# Second request — returns cached response
curl -s -D- -X POST http://localhost:3000/api/orders \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: test-key-123' \
-d '{"product_id": "prod_1", "quantity": 2}'
# => X-Idempotent-Replayed: true (same order ID!)
Key Takeaways
- Always support idempotency on POST endpoints that create resources or trigger side effects
- Use Redis for the idempotency cache
- Add a distributed lock to handle concurrent duplicates
- Include a body fingerprint to detect key reuse with different payloads
- Don't cache 5xx errors — let clients retry
- Set a reasonable TTL — 24 hours covers most scenarios
-
Document it — tell consumers to send
Idempotency-Keyheaders
Idempotency isn't optional for production APIs. Implement it early, and your users will thank you.
Building APIs? 1xAPI provides developer tools including email verification, SMS APIs, and more — all designed with idempotency built in.
Top comments (0)