Pro LessonLesson 02 of 08

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.

~15 min readNext.js + Node.jsStripe CLI required

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 stripe npm package installed
1

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.

app/api/webhooks/stripe/route.tstypescript
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.

2

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.

3

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.

terminalbash
# 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/stripe

When you run stripe listen, it prints a webhook signing secret:

terminal outputtext
> Ready! Your webhook signing secret is whsec_1234abc... (^C to quit)

Copy that whsec_ value and add it to your environment:

.env.localenv
STRIPE_SECRET_KEY=sk_test_your_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_signing_secret_here

The 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.

4

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 important

Fires 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.

app/api/webhooks/stripe/route.ts (excerpt)typescript
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.paidSubscriptions

Fires every time a subscription invoice is paid — both on first signup and every renewal. Use this to extend the customer's access period.

app/api/webhooks/stripe/route.ts (excerpt)typescript
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.
5

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:

terminal (tab 2)bash
# Trigger a checkout.session.completed event
stripe trigger checkout.session.completed

# Trigger an invoice.paid event
stripe trigger invoice.paid

Check the terminal where stripe listen is running — you should see:

stripe listen outputtext
2026-03-17 10:15:32  --> checkout.session.completed [evt_1abc...]
2026-03-17 10:15:32  <-- [200] POST http://localhost:3000/api/webhooks/stripe

And 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.

6

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.

  1. 1Go to Stripe Dashboard → Developers → Webhooks
  2. 2Click “Add endpoint” and enter your production URL: https://yourapp.com/api/webhooks/stripe
  3. 3Select the events to listen for: checkout.session.completed and invoice.paid
  4. 4Copy the Signing secret and add it as STRIPE_WEBHOOK_SECRET in 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