API Idempotency: Complete Implementation Guide for Production APIs
📡 Monitor your APIs — know when they go down before your users do
Better Stack checks uptime every 30 seconds with instant Slack, email & SMS alerts. Free tier available.
Affiliate link — we may earn a commission at no extra cost to you
API Idempotency: Complete Implementation Guide for Production APIs
Idempotency is a critical property for building reliable APIs. An idempotent operation can be performed multiple times without changing the result beyond the initial application. This guide covers everything you need to implement idempotency correctly in production.
Table of Contents
- What is Idempotency?
- Why Idempotency Matters
- When to Use Idempotency
- Idempotency Keys
- Server-Side Implementation
- Client-Side Implementation
- Database Patterns
- Real-World Examples
- Testing Idempotency
- Common Mistakes
- Production Checklist
Why Idempotency Matters
1. Network Failures
Network issues can cause requests to be sent multiple times:
Client sends payment request → Network timeout (no response)
↓
Client retries → Server processed original request
↓
Result: Double charge without idempotency ❌
Result: Single charge with idempotency ✅
2. Duplicate Prevention
Without idempotency:
- Double charges: User clicks "Pay" twice, gets charged twice
- Duplicate orders: Order submitted multiple times creates inventory issues
- Inconsistent state: Retry logic creates conflicting records
3. Real-World Impact
Shopify (2019): A bug caused some customers to be charged 100+ times for a single purchase. Proper idempotency would have prevented this.
Stripe: Processes billions in payments—idempotency is mandatory for all payment operations.
🔐 Protect your API keys and credentials. Idempotency is only secure if your API keys are. 1Password stores and auto-fills your API keys, database credentials, and secrets across all devices — with vault sharing for your team.
When to Use Idempotency
Operations That NEED Idempotency
✅ Payment processing
- Charging credit cards
- Creating invoices
- Processing refunds
✅ Order creation
- E-commerce checkouts
- Subscription signups
- Reservation systems
✅ State-changing webhooks
- Processing webhook events
- Third-party integrations
- Event-driven workflows
✅ Distributed transactions
- Microservice communication
- Saga patterns
- Event sourcing
Operations That DON'T Need Idempotency
❌ Read operations (GET, search, list) ❌ Analytics events (logging, tracking—duplicates are acceptable) ❌ Fire-and-forget notifications (email sends where duplication is tolerable)
Idempotency Keys
The standard pattern is to use idempotency keys—unique identifiers sent by the client with each request.
How It Works
1. Client generates unique key (UUID)
2. Client sends request with Idempotency-Key header
3. Server checks if key exists
- If yes: Return cached response (don't process again)
- If no: Process request, cache response with key
4. Return response
Header Format
Most APIs use a custom header:
POST /payments
Idempotency-Key: a7f8d9e2-3c4b-5d6e-7f8g-9h0i1j2k3l4m
Content-Type: application/json
{
"amount": 5000,
"currency": "usd"
}
Key Generation (Client-Side)
import { v4 as uuidv4 } from 'uuid';
// Generate a new idempotency key
const idempotencyKey = uuidv4();
// Example: "a7f8d9e2-3c4b-5d6e-7f8g-9h0i1j2k3l4m"
// Use it in your API call
await fetch('https://api.example.com/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ amount: 5000, currency: 'usd' }),
});
Key Requirements:
- Must be unique per operation (UUIDs are perfect)
- Should be generated before the request (not on retry)
- Should be persisted (in case client crashes before receiving response)
Server-Side Implementation
Complete TypeScript/Express Example
import express from 'express';
import { PrismaClient } from '@prisma/client';
import crypto from 'crypto';
const app = express();
const prisma = new PrismaClient();
app.use(express.json());
// Idempotency middleware
const idempotencyMiddleware = async (
req: express.Request,
res: express.Response,
next: express.NextFunction
) => {
const idempotencyKey = req.headers['idempotency-key'] as string;
// Only check for POST/PATCH (state-changing operations)
if (!['POST', 'PATCH'].includes(req.method)) {
return next();
}
// Idempotency key is optional but recommended
if (!idempotencyKey) {
return next(); // Proceed without idempotency check
}
// Validate key format (UUID)
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(idempotencyKey)) {
return res.status(400).json({
error: 'Invalid idempotency key format. Must be a UUID v4.',
});
}
try {
// Check if we've seen this key before
const existing = await prisma.idempotencyKey.findUnique({
where: { key: idempotencyKey },
});
if (existing) {
// Key exists—return cached response
if (existing.status === 'processing') {
// Request is still being processed (concurrent request)
return res.status(409).json({
error: 'Request with this idempotency key is already being processed',
});
}
// Return cached response
return res
.status(existing.responseStatus)
.set(existing.responseHeaders as Record<string, string{'>'}
.send(existing.responseBody);
}
// New key—create a processing record
await prisma.idempotencyKey.create({
data: {
key: idempotencyKey,
status: 'processing',
requestMethod: req.method,
requestPath: req.path,
requestBody: req.body,
createdAt: new Date(),
},
});
// Store the key in res.locals for later use
res.locals.idempotencyKey = idempotencyKey;
next();
} catch (error) {
console.error('Idempotency check failed:', error);
return res.status(500).json({ error: 'Internal server error' });
}
};
// Response caching interceptor
const cacheIdempotentResponse = async (
req: express.Request,
res: express.Response,
originalSend: any
) => {
const idempotencyKey = res.locals.idempotencyKey;
if (!idempotencyKey) {
return originalSend.call(res);
}
// Intercept the response
const originalJson = res.json.bind(res);
res.json = function (body: any) {
// Cache the response
prisma.idempotencyKey
.update({
where: { key: idempotencyKey },
data: {
status: 'completed',
responseStatus: res.statusCode,
responseHeaders: res.getHeaders(),
responseBody: body,
completedAt: new Date(),
},
})
.catch((err) => console.error('Failed to cache response:', err));
return originalJson(body);
};
};
// Apply middleware
app.use(idempotencyMiddleware);
// Example: Payment endpoint
app.post('/payments', async (req, res) => {
const { amount, currency, customerId } = req.body;
// Validate input
if (!amount || !currency || !customerId) {
return res.status(400).json({ error: 'Missing required fields' });
}
try {
// Process payment (idempotent operation)
const payment = await prisma.payment.create({
data: {
amount,
currency,
customerId,
status: 'succeeded',
createdAt: new Date(),
},
});
// Charge the customer (external API call)
// await stripeClient.charges.create({ ... });
return res.status(201).json({
id: payment.id,
amount: payment.amount,
currency: payment.currency,
status: payment.status,
});
} catch (error) {
console.error('Payment processing failed:', error);
return res.status(500).json({ error: 'Payment failed' });
}
});
app.listen(3000, () => console.log('Server running on port 3000'));
Key Implementation Details
- Check before processing: Look up the idempotency key before doing any work
- Handle concurrent requests: Return
409 Conflictif the same key is being processed - Cache the response: Store the response with the key for future lookups
- Set expiration: Keys should expire after 24-48 hours (see database schema)
Client-Side Implementation
Retry Logic with Idempotency
interface PaymentRequest {
amount: number;
currency: string;
customerId: string;
}
async function createPaymentWithRetry(
payment: PaymentRequest,
maxRetries = 3
): Promise<any{'>'} {
// Generate idempotency key ONCE (before first attempt)
const idempotencyKey = uuidv4();
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch('https://api.example.com/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey, // Same key on every retry
},
body: JSON.stringify(payment),
});
if (!response.ok) {
if (response.status === 409) {
// Concurrent request—wait and retry
await sleep(1000 * attempt);
continue;
}
if (response.status {'>'}= 500) {
// Server error—retry
await sleep(1000 * Math.pow(2, attempt)); // Exponential backoff
continue;
}
// Client error (4xx)—don't retry
throw new Error(`Payment failed: ${response.statusText}`);
}
return await response.json();
} catch (error) {
lastError = error as Error;
if (attempt === maxRetries) {
break; // Max retries reached
}
// Network error—retry with exponential backoff
await sleep(1000 * Math.pow(2, attempt));
}
}
throw new Error(
`Payment failed after ${maxRetries} attempts: ${lastError?.message}`
);
}
function sleep(ms: number): Promise<void{'>'} {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Usage
try {
const payment = await createPaymentWithRetry({
amount: 5000,
currency: 'usd',
customerId: 'cus_123',
});
console.log('Payment succeeded:', payment);
} catch (error) {
console.error('Payment failed:', error);
}
Key Client-Side Patterns
- Generate key before first attempt (not on each retry)
- Use the same key for all retries (that's the whole point!)
- Handle 409 Conflict (concurrent request with same key)
- Implement exponential backoff for retries
- Persist the key (in case client crashes before response)
Database Patterns
Schema for Idempotency Keys
// schema.prisma
model IdempotencyKey {
id String @id @default(cuid())
key String @unique // The idempotency key (UUID)
status String // 'processing' | 'completed' | 'failed'
// Request details
requestMethod String // POST, PATCH, etc.
requestPath String // /payments, /orders, etc.
requestBody Json? // Original request body
// Response cache
responseStatus Int? // 200, 201, 400, etc.
responseHeaders Json? // Response headers
responseBody Json? // Cached response
// Timestamps
createdAt DateTime @default(now())
completedAt DateTime?
expiresAt DateTime // Auto-delete after 24-48h
@@index([key])
@@index([expiresAt])
}
Automatic Cleanup
// Cron job to delete expired keys
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function cleanupExpiredKeys() {
const result = await prisma.idempotencyKey.deleteMany({
where: {
expiresAt: {
lt: new Date(), // Delete keys older than expiration
},
},
});
console.log(`Deleted ${result.count} expired idempotency keys`);
}
// Run every hour
setInterval(cleanupExpiredKeys, 60 * 60 * 1000);
Alternative: Unique Constraints
For simple cases, you can use database unique constraints:
model Payment {
id String @id @default(cuid())
idempotencyKey String @unique // Enforce uniqueness at DB level
amount Int
currency String
customerId String
status String
createdAt DateTime @default(now())
@@index([idempotencyKey])
}
// Create payment with idempotency
app.post('/payments', async (req, res) => {
const { amount, currency, customerId } = req.body;
const idempotencyKey = req.headers['idempotency-key'] as string;
if (!idempotencyKey) {
return res.status(400).json({ error: 'Idempotency-Key required' });
}
try {
const payment = await prisma.payment.create({
data: {
idempotencyKey, // Will fail if key already exists
amount,
currency,
customerId,
status: 'succeeded',
},
});
return res.status(201).json(payment);
} catch (error: any) {
if (error.code === 'P2002') {
// Unique constraint violation—key already used
const existing = await prisma.payment.findUnique({
where: { idempotencyKey },
});
return res.status(200).json(existing); // Return existing payment
}
throw error;
}
});
Pros:
- Simpler implementation
- Database enforces uniqueness
Cons:
- Less control over response caching
- Harder to handle concurrent requests
- Can't distinguish between "processing" and "completed"
Real-World Examples
Stripe API
Stripe requires idempotency keys for all POST requests:
curl https://api.stripe.com/v1/charges \
-u sk_test_YOUR_KEY: \
-H "Idempotency-Key: a7f8d9e2-3c4b-5d6e-7f8g-9h0i1j2k3l4m" \
-d amount=5000 \
-d currency=usd \
-d source=tok_visa
Stripe's Implementation:
- Keys expire after 24 hours
- Returns
409 Conflictfor concurrent requests with same key - Caches the entire response (status, headers, body)
- Supports all POST operations (charges, refunds, customers, subscriptions)
GitHub API
GitHub uses idempotency for webhook deliveries:
{
"id": "12345",
"event": "push",
"delivery_id": "a7f8d9e2-3c4b-5d6e-7f8g-9h0i1j2k3l4m",
"action": "created"
}
The delivery_id acts as an idempotency key—if GitHub retries the webhook, you can detect duplicates.
AWS S3
AWS S3 PUT operations are naturally idempotent:
# Uploading the same file multiple times produces the same result
aws s3 cp file.txt s3://bucket/file.txt
aws s3 cp file.txt s3://bucket/file.txt # Same result
Testing Idempotency
Unit Tests
import request from 'supertest';
import { app } from './app';
import { v4 as uuidv4 } from 'uuid';
describe('Payment Idempotency', () => {
it('should create payment on first request', async () => {
const idempotencyKey = uuidv4();
const res = await request(app)
.post('/payments')
.set('Idempotency-Key', idempotencyKey)
.send({
amount: 5000,
currency: 'usd',
customerId: 'cus_123',
});
expect(res.status).toBe(201);
expect(res.body).toHaveProperty('id');
expect(res.body.amount).toBe(5000);
});
it('should return cached response for duplicate request', async () => {
const idempotencyKey = uuidv4();
// First request
const res1 = await request(app)
.post('/payments')
.set('Idempotency-Key', idempotencyKey)
.send({
amount: 5000,
currency: 'usd',
customerId: 'cus_123',
});
expect(res1.status).toBe(201);
const paymentId = res1.body.id;
// Second request with same key
const res2 = await request(app)
.post('/payments')
.set('Idempotency-Key', idempotencyKey)
.send({
amount: 5000,
currency: 'usd',
customerId: 'cus_123',
});
expect(res2.status).toBe(201);
expect(res2.body.id).toBe(paymentId); // Same payment ID
});
it('should reject invalid idempotency key format', async () => {
const res = await request(app)
.post('/payments')
.set('Idempotency-Key', 'invalid-key')
.send({
amount: 5000,
currency: 'usd',
customerId: 'cus_123',
});
expect(res.status).toBe(400);
expect(res.body.error).toContain('Invalid idempotency key');
});
it('should handle concurrent requests with same key', async () => {
const idempotencyKey = uuidv4();
// Send two requests concurrently
const [res1, res2] = await Promise.all([
request(app)
.post('/payments')
.set('Idempotency-Key', idempotencyKey)
.send({ amount: 5000, currency: 'usd', customerId: 'cus_123' }),
request(app)
.post('/payments')
.set('Idempotency-Key', idempotencyKey)
.send({ amount: 5000, currency: 'usd', customerId: 'cus_123' }),
]);
// One should succeed (201), one should get 409 Conflict
const statuses = [res1.status, res2.status].sort();
expect(statuses).toEqual([201, 409]);
});
});
Load Testing
import { v4 as uuidv4 } from 'uuid';
async function loadTestIdempotency() {
const idempotencyKey = uuidv4();
const concurrentRequests = 100;
// Send 100 requests with the same idempotency key
const promises = Array.from({ length: concurrentRequests }, () =>
fetch('https://api.example.com/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
amount: 5000,
currency: 'usd',
customerId: 'cus_123',
}),
})
);
const responses = await Promise.all(promises);
// Count unique payment IDs
const paymentIds = new Set(
await Promise.all(
responses.map(async (r) => {
const data = await r.json();
return data.id;
})
)
);
console.log(`${concurrentRequests} requests created ${paymentIds.size} payments`);
// Expected: 1 payment created (all others returned cached response)
}
Common Mistakes
1. ❌ Generating New Key on Retry
// WRONG: New key on each retry
for (let attempt = 0; attempt < 3; attempt++) {
const idempotencyKey = uuidv4(); // ❌ New key every time
await createPayment({ idempotencyKey });
}
// CORRECT: Same key for all retries
const idempotencyKey = uuidv4(); // ✅ Generate once
for (let attempt = 0; attempt < 3; attempt++) {
await createPayment({ idempotencyKey }); // ✅ Same key
}
2. ❌ Not Validating Key Format
// WRONG: Accept any string
const idempotencyKey = req.headers['idempotency-key'];
// CORRECT: Validate UUID format
const idempotencyKey = req.headers['idempotency-key'] as string;
if (!/^[0-9a-f-]{36}$/i.test(idempotencyKey)) {
return res.status(400).json({ error: 'Invalid key format' });
}
3. ❌ Making Idempotency Optional for Critical Operations
// WRONG: Idempotency is optional for payments
app.post('/payments', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key']; // May be undefined
// Process payment anyway ❌
});
// CORRECT: Require idempotency for payments
app.post('/payments', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) {
return res.status(400).json({ error: 'Idempotency-Key required' });
}
// Process payment ✅
});
4. ❌ Not Handling Concurrent Requests
// WRONG: No concurrent request handling
const existing = await db.findKey(idempotencyKey);
if (existing) {
return existing.response; // ✅ Return cached
}
// Process request... (multiple requests could reach here simultaneously) ❌
// CORRECT: Lock or return 409 for concurrent requests
const existing = await db.findKey(idempotencyKey);
if (existing && existing.status === 'processing') {
return res.status(409).json({ error: 'Request in progress' }); // ✅
}
5. ❌ Storing Keys Forever
// WRONG: No expiration
model IdempotencyKey {
key String @unique
// No expiresAt field ❌
}
// CORRECT: Auto-expire after 24-48 hours
model IdempotencyKey {
key String @unique
expiresAt DateTime // ✅ Automatic cleanup
}
6. ❌ Not Testing with Production Traffic Patterns
Many idempotency bugs only appear under:
- High concurrent requests (race conditions)
- Network flakiness (retry storms)
- Database replication lag (stale reads)
Always load test with realistic traffic before going to production.
7. ❌ Caching Error Responses
// WRONG: Cache all responses (including errors)
await cacheResponse(idempotencyKey, response); // ❌ Caches 500 errors
// CORRECT: Only cache successful responses
if (response.status < 400) {
await cacheResponse(idempotencyKey, response); // ✅
}
Why? If a request fails due to a transient error (DB timeout, external API down), you want the client to retry—not get a cached error.
Production Checklist
Implementation
- ✅ Idempotency keys required for all
POST/PATCHoperations - ✅ UUIDs validated (reject invalid formats)
- ✅ Response caching implemented (status, headers, body)
- ✅ Concurrent request handling (return
409 Conflictif processing) - ✅ Key expiration configured (24-48 hours)
- ✅ Automatic cleanup job running (delete expired keys)
- ✅ Database indexes on
keycolumn for fast lookups - ✅ Error responses NOT cached (only successful responses)
Client-Side
- ✅ Keys generated before first request (not on retry)
- ✅ Same key used for all retries
- ✅ Keys persisted (in case client crashes)
- ✅ Exponential backoff implemented
- ✅
409 Conflicthandled (wait and retry)
Monitoring
- ✅ Track idempotency key hit rate (how often keys are reused)
- ✅ Alert on high
409 Conflictrate (indicates retry storms) - ✅ Monitor key cache size (shouldn't grow unbounded)
- ✅ Track key expiration job success
📡 Monitor idempotency key collisions and API retry rates. Better Stack combines uptime monitoring, incident management, and log aggregation — catch duplicate request issues before they become payment problems. Free tier available.
Testing
- ✅ Unit tests for duplicate requests
- ✅ Unit tests for concurrent requests
- ✅ Unit tests for invalid keys
- ✅ Load tests with realistic traffic patterns
- ✅ Chaos testing (simulate network failures, retries)
Documentation
- ✅ API docs explain idempotency requirements
- ✅ Header format documented (
Idempotency-Key: UUID) - ✅ Response codes documented (200, 201, 409, etc.)
- ✅ Client retry examples provided
Related Resources
- API Error Handling Guide — How to handle and retry errors correctly
- Webhook Implementation Guide — Idempotency for webhook processing
- API Authentication — Secure your idempotent endpoints
- Stripe Status — Monitor Stripe API availability
- GitHub Status — Monitor GitHub API availability
- AWS Status — Monitor AWS API availability
- PayPal Status — Monitor PayPal API availability
- Shopify Status — Monitor Shopify API availability
- Twilio Status — Monitor Twilio API availability
- Braintree Status — Monitor Braintree API availability
- Square Status — Monitor Square API availability
- Adyen Status — Monitor Adyen API availability
- Datadog Status — Monitor Datadog API for observability
- New Relic Status — Monitor New Relic API for observability
🛡️ Building payment APIs? Protect your personal data too. When your name is attached to production systems processing real transactions, your personal data exposure matters. Optery removes your personal information from 350+ data broker sites — reducing your exposure to targeted phishing.
Conclusion
Idempotency is non-negotiable for production APIs that handle payments, orders, or any state-changing operation. The cost of getting it wrong—double charges, duplicate orders, data corruption—far outweighs the implementation effort.
Key Takeaways:
- Use idempotency keys (UUIDs) for all POST/PATCH operations
- Generate keys on the client before the first request
- Use the same key for retries (that's the entire point)
- Cache successful responses (status, headers, body)
- Handle concurrent requests (return 409 Conflict if processing)
- Expire keys after 24-48 hours (prevent unbounded growth)
- Test with production traffic patterns (concurrent requests, retries)
With proper idempotency, your API can handle network failures, retries, and duplicate requests gracefully—without creating duplicate charges or inconsistent state.
Need help monitoring your third-party APIs? API Status Check tracks 160+ APIs including Stripe, PayPal, AWS, GitHub, and more. Get real-time status updates and embeddable badges for your docs.
🛠 Tools We Use & Recommend
Tested across our own infrastructure monitoring 200+ APIs daily
Uptime Monitoring & Incident Management
Used by 100,000+ websites
Monitors your APIs every 30 seconds. Instant alerts via Slack, email, SMS, and phone calls when something goes down.
“We use Better Stack to monitor every API on this site. It caught 23 outages last month before users reported them.”
Secrets Management & Developer Security
Trusted by 150,000+ businesses
Manage API keys, database passwords, and service tokens with CLI integration and automatic rotation.
“After covering dozens of outages caused by leaked credentials, we recommend every team use a secrets manager.”
SEO & Site Performance Monitoring
Used by 10M+ marketers
Track your site health, uptime, search rankings, and competitor movements from one dashboard.
“We use SEMrush to track how our API status pages rank and catch site health issues early.”
API Status Check
Stop checking API status pages manually
Get instant email alerts when OpenAI, Stripe, AWS, and 100+ APIs go down. Know before your users do.
14-day free trial · $0 due today · $9/mo after · Cancel anytime
Browse Free Dashboard →