Stripe Webhooks Done Right: Idempotency, Retries, and Failure Handling
Stripe webhook handling looks simple. You receive an event, update your database, return a 200. Done.
Except Stripe retries failed webhooks for up to 72 hours with exponential backoff. Your server restarts. Your database transaction times out mid-write. A downstream API you call takes 35 seconds and Stripe times out your handler at 30. Your handler processes the event successfully but your 200 response gets lost in a network blip.
All of these result in Stripe re-delivering the same event. If your handler is not idempotent — safe to run multiple times with the same input — every retry is a potential disaster.
Step 0: Always verify the signature first
Before doing anything else, verify the Stripe signature. This prevents replay attacks and ensures you are processing real Stripe events.
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text(); // raw body, not parsed
const sig = req.headers.get("stripe-signature");
if (!sig) {
return new Response("Missing signature", { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return new Response(`Webhook signature verification failed`, { status: 400 });
}
// Now safe to process
await processEvent(event);
return new Response("ok", { status: 200 });
}
The constructEvent call will throw if the signature does not match. Catch it and return 400 — do not let it propagate.
Critical: use the raw request body for signature verification, not a parsed JSON body. Any parsing — even pretty-printing — changes the bytes and breaks the signature.
Step 1: Idempotency using the event ID
Stripe's event IDs are stable. The same event, re-delivered, has the same ID. Use it as your idempotency key.
async function processEvent(event: Stripe.Event) {
// Check if already processed
const existing = await db.query(
"SELECT id FROM processed_webhook_events WHERE stripe_event_id = $1",
[event.id]
);
if (existing.rows.length > 0) {
// Already processed — safe to return 200 immediately
return;
}
// Process in a transaction: record + handle atomically
await db.transaction(async (tx) => {
// Record first, handle second
await tx.query(
"INSERT INTO processed_webhook_events (stripe_event_id, created_at) VALUES ($1, NOW())",
[event.id]
);
await handleEvent(tx, event);
});
}
The transaction is key. Recording and handling happen atomically. If the database transaction rolls back, neither the record nor the side effects survive. The next retry will process the event cleanly.
Step 2: Handle each event type defensively
Map event types to specific handlers. Ignore events you do not handle explicitly — do not fall through to a default path that does something unexpected.
async function handleEvent(tx: Transaction, event: Stripe.Event) {
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutCompleted(tx, event.data.object as Stripe.Checkout.Session);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdated(tx, event.data.object as Stripe.Subscription);
break;
case "invoice.payment_failed":
await handlePaymentFailed(tx, event.data.object as Stripe.Invoice);
break;
default:
// Explicitly ignore unknown events
// Do NOT throw here — that would cause a retry
break;
}
}
Never throw on unknown event types. A thrown exception causes Stripe to retry. An unknown event type is not an error — it is an event you chose not to handle.
Step 3: Return 200 fast, process async
Your webhook handler has a 30-second timeout. For anything that might take longer — sending emails, calling external APIs, updating multiple systems — enqueue the work and return 200 immediately.
export async function POST(req: Request) {
// ... signature verification ...
// Enqueue for async processing
await queue.push({
type: "stripe_webhook",
payload: event,
eventId: event.id,
});
// Return 200 immediately — Stripe is satisfied
return new Response("ok", { status: 200 });
}
The queue worker handles the actual processing, with its own retry logic and dead letter queue. Stripe and your processing pipeline are now decoupled.
Step 4: Dead letter queue for unrecoverable failures
Some events will fail processing repeatedly — the customer record doesn't exist, the external service is down for hours, a data validation error. You need a place for these to land so you can investigate without Stripe eventually giving up on delivery.
async function processQueuedEvent(job: WebhookJob) {
try {
await processEvent(job.payload);
await queue.complete(job.id);
} catch (err) {
job.attempts += 1;
if (job.attempts >= MAX_ATTEMPTS) {
// Move to dead letter queue
await dlq.push({ ...job, error: String(err), failedAt: new Date() });
await queue.remove(job.id);
await alerting.send(`Webhook DLQ: ${job.payload.type} ${job.eventId}`);
} else {
// Retry with backoff
await queue.reschedule(job.id, backoffMs(job.attempts));
}
}
}
The dead letter queue is not a failure state. It is a signal: something in this event requires manual review. Every DLQ entry should trigger an alert and have a clear path to resolution.
Step 5: Test with the Stripe CLI
Do not test webhook handling with curl. Use the Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed
The CLI re-delivers events, which means you can test your idempotency handling without writing special test code. Trigger the same event twice and verify your handler processes it once.
For integration tests, use Stripe test mode with real test webhooks — not mocked events. The event shape, nested objects, and metadata can differ from what you expect when mocking, and those differences surface in production.
Stripe webhook handling is one of those things that works fine until it does not. The patterns here — signature verification, event-ID idempotency, async processing, dead letter queues — are the difference between a payment system that holds up under load and one that requires manual intervention every time Stripe has a retry storm.
If you are building a payment system with Stripe and want someone who has shipped these patterns across multiple production systems, I'm available on Upwork.
Waqas Raza
AI-Native Full-Stack Engineer. Top Rated on Upwork · $180K+ earned · 93% job success. I build production AI agents, LLM systems, Web3 platforms, and full-stack applications.
Hire me on Upwork