Here is a scenario every production application eventually faces: a user clicks "Pay Now." The request leaves the browser, reaches your server, the charge is processed successfully — but the network drops before the response returns. The browser shows an error. The user clicks "Pay Now" again. Your server processes the charge a second time. The user is billed twice.
Idempotency is the API design property that prevents this. An idempotent operation can be executed multiple times and will always produce the same result as if it had been executed once. For payment processing and any mutation that has real-world side effects, idempotency is not an optimization — it is a correctness requirement.
The Idempotency Key Pattern
The standard solution used by Stripe, Braintree, and every major payment processor is the idempotency key: a unique identifier that the client generates for each logical operation and includes with every request. The server uses this key to detect and suppress duplicate requests:
IDEMPOTENCY FLOW:
Request 1 (first attempt):
POST /api/payments { amount: 100, idempotencyKey: "order-123-attempt-1" }
→ Server: Key not seen before → Process payment → Store result → Return success
Request 2 (retry after network failure):
POST /api/payments { amount: 100, idempotencyKey: "order-123-attempt-1" }
→ Server: Key seen before → Return cached result (no charge) → Return successThe client generates the key before the first request. The server stores the key and the result. Retries with the same key get the same result without re-processing.
Client-Side: Generating Idempotency Keys
// lib/api-client.ts
import { randomUUID } from 'crypto';
// Generate a key per user action — not per request
// The key is stable across retries for the same action
export function generateIdempotencyKey(prefix: string): string {
return `${prefix}-${randomUUID()}`;
}
// Store the key per session action so retries use the same key
export function getOrCreateCheckoutKey(orderId: string): string {
const storageKey = `checkout-key-${orderId}`;
let key = sessionStorage.getItem(storageKey);
if (!key) {
key = generateIdempotencyKey(`checkout-${orderId}`);
sessionStorage.setItem(storageKey, key);
}
return key;
}
// Retry logic with exponential backoff
export async function fetchWithRetry(
url: string,
options: RequestInit,
retries = 3
): Promise<Response> {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const response= await fetch(url, options);
// Don't retry on client errors (4xx) — only on server errors (5xx) or network failures
if (response.status < 500) return response;
if (attempt < retries - 1) {
await new Promise(resolve=> setTimeout(resolve, 2 ** attempt * 1000));
}
} catch (error) {
if (attempt= retries - 1) throw error;
await new Promise(resolve=> setTimeout(resolve, 2 ** attempt * 1000));
}
}
throw new Error('Max retries exceeded');
}
// Usage in checkout flow
async function processPayment(orderId: string, amount: number) {
const idempotencyKey= getOrCreateCheckoutKey(orderId);
const response= await fetchWithRetry('/api/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ orderId, amount }),
});
// After success, clear the key so a new purchase generates a fresh key
if (response.ok) {
sessionStorage.removeItem(`checkout-key-${orderId}`);
}
return response.json();
}Server-Side: Storing and Checking Idempotency Keys
// app/api/payments/route.ts
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function POST(request: Request) {
const idempotencyKey = request.headers.get('Idempotency-Key');
if (!idempotencyKey) {
return NextResponse.json(
{ error: 'Idempotency-Key header is required' },
{ status: 400 }
);
}
// 1. Check if we've seen this key before
const existing = await db.query(
'SELECT result, status FROM idempotency_store WHERE key = $1 AND expires_at > NOW()',
[idempotencyKey]
);
if (existing.rows.length > 0) {
const cached = existing.rows[0];
if (cached.status === 'processing') {
// The original request is still in progress — return 409
return NextResponse.json(
{ error: 'Request is still being processed. Please wait and retry.' },
{ status: 409 }
);
}
// Return the cached result
return NextResponse.json(
JSON.parse(cached.result),
{ status: 200, headers: { 'Idempotent-Replayed': 'true' } }
);
}
// 2. Mark as processing to prevent concurrent duplicate requests
await db.query(
`INSERT INTO idempotency_store (key, status, expires_at)
VALUES ($1, 'processing', NOW() + INTERVAL '24 hours')`,
[idempotencyKey]
);
try {
// 3. Process the payment
const { orderId, amount } = await request.json();
const session = await getAuthSession();
if (!session) {
await clearIdempotencyKey(idempotencyKey);
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payment = await processStripePayment({ orderId, amount, userId: session.user.id });
const result = { success: true, paymentId: payment.id, status: payment.status };
// 4. Store the result and mark as complete
await db.query(
`UPDATE idempotency_store
SET status = 'completed', result = $2
WHERE key = $1`,
[idempotencyKey, JSON.stringify(result)]
);
return NextResponse.json(result);
} catch (error) {
// 5. On error, clear the key so the client can retry
await clearIdempotencyKey(idempotencyKey);
throw error;
}
}
async function clearIdempotencyKey(key: string) {
await db.query('DELETE FROM idempotency_store WHERE key = $1', [key]);
}The Idempotency Store Table
CREATE TABLE idempotency_store (
key TEXT PRIMARY KEY,
status TEXT NOT NULL CHECK (status IN ('processing', 'completed', 'failed')),
result JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
);
-- Index for expiry cleanup
CREATE INDEX idx_idempotency_expires ON idempotency_store (expires_at);
-- Cleanup job: delete expired keys daily
-- Add this as a cron job or scheduled task:
-- DELETE FROM idempotency_store WHERE expires_at < NOW();Database-Level Idempotency with Unique Constraints
For database inserts, idempotency can be enforced at the schema level:
-- Prevent duplicate orders for the same user and session
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
idempotency_key TEXT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Unique constraint prevents duplicate inserts
UNIQUE (user_id, idempotency_key)
);// Use INSERT ... ON CONFLICT DO NOTHING for database-level idempotency
const result = await db.query(
`INSERT INTO orders (user_id, idempotency_key, amount, status)
VALUES ($1, $2, $3, 'pending')
ON CONFLICT (user_id, idempotency_key) DO UPDATE
SET updated_at = NOW()
RETURNING *`,
[userId, idempotencyKey, amount]
);
// Whether it's the first or 100th request, you get the same order back
Stripe's Built-In Idempotency
When using Stripe, pass the idempotency key directly to the SDK:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const paymentIntent = await stripe.paymentIntents.create(
{
amount: 2000,
currency: 'usd',
customer: stripeCustomerId,
},
{
idempotencyKey: `payment-intent-${orderId}-${userId}`,
}
);
// Stripe guarantees: same idempotency key = same PaymentIntent, no duplicate charge
Conclusion
Idempotency is the engineering discipline that makes distributed systems safe under the conditions that are guaranteed to occur in production: network timeouts, client retries, double-submits, and infrastructure restarts. The pattern is always the same: client generates a unique key before the first attempt, sends it with every retry, server uses the key to detect and suppress duplicates. For anything with a real-world side effect — payments, emails, order creation, account changes — idempotency is not a feature. It is a correctness requirement.