Handle Stripe Webhooks
Like a Pro
Webhooks are how Stripe tells your app “something happened.” A payment succeeded, an invoice was paid, a subscription was canceled. Without webhooks, you're flying blind. Let's fix that in 15 minutes.
Why webhooks matter
After a customer pays on Stripe Checkout, your app needs to know it happened. You might think “the success page loaded, so we're good” — but that's wrong. The customer could close the tab before the redirect. Their browser could crash. The network could hiccup.
Webhooks are the reliable answer. Stripe sends a POST request directly to your server every time something happens. No browser involved. No user action required.
Never miss a payment
Webhooks fire even if the customer closes their browser mid-redirect.
Real-time updates
Know instantly when a subscription renews, a charge fails, or a dispute is opened.
Server-to-server
No client-side code. No race conditions. Just a POST from Stripe to your API.
Before you start
- ✓A working Stripe Checkout integration — see Lesson 01 if you haven't set this up
- ✓Stripe CLI installed —
brew install stripe/stripe-cli/stripe - ✓Your Stripe secret key and webhook signing secret — we'll generate the signing secret in step 3
- ✓Next.js app (App Router) with the
stripenpm package installed
Create the webhook API route
This is the endpoint Stripe will POST to every time an event fires. We need to read the raw request body (not parsed JSON) because signature verification requires the exact bytes Stripe sent.
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 400 }
);
}
// Handle the event
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
console.log("Payment succeeded:", session.customer_email);
// TODO: fulfill the order, send email, update DB
break;
}
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
console.log("Invoice paid:", invoice.id);
// TODO: extend subscription, update DB
break;
}
default:
console.log("Unhandled event type:", event.type);
}
return NextResponse.json({ received: true });
}That's the full handler — about 40 lines. Let's break down the critical parts:
req.text()Reads the raw body as a string. Do NOT use req.json() — that changes the bytes and breaks signature verification.stripe-signatureA header Stripe attaches to every webhook request. Contains a timestamp and signature.constructEvent()Verifies the signature, checks the timestamp (rejects replays), and parses the event. If this passes, you know it's really from Stripe.switch (event.type)Route each event type to the right handler. Start with the events you need, ignore the rest.You must use req.text() instead of req.json(). Parsing the body as JSON then re-stringifying it changes whitespace and breaks the signature check. This is the #1 webhook bug.
Understand signature verification
Why do we verify signatures? Without it, anyone could POST fake events to your endpoint and trick your app into thinking a payment happened. Here's what Stripe does behind the scenes:
1. Stripe signs every webhook
When Stripe sends an event, it hashes the payload + a timestamp using your webhook signing secret (HMAC-SHA256). The result goes in the stripe-signature header.
2. Your server re-computes the hash
constructEvent() takes the raw body, the header, and your secret — then re-computes the same hash. If it matches, the event is legit. If not, someone tampered with it.
3. Timestamp prevents replay attacks
The signature includes a timestamp. By default, Stripe's SDK rejects events older than 5 minutes. So even if someone captures a valid webhook, they can't replay it later.
Never skip signature verification in production. It's tempting to just JSON.parse(body) and call it done, but that leaves your endpoint wide open to spoofed events.
Set up the Stripe CLI for local testing
In production, Stripe sends webhooks to your deployed URL. But locally, your localhost:3000 isn't accessible from the internet. The Stripe CLI solves this by forwarding events to your local server.
# Log in to the CLI (first time only)
stripe login
# Forward webhook events to your local endpoint
stripe listen --forward-to localhost:3000/api/webhooks/stripeWhen you run stripe listen, it prints a webhook signing secret:
> Ready! Your webhook signing secret is whsec_1234abc... (^C to quit)Copy that whsec_ value and add it to your environment:
STRIPE_SECRET_KEY=sk_test_your_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_signing_secret_hereThe CLI signing secret (whsec_) is different from your dashboard webhook secret. When testing locally, use the CLI secret. In production, use the secret from your Stripe Dashboard → Developers → Webhooks.
Handle the events that matter
Stripe sends dozens of event types, but you only need to handle a few to start. Here are the two most important ones and what to do with them:
checkout.session.completedMost importantFires when a customer successfully completes Checkout. This is where you fulfill the order — grant access, send a welcome email, create a record in your database.
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const email = session.customer_email;
const amount = session.amount_total; // in cents
const sessionId = session.id;
// Example: save to your database
await db.orders.create({
email,
amountCents: amount,
stripeSessionId: sessionId,
status: "paid",
});
// Example: send welcome email
await sendWelcomeEmail(email);
break;
}invoice.paidSubscriptionsFires every time a subscription invoice is paid — both on first signup and every renewal. Use this to extend the customer's access period.
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
const customerId = invoice.customer as string;
const subscriptionId = invoice.subscription as string;
const periodEnd = invoice.lines.data[0]?.period.end;
// Extend access until the end of the billing period
await db.subscriptions.upsert({
stripeCustomerId: customerId,
stripeSubscriptionId: subscriptionId,
activeUntil: new Date(periodEnd * 1000),
status: "active",
});
break;
}Other events you might add later:
invoice.payment_failedA subscription renewal failed. Email the customer to update their card.customer.subscription.deletedSubscription was canceled. Revoke access at the end of the billing period.charge.dispute.createdA customer opened a chargeback. You'll want to know about this immediately.Test it end-to-end
With your dev server and the Stripe CLI both running, let's trigger some test events. Open a second terminal:
# Trigger a checkout.session.completed event
stripe trigger checkout.session.completed
# Trigger an invoice.paid event
stripe trigger invoice.paidCheck the terminal where stripe listen is running — you should see:
2026-03-17 10:15:32 --> checkout.session.completed [evt_1abc...]
2026-03-17 10:15:32 <-- [200] POST http://localhost:3000/api/webhooks/stripeAnd in your Next.js terminal, your console.log output confirms the event was processed.
You can also do a full end-to-end test: complete a Checkout payment with the test card 4242 4242 4242 4242 and watch the webhook arrive in real time.
Make sure stripe listen is running before you trigger the event. If it's not running, the event goes nowhere — Stripe doesn't retry for CLI-forwarded events.
Deploy to production
When you're ready to go live, register your webhook endpoint in the Stripe Dashboard so Stripe sends real events to your deployed app.
- 1Go to Stripe Dashboard → Developers → Webhooks
- 2Click “Add endpoint” and enter your production URL:
https://yourapp.com/api/webhooks/stripe - 3Select the events to listen for:
checkout.session.completedandinvoice.paid - 4Copy the Signing secret and add it as
STRIPE_WEBHOOK_SECRETin your production environment variables
The production signing secret is different from the CLI's whsec_ secret. Make sure your production environment has the dashboard secret, not the CLI one.
Common gotchas
“No signatures found matching the expected signature”
You're using the wrong webhook secret. Locally, use the whsec_ from stripe listen. In production, use the one from the Stripe Dashboard. They're different values.
Webhook returns 400 but code looks right
Check if a middleware or body parser is consuming the body before your route handler runs. req.text() must be the first thing that reads the body. If you're using middleware that calls req.json(), the raw body is gone.
Duplicate events
Stripe may send the same event more than once (network retries). Make your handlers idempotent — check if you've already processed an event by storing event.id in your database and skipping duplicates.
Webhook times out (Stripe shows 504)
Stripe gives you 20 seconds to respond. If your handler does heavy processing (sending emails, big DB writes), do the minimum work synchronously and push the rest to a background queue. Always return 200 quickly.
Ship-it checklist
Before you push your webhook handler to production:
What you just built
In about 15 minutes and ~40 lines of code, you added:
- ✓A secure webhook endpoint with signature verification
- ✓Handlers for the two most important Stripe events
- ✓Local testing with the Stripe CLI
- ✓A production deployment checklist for going live
Your app no longer relies on redirects to know when a payment happened. Webhooks give you a reliable, server-to-server confirmation for every event — the foundation of any serious Stripe integration.
Next up: Build a pricing page that converts
Design and wire up a pricing page with plan toggles, feature comparison, and one-click checkout. Same speed — 15 minutes.
Unlock all lessons — $14.50/mo→Cancel anytime · Instant access to all 8 lessons