Handling Stripe Webhooks Reliably
I learned quickly that Stripe webhooks are not background noise around payments they are the source of truth for everything that happens after checkout.
In a production ecommerce platform, the difference between a clean order flow and a quietly broken one often comes down to whether webhook handling is strict, idempotent, and boring.
Stripe's documentation is very clear about the fundamentals:
- Verify signatures
- Return a fast 2xx response
- Expect retries
- Expect duplicate deliveries
Why Webhooks Matter
Checkout does not end when the customer clicks Pay.
The browser can close.
The network can fail.
The redirect can stall.
A delayed payment method can settle long after the user leaves the page.
That is why I treat webhooks as the durable signal that updates order state, sends fulfillment jobs, and reconciles payment records inside my database.
I never trust the client to tell me a payment succeeded.
The client can show a success page, but only the webhook should move the system of record from:
pending -> paid
pending -> failedStripe explicitly positions webhooks as the mechanism for:
- Fulfillment
- Receipts
- Database updates
- Post-payment workflows
Signature Verification
Skipping signature verification creates a real security hole.
Stripe recommends verifying every event using:
- The
Stripe-Signatureheader - The raw request body
- The webhook endpoint secret
Without verification, anyone could send a fake POST request and trick your backend into marking orders as paid.
The raw-body requirement catches many developers.
Stripe verifies the exact payload it originally sent.
If the request body is parsed and reserialized before verification, whitespace and ordering can change, causing signature validation to fail.
Next.js Route Handler
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-07-30.basil",
});
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
const signature = headers().get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
const body = await req.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, endpointSecret);
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
await handleStripeEvent(event);
return NextResponse.json({
received: true,
});
}This is the shape I want in production:
- Raw body
- Signature header
- Endpoint secret
- Verified event before any business logic executes
One easy mistake is mixing the webhook secret from the Stripe Dashboard with the one generated by the Stripe CLI during local development.
They are different values.
Idempotency and Duplicate Deliveries
Stripe webhooks can arrive more than once.
That is not an edge case.
It is part of the delivery model.
Stripe automatically retries failed deliveries for up to three days.
Because of that, I never ask:
Did I receive this event?
Instead I ask:
Have I already processed this event ID?
The event ID becomes the natural deduplication key.
Idempotent Event Log
async function handleStripeEvent(event: Stripe.Event) {
const alreadyProcessed = await db.webhookEvent.findUnique({
where: {
stripeEventId: event.id,
},
});
if (alreadyProcessed) {
return;
}
await db.webhookEvent.create({
data: {
stripeEventId: event.id,
type: event.type,
processedAt: new Date(),
},
});
await processEvent(event);
}I also make side effects idempotent.
Without that safeguard, retries can create:
- Duplicate orders
- Duplicate emails
- Duplicate inventory updates
- Duplicate shipment requests
If an event was already processed, the handler should safely no-op and return success.
Which Events Actually Matter?
Most ecommerce systems do not need every Stripe event.
I usually start with:
checkout.session.completedcheckout.session.async_payment_succeededcheckout.session.async_payment_failedpayment_intent.succeededpayment_intent.payment_failedcharge.refundedcharge.dispute.created
These events cover most post-checkout workflows without introducing unnecessary complexity.
For a typical one-time purchase flow:
checkout.session.completed
Marks checkout as completed and starts order creation.
payment_intent.succeeded
Confirms the payment succeeded.
payment_intent.payment_failed
Marks payment as failed.
charge.refunded
Updates refund state.
charge.dispute.created
Flags the order for manual review.
If subscriptions enter the picture, the event list changes significantly.
Only subscribe to events your business model actually needs.
Retry Behavior
Stripe retries failed webhook deliveries automatically.
That means the webhook endpoint must satisfy two requirements:
- Respond quickly.
- Move expensive work elsewhere.
The most common mistake is doing everything synchronously:
- Database updates
- Email delivery
- Invoice generation
- Shipping integrations
- Analytics tracking
One slow dependency triggers retries.
Retries replay already-completed work.
The fix is simple:
- Record the event.
- Queue downstream work.
- Return a success response.
Production-Safe Handler
async function processEvent(event: Stripe.Event) {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await orders.markCheckoutComplete(
session.id,
session.payment_intent as string,
);
await queue.publish("order.paid", {
orderId: session.metadata?.orderId,
stripeEventId: event.id,
});
break;
}
case "payment_intent.payment_failed": {
const intent = event.data.object as Stripe.PaymentIntent;
await payments.markFailed(
intent.id,
intent.last_payment_error?.message ?? null,
);
break;
}
case "charge.refunded": {
const charge = event.data.object as Stripe.Charge;
await refunds.recordRefund(charge.id);
break;
}
default:
return;
}
}I keep webhook handlers intentionally narrow.
Their job is to translate Stripe events into domain events or state transitions.
Nothing more.
Common Failure Modes
Trusting the Redirect
The browser redirect is a UX signal.
It is not a payment guarantee.
Users can close tabs before the redirect completes.
The webhook remains the source of truth.
Using the Wrong Body Parser
If the framework parses the request body before verification, Stripe signature checks fail.
Always verify against the raw payload.
Doing Too Much Synchronously
Slow webhook responses trigger retries.
Retries increase duplicate processing risk.
Acknowledge first.
Process asynchronously.
Not Storing Processed Event IDs
Without deduplication:
- Orders duplicate
- Emails duplicate
- Shipments duplicate
- Subscription changes duplicate
These bugs are expensive because they appear randomly and are difficult to reproduce.
What I Store
I typically persist three layers of state:
- Stripe event ID and type
- Internal payment or order record
- Processed marker
That gives me:
- An audit trail
- Safe replay capability
- Reliable deduplication
If I ever need to replay missed events, the event log makes the process safe and predictable.
Final Takeaways
Stripe webhooks are only reliable when treated like infrastructure rather than glue code.
My rules are simple:
- Verify every signature
- Use the raw request body
- Process events idempotently
- Keep handlers small
- Subscribe only to relevant event types
- Move long-running work into queues
If those fundamentals are in place, payment state remains correct even when browsers, networks, and Stripe retries become messy.