Back to Blog
APIIdempotencyReliabilityBest Practices

Idempotency in API Design

Build reliable APIs with idempotency. From idempotency keys to retry handling to implementation patterns.

B
Bootspring Team
Engineering
May 20, 2023
6 min read

Idempotent operations produce the same result regardless of how many times they're called. This is essential for reliable APIs that handle retries and network failures.

Why Idempotency Matters#

Network Failures: - Request sent but response lost - Client retries not knowing if it succeeded - Without idempotency: duplicate charges, orders, etc. With Idempotency: - Client can safely retry - Server detects duplicate requests - Same result returned for repeated calls

HTTP Method Idempotency#

Naturally Idempotent: - GET: Read operations - PUT: Replace resource - DELETE: Remove resource - HEAD: Read headers - OPTIONS: Read options NOT Idempotent by Default: - POST: Create resource (needs idempotency key) - PATCH: May or may not be idempotent

Idempotency Keys#

1import { Redis } from 'ioredis'; 2import crypto from 'crypto'; 3 4const redis = new Redis(process.env.REDIS_URL); 5 6interface IdempotencyRecord { 7 status: 'processing' | 'completed'; 8 response?: { 9 statusCode: number; 10 body: any; 11 }; 12 createdAt: string; 13} 14 15const IDEMPOTENCY_TTL = 24 * 60 * 60; // 24 hours 16 17async function getIdempotencyRecord(key: string): Promise<IdempotencyRecord | null> { 18 const data = await redis.get(`idempotency:${key}`); 19 return data ? JSON.parse(data) : null; 20} 21 22async function setIdempotencyRecord( 23 key: string, 24 record: IdempotencyRecord 25): Promise<void> { 26 await redis.setex( 27 `idempotency:${key}`, 28 IDEMPOTENCY_TTL, 29 JSON.stringify(record) 30 ); 31} 32 33// Middleware 34function idempotent() { 35 return async (req: Request, res: Response, next: NextFunction) => { 36 const idempotencyKey = req.headers['idempotency-key'] as string; 37 38 // Only apply to POST/PATCH 39 if (!['POST', 'PATCH'].includes(req.method)) { 40 return next(); 41 } 42 43 // Key is optional but recommended 44 if (!idempotencyKey) { 45 return next(); 46 } 47 48 // Validate key format 49 if (idempotencyKey.length > 255) { 50 return res.status(400).json({ 51 error: 'Idempotency key too long', 52 }); 53 } 54 55 // Create unique key including user context 56 const fullKey = `${req.user?.id || 'anonymous'}:${req.path}:${idempotencyKey}`; 57 58 // Check for existing record 59 const existing = await getIdempotencyRecord(fullKey); 60 61 if (existing) { 62 if (existing.status === 'processing') { 63 // Request still in progress 64 return res.status(409).json({ 65 error: 'Request is being processed', 66 retryAfter: 1, 67 }); 68 } 69 70 if (existing.status === 'completed' && existing.response) { 71 // Return cached response 72 res.setHeader('X-Idempotent-Replay', 'true'); 73 return res.status(existing.response.statusCode).json(existing.response.body); 74 } 75 } 76 77 // Mark as processing 78 await setIdempotencyRecord(fullKey, { 79 status: 'processing', 80 createdAt: new Date().toISOString(), 81 }); 82 83 // Capture response 84 const originalJson = res.json.bind(res); 85 res.json = (body) => { 86 // Store completed response 87 setIdempotencyRecord(fullKey, { 88 status: 'completed', 89 response: { 90 statusCode: res.statusCode, 91 body, 92 }, 93 createdAt: new Date().toISOString(), 94 }).catch((err) => { 95 console.error('Failed to store idempotency record:', err); 96 }); 97 98 return originalJson(body); 99 }; 100 101 next(); 102 }; 103} 104 105app.use(idempotent());

Payment Processing Example#

1// Client-side: Generate idempotency key 2async function createPayment(amount: number): Promise<Payment> { 3 const idempotencyKey = crypto.randomUUID(); 4 5 // Store key for potential retries 6 localStorage.setItem('pendingPayment', idempotencyKey); 7 8 const response = await fetch('/api/payments', { 9 method: 'POST', 10 headers: { 11 'Content-Type': 'application/json', 12 'Idempotency-Key': idempotencyKey, 13 }, 14 body: JSON.stringify({ amount }), 15 }); 16 17 if (response.ok) { 18 localStorage.removeItem('pendingPayment'); 19 } 20 21 return response.json(); 22} 23 24// Server-side: Process payment idempotently 25app.post('/api/payments', idempotent(), async (req, res) => { 26 const { amount, customerId } = req.body; 27 28 try { 29 // Create payment in database 30 const payment = await prisma.payment.create({ 31 data: { 32 id: crypto.randomUUID(), 33 amount, 34 customerId, 35 status: 'pending', 36 }, 37 }); 38 39 // Charge payment provider 40 const charge = await stripe.charges.create({ 41 amount: amount * 100, 42 currency: 'usd', 43 customer: customerId, 44 idempotencyKey: req.headers['idempotency-key'], 45 }); 46 47 // Update payment status 48 await prisma.payment.update({ 49 where: { id: payment.id }, 50 data: { 51 status: 'completed', 52 stripeChargeId: charge.id, 53 }, 54 }); 55 56 res.status(201).json(payment); 57 } catch (error) { 58 // Handle specific errors 59 if (error.type === 'StripeIdempotencyError') { 60 // Key was reused with different parameters 61 return res.status(400).json({ 62 error: 'Idempotency key was used with different request parameters', 63 }); 64 } 65 66 throw error; 67 } 68});

