Stripe Webhook Failures at Scale: Preventing Duplicate Charges, Replay Attacks, and Out-of-Order Events
A battle-tested guide to architecting idempotent Stripe webhook handlers. Stop duplicate fulfillment, handle out-of-order retries, and lock state in PostgreSQL.
Look, I have audited seven production SaaS codebases over the past two quarters, and five of them handled Stripe webhooks with a single, fragile assumption: they assumed a payment webhook arrives exactly once, in sequential order, over a clean network connection.
That assumption works fine in local development with the Stripe CLI. It falls apart in production the first time an edge serverless function experiences a cold start or an upstream ISP hiccups.
Stripe does not guarantee exactly-once delivery. Stripe guarantees at-least-once delivery. If your handler takes 3,100 milliseconds to respond because of a slow database query, Stripe times out at 3,000 milliseconds, queues a retry, and fires the same payload again. If your code lacks strict database-level idempotency locks, your system provisions two subscriptions, credits double credits, or triggers duplicate fulfillment emails.
Here is what happens under the hood when webhook architectures fail, and how to build an atomic, replay-safe pipeline that scales without race conditions.
1. The Anomaly: At-Least-Once Delivery vs Concurrent Race Conditions
Stripe re-attempts failed webhook deliveries with exponential backoff over a 72-hour window. When an endpoint times out or returns HTTP 500, subsequent retries can arrive concurrently with the original delayed execution.
Here is the deterministic cascade that triggers duplicate fulfillment:
1. Stripe dispatches customer.subscription.created -> 2. Next.js serverless route cold starts (2,800ms) -> 3. DB connection acquired late -> 4. Stripe hits 3,000ms timeout -> 5. Stripe dispatches Retry #1 -> 6. Both requests execute simultaneously -> 7. Customer credited twice
What do developers usually do when users report double fulfillment? They blame Stripe or add a naive in-memory flag. In-memory flags are useless in modern serverless and containerized setups because each incoming HTTP request lands in a separate container, edge isolate, or node worker process.
// ❌ CATASTROPHIC RACE CONDITION: Naive check-then-act pattern
export async function POST(req: Request) {
const event = await parseStripeEvent(req);
// <-- This check returns false for BOTH concurrent executions!
const alreadyProcessed = await db.hasProcessed(event.id);
if (alreadyProcessed) {
return new Response('Already handled', { status: 200 });
}
// Window of vulnerability: Both requests reach this line simultaneously
await provisionCustomerCredits(event.data.object.customer);
await db.markAsProcessed(event.id);
return new Response('OK', { status: 200 });
}
Between the moment db.hasProcessed() runs and db.markAsProcessed() commits, a parallel retry executes the exact same query. Both threads see that the event has not been processed yet. Both proceed to credit the user account.
2. The Fix: Atomic PostgreSQL Idempotency Tables
To eliminate duplicate processing under concurrency, leverage relational database constraints. A relational database primary key collision is atomic, immediate, and independent of application server concurrency.
Create a dedicated stripe_processed_events table in PostgreSQL with the Stripe event ID as the primary key:
CREATE TABLE IF NOT EXISTS stripe_processed_events (
event_id VARCHAR(255) PRIMARY KEY,
event_type VARCHAR(100) NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
payload JSONB
);
When your webhook handler receives an event, it attempts to insert the event ID inside an explicit database transaction. If the ID already exists, PostgreSQL rejects the operation with an error code 23505 (unique violation).
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { pool } from '@/lib/db';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
});
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
}
let event: Stripe.Event;
try {
// 1. Cryptographic HMAC validation against unparsed raw body
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: any) {
return NextResponse.json({ error: `Signature failed: ${err.message}` }, { status: 400 });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
// 2. Atomic idempotency reservation
const insertResult = await client.query(
`INSERT INTO stripe_processed_events (event_id, event_type, payload)
VALUES ($1, $2, $3)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id`,
[event.id, event.type, JSON.stringify(event.data.object)]
);
// If no row was inserted, this event was already processed or is currently committing
if (insertResult.rowCount === 0) {
await client.query('ROLLBACK');
// Always return 200 so Stripe stops retrying duplicate deliveries
return NextResponse.json({ received: true, duplicate: true }, { status: 200 });
}
// 3. Execute business logic within the same atomic transaction
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await fulfillOrder(client, session);
break;
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object as Stripe.Invoice;
await recordInvoicePayment(client, invoice);
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
await revokeSubscriptionAccess(client, sub);
break;
}
default:
// Ignore unhandled event types
break;
}
await client.query('COMMIT');
return NextResponse.json({ received: true });
} catch (error) {
await client.query('ROLLBACK');
// Returning 500 tells Stripe to retry if an unexpected internal error occurred
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 });
} finally {
client.release();
}
}
Notice the crucial pattern: if rowCount === 0, we immediately rollback and return an HTTP 200 OK. If you return a 400 or 500, Stripe thinks your endpoint failed and continues hammering your API with retries for up to 72 hours. Returning HTTP 200 tells Stripe: “Received and acknowledged. Do not send this event again.”
3. Webhook Failure Modes vs Architectural Mitigation
Modern payment integrations fail in predictable, reproducible ways. The following benchmark table outlines the most frequent webhook edge cases, their root causes, and the engineering mitigations required for zero-loss billing pipelines:
| Failure Mode | Root Trigger | Observable Symptom | Required Mitigation Pattern |
|---|---|---|---|
| Duplicate Delivery | Network dropped packet or response latency > 3,000ms | Customer billed once but credited twice in database | PostgreSQL INSERT ... ON CONFLICT (event_id) DO NOTHING |
| Out-of-Order Events | Multi-worker concurrent dispatch across distributed queues | invoice.payment_succeeded fails because subscription record does not exist yet | State machine verification & payload upserts using Stripe Object IDs |
| Payload Deserialization Bug | Parsing body via req.json() before signature verification | Cryptographic validation failure (stripe-signature verification failed) | Extract raw body as string or buffer using req.text() before passing to SDK |
| Worker Timeout | Synchronous heavy tasks (PDF generation, third-party emails) | HTTP 504 Gateway Timeout followed by a 72-hour retry storm | Decouple webhook ingress: acknowledge with HTTP 200 in under 200ms, queue tasks to BullMQ/SQS |
| Stale Webhook Secrets | Production deployment rotation without dual-secret grace period | All production payments suddenly report 400 signature errors | Multi-secret signature verification array during credential rollover |
4. The Out-of-Order Delivery Dilemma
Stripe webhooks are dispatched asynchronously across distributed systems. Event arrival order cannot be guaranteed.
Consider this frequent scenario when a user subscribes:
customer.subscription.createdis emitted.invoice.payment_succeededis emitted immediately after.
Because of network routing differences, your webhook server may receive invoice.payment_succeeded three seconds before customer.subscription.created. If your invoice handler executes an SQL query like:
UPDATE subscriptions
SET status = 'active', current_period_end = $1
WHERE stripe_subscription_id = $2;
And the row does not exist yet because the subscription creation event is still in transit, the UPDATE query silently affects zero rows (rowCount: 0). Your application reports the payment as succeeded, but the user’s account remains locked in an expired state.
Here is how senior architects solve out-of-order delivery:
Strategy A: Use Upserts with Natural Keys
Never rely on an entity already existing when processing downstream events. Use PostgreSQL INSERT ... ON CONFLICT (stripe_subscription_id) DO UPDATE:
INSERT INTO user_subscriptions (
user_id,
stripe_customer_id,
stripe_subscription_id,
status,
current_period_end
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (stripe_subscription_id) DO UPDATE SET
status = EXCLUDED.status,
current_period_end = EXCLUDED.current_period_end,
updated_at = NOW();
Strategy B: Timestamp Sequence Guards
Stripe payloads include a unix timestamp attribute: event.created. If you update user records based on status events (such as customer.subscription.updated), verify that the incoming event is newer than the last recorded update:
UPDATE user_subscriptions
SET
status = $1,
last_stripe_event_timestamp = $2
WHERE stripe_subscription_id = $3
AND last_stripe_event_timestamp < $2;
If an older event arrives delayed after a newer update has already committed, the update statement condition fails safely, preserving your latest state.
5. Security & Cryptographic Integrity Rules
Handling payment events requires zero-trust verification. Bypassing cryptographic validation or accepting parsed bodies opens your application to catastrophic replay attacks and forged balance top-ups.
Rule 1: Always Construct from Unparsed Raw Bytes
Never parse the HTTP body as JSON before feeding it to stripe.webhooks.constructEvent(). Most web frameworks (Express, Next.js, Fastify) modify whitespace or re-order object keys during JSON serialization, which corrupts the HMAC SHA-256 signature hash.
In Next.js App Router, always read raw text:
// ✅ CORRECT: Preserves exact byte structure for cryptographic verification
const rawBody = await req.text();
const event = stripe.webhooks.constructEvent(rawBody, signature, secret);
Rule 2: Rotate Secrets with Dual-Signature Support
When updating your Stripe webhook endpoint secret in production, do not overwrite the environment variable instantly. A sudden switch causes in-flight requests to fail. Stripe SDKs allow passing an array or validating against a fallback secret:
function verifyWithFallback(body: string, signature: string, secrets: string[]) {
for (const secret of secrets) {
try {
return stripe.webhooks.constructEvent(body, signature, secret);
} catch {
// Continue to next secret
}
}
throw new Error('All signature verification attempts failed.');
}
The 20-Minute Architectural Checklist
Before launching payment features or onboarding paying enterprise accounts, run through these five production requirements:
- Verify Raw Body Processing: Confirm your framework passes unparsed raw text to
constructEvent(). - Database Primary Key Reservation: Create an
idempotency_keysorstripe_processed_eventstable and reserveevent.idatomically before mutating state. - HTTP 200 on Duplicate Detection: Return HTTP 200 immediately if an event ID is already reserved, terminating the retry cycle.
- Decouple Heavy Workloads: Move email notifications, PDF invoices, and third-party API syncs to background jobs. Keep webhook response latency under 300 milliseconds.
- Upserts for State Transitions: Use
ON CONFLICTclauses so out-of-order payment and subscription events self-heal without human intervention.
Implementing these constraints takes twenty minutes. It saves you dozens of hours of debugging corrupted accounts, issuing manual refunds, and handling urgent customer billing complaints.
Marcus Vance
Principal Systems ArchitectSystems engineer focusing on distributed backends, database concurrency, and application security. Specializes in high-throughput PostgreSQL concurrency, serverless connection pooling, and infrastructure hardening.
Why does Stripe send duplicate webhook events for the same transaction?
How do you prevent duplicate fulfillment when receiving concurrent webhook retries?
Why do Stripe webhooks arrive out of order?
Should you process heavy business logic directly inside the webhook HTTP route?
Have our team inspect your AI-generated codebase.
48-hour turnaround with direct pull requests fixing security and scale bottlenecks.