Back to Resources Resource

Idempotent APIs - Interactive Guide

Master idempotency with an interactive simulator. Understand how Stripe, AWS, and payment systems prevent duplicate transactions and build bulletproof APIs.

Prevent duplicate payments, double orders, and data corruption. This guide covers Idempotency Keys— the pattern used by Stripe, AWS, and every payment processor to guarantee exactly-once processing.

How to use: Toggle between Visual to understand the pattern and Code to implement it. Try the simulator to see idempotency in action.

The Problem

Network fails mid-request. User clicks "Pay" twice. Your retry logic fires 3 times. Without idempotency, you charge the customer $300 instead of $100.

💳
Double Payment
User clicks "Pay" button twice because page seemed frozen.
Without: Charged twice
With: Second click returns cached response
🔄
Network Retry
Request succeeded but response was lost. Client retries.
Without: Duplicate order created
With: Returns original order
Server Crash
Server crashes after processing, before responding.
Without: Data in inconsistent state
With: Safe to retry

The Solution: Idempotency Keys

Client generates a unique key for each logical operation. Server stores the key with the response. If the same key arrives again, return the cached response instead of processing again.

Idempotency Simulator

Client

📱
Idempotency-Key:
pay_abc123
POST /charge

Server

🖥️
0
Processed
0
From Cache
$0
Charged
Idempotency Cache:

How It Works

Request + Idempotency-Key
Lookup Key in Cache
Key Exists?
No (First time)
Process Request
Store key + response
Yes (Duplicate)
Return Cached
Don't reprocess
Key Insight: The client controls the key. Same key = same logical operation. The server guarantees at-most-once processing per key.

Express Middleware

const idempotencyCache = new Map(); function idempotent(req, res, next) { const key = req.headers['idempotency-key']; if (!key) { return res.status(400).json({ error: 'Idempotency-Key header required' }); } // Check if we've seen this key before const cached = idempotencyCache.get(key); if (cached) { return res.status(cached.status).json(cached.body); } // Intercept the response to cache it const originalJson = res.json.bind(res); res.json = (body) => { idempotencyCache.set(key, { status: res.statusCode, body, timestamp: Date.now() }); return originalJson(body); }; next(); } // Usage app.post('/payments', idempotent, async (req, res) => { const payment = await processPayment(req.body); res.json(payment); // Automatically cached });
Pitfall: In-memory cache only works for single server. Use Redis for distributed systems.

Client-Side: Generate & Retry

async function chargeWithRetry(amount, maxRetries = 3) { // Generate key ONCE for this logical operation const idempotencyKey = `pay_${crypto.randomUUID()}`; for (let attempt = 0; attempt < maxRetries; attempt++) { try { const response = await fetch('/api/payments', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey // Same key for retries! }, body: JSON.stringify({ amount }) }); return await response.json(); } catch (err) { if (attempt === maxRetries - 1) throw err; await sleep(1000 * (attempt + 1)); // Exponential backoff } } }

Production: Redis + PostgreSQL

-- PostgreSQL: Store idempotency keys with responses CREATE TABLE idempotency_keys ( key VARCHAR(255) PRIMARY KEY, response JSONB NOT NULL, status_code INTEGER NOT NULL, created_at TIMESTAMP DEFAULT NOW(), expires_at TIMESTAMP DEFAULT NOW() + INTERVAL '24 hours' ); -- Index for cleanup job CREATE INDEX idx_expires ON idempotency_keys(expires_at);
// Node.js with Redis for locking + Postgres for storage async function handleIdempotent(key, processor) { // 1. Try to get cached response const cached = await db.query( 'SELECT response, status_code FROM idempotency_keys WHERE key = $1', [key] ); if (cached.rows[0]) { return cached.rows[0]; // Return cached! } // 2. Acquire distributed lock (prevent race condition) const lock = await redis.set(`lock:${key}`, '1', 'NX', 'EX', 30); if (!lock) { throw new Error('Request in progress'); // 409 Conflict } try { // 3. Process the request const result = await processor(); // 4. Store response for future retries await db.query( 'INSERT INTO idempotency_keys (key, response, status_code) VALUES ($1, $2, $3)', [key, result.body, result.status] ); return result; } finally { await redis.del(`lock:${key}`); } }
Production Tips:
  • Keys should expire (24h is common) to prevent unbounded growth
  • Return 409 Conflict if same key is in-flight (being processed)
  • Include request body hash to detect key reuse with different data

When to Use

  • Payment processing (charges, refunds)
  • Order creation / inventory updates
  • Any non-reversible mutation
  • Webhook handlers

When to Skip

  • GET requests (naturally idempotent)
  • DELETE by ID (already idempotent)
  • Stateless operations

Edge Cases & Pitfalls

🔑
Key Reuse Attack
Client sends same key with different amount.
Fix: Hash request body with key, reject mismatches
⏱️
Race Condition
Two requests with same key arrive simultaneously.
Fix: Use distributed lock (Redis SETNX)
💾
Cache Bloat
Keys never expire, database grows forever.
Fix: TTL on keys (24h), background cleanup job
🔄
Partial Failure
Request processed but cache write fails.
Fix: Use database transaction for both

Real-World Examples

Stripe: Idempotency-Key header on all POST requests. AWS: ClientToken parameter. PayPal: PayPal-Request-Id header.

Explore More Resources