Database-Level Idempotency#

1// Use unique constraints 2const schema = ` 3 CREATE TABLE orders ( 4 id UUID PRIMARY KEY, 5 idempotency_key VARCHAR(255) UNIQUE, 6 customer_id UUID NOT NULL, 7 total DECIMAL(10,2) NOT NULL, 8 created_at TIMESTAMP DEFAULT NOW() 9 ); 10`; 11 12async function createOrder( 13 idempotencyKey: string, 14 orderData: CreateOrderInput 15): Promise<Order> { 16 try { 17 // Try to create order 18 const order = await prisma.order.create({ 19 data: { 20 id: crypto.randomUUID(), 21 idempotencyKey, 22 ...orderData, 23 }, 24 }); 25 26 return order; 27 } catch (error) { 28 // Check for unique constraint violation 29 if (error.code === 'P2002' && error.meta?.target?.includes('idempotency_key')) { 30 // Return existing order 31 const existing = await prisma.order.findUnique({ 32 where: { idempotencyKey }, 33 }); 34 35 if (existing) { 36 return existing; 37 } 38 } 39 40 throw error; 41 } 42}

Optimistic Locking#

1// Use version numbers for idempotent updates 2async function updateInventory( 3 productId: string, 4 quantity: number, 5 expectedVersion: number 6): Promise<Inventory> { 7 const result = await prisma.inventory.updateMany({ 8 where: { 9 productId, 10 version: expectedVersion, 11 }, 12 data: { 13 quantity, 14 version: { increment: 1 }, 15 }, 16 }); 17 18 if (result.count === 0) { 19 // Version mismatch - concurrent modification 20 throw new ConcurrentModificationError(); 21 } 22 23 return prisma.inventory.findUnique({ 24 where: { productId }, 25 }); 26} 27 28// Client handles conflicts 29async function decrementStock(productId: string, amount: number): Promise<void> { 30 let retries = 3; 31 32 while (retries > 0) { 33 const inventory = await getInventory(productId); 34 35 try { 36 await updateInventory( 37 productId, 38 inventory.quantity - amount, 39 inventory.version 40 ); 41 return; 42 } catch (error) { 43 if (error instanceof ConcurrentModificationError && retries > 1) { 44 retries--; 45 continue; 46 } 47 throw error; 48 } 49 } 50}

Event Processing Idempotency#

1// Deduplicate webhook events 2async function processWebhook(event: WebhookEvent): Promise<void> { 3 const eventId = event.id; 4 5 // Check if already processed 6 const existing = await prisma.processedEvent.findUnique({ 7 where: { eventId }, 8 }); 9 10 if (existing) { 11 console.log(`Event ${eventId} already processed`); 12 return; 13 } 14 15 // Process in transaction 16 await prisma.$transaction(async (tx) => { 17 // Mark as processing 18 await tx.processedEvent.create({ 19 data: { 20 eventId, 21 type: event.type, 22 processedAt: new Date(), 23 }, 24 }); 25 26 // Handle event 27 switch (event.type) { 28 case 'payment.succeeded': 29 await handlePaymentSuccess(tx, event.data); 30 break; 31 case 'subscription.created': 32 await handleSubscriptionCreated(tx, event.data); 33 break; 34 } 35 }); 36} 37 38// Message queue deduplication 39async function processMessage(message: QueueMessage): Promise<void> { 40 const messageId = message.id; 41 42 // Use Redis for deduplication with TTL 43 const key = `processed:${messageId}`; 44 const wasSet = await redis.setnx(key, '1'); 45 46 if (!wasSet) { 47 // Already processed 48 return; 49 } 50 51 // Set expiry 52 await redis.expire(key, 24 * 60 * 60); 53 54 // Process message 55 await handleMessage(message); 56}

Retry Handling#

1// Client-side retry with backoff 2async function fetchWithRetry<T>( 3 fn: () => Promise<T>, 4 options: { 5 maxRetries?: number; 6 baseDelay?: number; 7 maxDelay?: number; 8 } = {} 9): Promise<T> { 10 const { maxRetries = 3, baseDelay = 1000, maxDelay = 10000 } = options; 11 12 let lastError: Error; 13 14 for (let attempt = 0; attempt <= maxRetries; attempt++) { 15 try { 16 return await fn(); 17 } catch (error) { 18 lastError = error; 19 20 // Don't retry client errors 21 if (error.status >= 400 && error.status < 500 && error.status !== 429) { 22 throw error; 23 } 24 25 if (attempt < maxRetries) { 26 const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay); 27 const jitter = delay * 0.2 * Math.random(); 28 await sleep(delay + jitter); 29 } 30 } 31 } 32 33 throw lastError!; 34} 35 36// Usage with idempotency key 37const payment = await fetchWithRetry( 38 () => createPayment(amount, idempotencyKey), 39 { maxRetries: 3 } 40);

Best Practices#

Implementation: ✓ Generate keys client-side (UUIDs) ✓ Include user context in key ✓ Set appropriate TTL (24-48 hours) ✓ Handle "processing" state Error Handling: ✓ Return same response for duplicates ✓ Handle parameter mismatches ✓ Log duplicate requests ✓ Clean up on failure Performance: ✓ Use fast storage (Redis) ✓ Index idempotency columns ✓ Set TTL to auto-cleanup ✓ Consider eventual consistency

Conclusion#

Idempotency is essential for reliable APIs. Use idempotency keys for non-idempotent operations, store responses for replay, and design your data models to handle duplicates gracefully. This enables safe client retries and improves system reliability.

Share this article

Help spread the word about Bootspring