A user clicks "Pay Now" on your checkout page. The network hiccups. The request times out. Their browser retries. Your server processes the payment again. The customer gets charged twice. Their trust in your product? Gone.
This exact scenario happens more than you'd think — especially on mobile networks, during deploy windows, or when webhooks fire multiple times. Idempotency is the pattern that prevents it. It ensures that no matter how many times a request is sent, the result is the same as if it was sent once.
What Is Idempotency?
An operation is idempotent if performing it multiple times produces the same result as performing it once. The system ends up in the same state regardless of how many times you execute the operation.
Real-world analogy: pressing an elevator button. Press it once — the elevator is called. Press it 10 more times — the elevator is still just called once. The button is idempotent. Compare that to a vending machine: insert a coin and you get a snack. Insert another coin and you get another snack. That's not idempotent.
In API terms:
// Idempotent: setting a value
PUT /users/123 { "email": "alice@example.com" }
// Do it 1 time or 100 times — email is "alice@example.com"
// NOT idempotent: creating a resource
POST /orders { "item": "laptop", "qty": 1 }
// Do it 3 times — you get 3 ordersWhich HTTP Methods Are Idempotent?
The HTTP specification defines which methods are idempotent. This isn't a suggestion — clients and intermediaries (proxies, CDNs, browsers) rely on this contract to decide what's safe to retry automatically.
| Method | Idempotent? | Safe? | Why |
|---|---|---|---|
| GET | ✅ Yes | ✅ Yes | Retrieves data without changing state — always returns the same resource |
| HEAD | ✅ Yes | ✅ Yes | Same as GET but without body — no state change |
| OPTIONS | ✅ Yes | ✅ Yes | Returns server capabilities — no state change |
| PUT | ✅ Yes | ❌ No | Replaces entire resource — doing it twice leaves the same state |
| DELETE | ✅ Yes | ❌ No | Removes resource — deleting twice still results in 'resource gone' |
| POST | ❌ No | ❌ No | Creates new resources — each call may create a new entry |
| PATCH | ❌ No | ❌ No | Partial update — 'increment counter' is not idempotent |
Key distinction: "safe" means no side effects at all. "Idempotent" means repeated calls produce the same result. DELETE changes state (removes a resource) but is still idempotent because doing it twice doesn't change anything beyond the first call.
Why Idempotency Matters
In the real world, requests don't always arrive once. Networks are unreliable. Clients retry. Users double-click. Here's where non-idempotent APIs break:
Network Retries
HTTP clients (Axios, fetch with retry libraries) automatically retry on timeout or 5xx errors. If your POST endpoint creates a resource on each call, retries create duplicates.
Double-Clicks
Users click submit buttons multiple times — especially when the UI doesn't show a loading state fast enough. Without idempotency, each click creates a new transaction.
Mobile Networks
Mobile connections drop and reconnect constantly. A request might reach the server, get processed, but the response gets lost. The client retries — and without idempotency, the operation executes again.
Webhooks
Webhook providers (Stripe, GitHub, Shopify) retry on delivery failure. Your webhook handler might receive the same event 2-5 times. If it's not idempotent, you process the event multiple times.
How to Implement Idempotency Keys
The standard pattern for making non-idempotent operations (like POST) safe to retry is the idempotency key. Here's the flow:
Client Server | | |--- POST /payments ----------------->| | Header: Idempotency-Key: abc-123 | | | | 1. Check: has "abc-123" been | | seen before? | | | | [FIRST TIME] | | 2. Store key as "processing" | | 3. Execute the operation | | 4. Store result with key | | 5. Return response | |<--- 201 Created -------------------| | | |--- POST /payments ----------------->| (retry — same key) | Header: Idempotency-Key: abc-123 | | | | [KEY EXISTS] | | 2. Return stored response | |<--- 201 Created (cached) ----------| | (no duplicate payment created) |
The client generates a unique key (UUID v4 works well) before sending the request. If it needs to retry, it sends the same key. The server recognizes the duplicate and returns the original response without re-executing the operation.
Code Example: Express.js Idempotency Middleware
A production-ready middleware using Redis to store idempotency keys and responses:
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL)
const IDEMPOTENCY_TTL = 86400 // 24 hours
export function idempotencyMiddleware(req, res, next) {
// Only apply to non-idempotent methods
if (['GET', 'PUT', 'DELETE', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next()
}
const idempotencyKey = req.headers['idempotency-key']
if (!idempotencyKey) {
return next() // No key provided — process normally
}
// Validate key format (UUID v4)
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. Use UUID v4.'
})
}
const cacheKey = `idempotency:${req.path}:${idempotencyKey}`
handleIdempotency(cacheKey, req, res, next)
}
async function handleIdempotency(cacheKey, req, res, next) {
try {
// Check if key exists
const existing = await redis.get(cacheKey)
if (existing) {
const cached = JSON.parse(existing)
// Key is still processing (server crashed mid-request?)
if (cached.status === 'processing') {
return res.status(409).json({
error: 'Request is already being processed. Retry later.'
})
}
// Return cached response
return res.status(cached.statusCode).json(cached.body)
}
// Mark as processing
await redis.setex(cacheKey, IDEMPOTENCY_TTL, JSON.stringify({
status: 'processing',
startedAt: Date.now()
}))
// Intercept res.json to capture the response
const originalJson = res.json.bind(res)
res.json = (body) => {
// Store completed response
redis.setex(cacheKey, IDEMPOTENCY_TTL, JSON.stringify({
status: 'completed',
statusCode: res.statusCode,
body,
completedAt: Date.now()
}))
return originalJson(body)
}
next()
} catch (err) {
console.error('Idempotency middleware error:', err)
next() // Fail open — process request normally
}
}Usage in your Express app:
import express from 'express'
import { idempotencyMiddleware } from './middleware/idempotency.js'
const app = express()
app.use(express.json())
app.use(idempotencyMiddleware)
app.post('/api/payments', async (req, res) => {
const payment = await processPayment(req.body)
res.status(201).json(payment)
})
// Client sends:
// POST /api/payments
// Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
// Content-Type: application/json
// { "amount": 2999, "currency": "usd" }How Stripe Handles Idempotency
Stripe's implementation is the gold standard for idempotency in payment APIs. Here's how they do it:
Client sends Idempotency-Key header
Any POST request to Stripe can include an Idempotency-Key header with a unique string (up to 255 characters). Stripe recommends UUID v4.
Keys expire after 24 hours
Stripe stores the key and response for 24 hours. After that, the same key can be reused (though you generally shouldn't).
Parameter mismatch = 422 error
If you send the same key with different request body parameters, Stripe returns a 422 error. The key is bound to the original request parameters to prevent misuse.
Automatic retries in SDKs
Stripe's official SDKs automatically generate and reuse idempotency keys when retrying failed requests. You get safety without thinking about it.
// Stripe API with idempotency key
const stripe = require('stripe')('sk_test_...')
const paymentIntent = await stripe.paymentIntents.create(
{
amount: 2000,
currency: 'usd',
payment_method: 'pm_card_visa',
confirm: true,
},
{
idempotencyKey: '550e8400-e29b-41d4-a716-446655440000'
}
)
// If this request is retried (network timeout, etc.),
// Stripe returns the same PaymentIntent without charging againDatabase-Level Idempotency
You don't always need Redis and middleware. Sometimes the database itself can enforce idempotency through constraints and conflict handling:
Unique Constraints
Add a unique constraint on a business-meaningful field. Duplicate inserts fail at the database level instead of creating duplicates.
-- Unique constraint on order reference
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
order_ref VARCHAR(50) UNIQUE NOT NULL, -- client-generated reference
user_id INTEGER NOT NULL,
amount DECIMAL(10,2) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- Second insert with same order_ref fails with unique violation
INSERT INTO orders (order_ref, user_id, amount)
VALUES ('ORD-2026-ABC123', 42, 99.99);Upserts (INSERT ON CONFLICT)
Instead of failing on duplicates, upsert either does nothing or updates the existing row. This makes the INSERT idempotent by definition.
-- PostgreSQL: Insert or do nothing if duplicate
INSERT INTO webhook_events (event_id, type, payload, processed_at)
VALUES ('evt_123abc', 'payment.completed', '{"amount": 5000}', NOW())
ON CONFLICT (event_id) DO NOTHING;
-- MySQL equivalent
INSERT IGNORE INTO webhook_events (event_id, type, payload)
VALUES ('evt_123abc', 'payment.completed', '{"amount": 5000}');Conditional Inserts
Check a condition before inserting. Only insert if the precondition holds — making the operation safe to retry.
-- Only insert if not already processed INSERT INTO ledger_entries (transaction_id, account_id, amount, type) SELECT 'txn_abc123', 42, 100.00, 'credit' WHERE NOT EXISTS ( SELECT 1 FROM ledger_entries WHERE transaction_id = 'txn_abc123' );
Common Idempotency Mistakes
These are the pitfalls that catch teams when implementing idempotency:
1. Not Validating Key Format
Accepting any string as an idempotency key means clients might accidentally reuse keys (like sending "test" or "1"). Require UUID v4 format or at least a minimum length to prevent collisions.
2. Not Expiring Keys
Storing idempotency keys forever means your storage grows without limit. Set a TTL (24-48 hours is standard). After expiry, the same key can technically be reused, but the retry window has long passed.
3. Returning Different Responses for the Same Key
If the stored response doesn't match what the operation would produce today (because data changed), you should still return the original stored response. The idempotency contract is: same key = same response. Period.
4. Not Binding Key to Request Parameters
If you accept the same idempotency key with different parameters, you might return a cached response for a completely different request. Always validate that the request body matches what was originally sent with that key.
5. Server-Side Key Generation
The client must generate the idempotency key. If the server generates it, the client can't retry with the same key after a timeout (because it never received the key). The whole point is the client controls the deduplication.
6. Not Handling the "Processing" State
If a request is mid-execution and a retry arrives, you need to handle this gracefully. Without a "processing" state, you might execute the operation twice concurrently. Use a state machine: received → processing → completed.
Idempotency Best Practices
Use UUID v4 for idempotency keys. They're globally unique without coordination, 128 bits of randomness, and impossible to accidentally reuse. Generate one per user action, not per retry attempt.
Store keys with a 24-hour TTL. Long enough to handle any realistic retry scenario. Short enough to prevent unbounded storage growth. Match your client's maximum retry window.
Scope keys to the endpoint + user. The cache key should include the API path and authenticated user to prevent cross-endpoint collisions. Format: idempotency:{userId}:{path}:{key}
Validate request body matches. When a cached key is found, compare the incoming request parameters with the original. If they differ, return a 422 error — don't silently return a mismatched response.
Fail open on cache errors. If Redis is down, process the request normally rather than blocking all writes. Log the failure and accept the (unlikely) risk of a duplicate. Availability beats perfect idempotency.
Make webhook handlers idempotent. Use the event ID as a natural idempotency key. Store processed event IDs and skip duplicates. This is critical for payment webhooks where reprocessing means double-crediting accounts.
Document idempotency behavior in your API docs. Tell clients which endpoints accept idempotency keys, what header to use, what format is expected, and how long keys are stored. Don't make them guess.
Test with concurrent duplicate requests. Unit tests aren't enough. Send the same request simultaneously from multiple threads and verify only one operation executes. Race conditions hide in idempotency logic.
Frequently Asked Questions
What does idempotent mean in APIs?+
Which HTTP methods are idempotent?+
What is an idempotency key?+
Why does idempotency matter for payment APIs?+
How does Stripe handle idempotency?+
How long should idempotency keys be stored?+
Is PATCH idempotent?+
What happens if the server crashes mid-request with an idempotency key?+
Idempotency Implementation Checklist
Use this as a reference when adding idempotency to your API:
- ☐ Identify all non-idempotent endpoints (POST, non-idempotent PATCH)
- ☐ Add
Idempotency-Keyheader support to those endpoints - ☐ Validate key format (UUID v4, minimum length)
- ☐ Store keys in Redis with 24h TTL (or database for simpler setups)
- ☐ Implement "processing" state to handle concurrent duplicates
- ☐ Validate request body matches on cache hit (return 422 on mismatch)
- ☐ Return exact cached response (same status code, same body)
- ☐ Fail open on Redis errors (process request normally)
- ☐ Add unique constraints on business-meaningful fields in database
- ☐ Make webhook handlers idempotent using event IDs
- ☐ Test with concurrent duplicate requests (race condition testing)
- ☐ Document idempotency behavior in API docs
Idempotency vs Exactly-Once Delivery
People often confuse these concepts. Exactly-once delivery means a message is delivered precisely one time — no more, no less. This is extremely hard (some argue impossible) in distributed systems.
Idempotency sidesteps the problem entirely. Instead of guaranteeing a message arrives once, you guarantee that processing it multiple times has the same effect as processing it once. You get "effectively exactly-once" semantics by combining "at-least-once delivery" with "idempotent processing."
At-least-once delivery + Idempotent handler = Effectively exactly-once // Message queue delivers the same message 3 times due to retries // Idempotent handler: processes first, skips duplicates // Result: same as if message was delivered once
This is why idempotency is critical for message queue consumers, webhook handlers, and any system that receives events from an unreliable transport.
Related Articles
Conclusion
Idempotency isn't a nice-to-have — it's a requirement for any API that handles money, creates resources, or processes events. Without it, network retries become data corruption, double-clicks become double charges, and webhook redeliveries become duplicated side effects.
The implementation doesn't need to be complex. A UUID-based idempotency key stored in Redis with a 24-hour TTL handles the vast majority of cases. For simpler scenarios, database unique constraints and upserts give you idempotency without extra infrastructure.
Start with your payment endpoints and webhook handlers — those are where duplicate execution hurts most. Then extend to any POST endpoint where duplicates would cause visible problems for users.
