Stripe Webhooks in Next.js: Complete Guide (2026)

05 May 2026 · Bank K.

Set up Stripe webhooks in a Next.js App Router project. Signature verification, raw body handling, and event handlers with working code.

You created a Stripe Checkout Session, the user paid, and now you need to know about it. The browser redirect is unreliable — users close tabs, lose connection, or refuse to wait for the redirect. Webhooks are how Stripe tells your server “this thing actually happened” regardless of what the browser does.

This guide covers setting up a Stripe webhook endpoint in a Next.js App Router project, verifying signatures correctly, and handling the events you actually care about. Every code example works with Next.js 15+ and the current Stripe SDK.

If you have not set up Stripe Checkout in Next.js yet, start there first. Webhooks complement the checkout flow — they don’t replace it.

Why You Need Webhooks (Even With success_url)

After Checkout, Stripe redirects the user to your success_url. Tempting to mark the order as paid right there. Don’t.

Three reasons:

  1. The user might never land on success_url. They close the tab, lose connection, or get distracted. Payment still goes through. Order never gets fulfilled.
  2. success_url runs in the browser. Anyone can open that URL with their own session ID and trigger your “fulfill order” code.
  3. Subscriptions, refunds, disputes, and failed renewals never trigger success_url. You need a separate channel for those.

Webhooks solve all three. Stripe sends a signed POST request to your server every time something happens. Your server verifies the signature, checks the event type, and reacts.

What You Need

  • A Next.js 15+ project with the App Router
  • Stripe account and the stripe npm package installed
  • Your secret key (sk_test_...) from the Dashboard
  • The Stripe CLI for local testing (brew install stripe/stripe-cli/stripe on Mac)

Step 1: Create the Webhook Route

In the App Router, webhook handlers live as Route Handlers. Create the file:

mkdir -p app/api/webhooks/stripe
touch app/api/webhooks/stripe/route.ts

Here’s the minimum viable handler:

// app/api/webhooks/stripe/route.ts
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 signature = req.headers.get("stripe-signature");

  if (!signature) {
    return NextResponse.json({ error: "No signature" }, { status: 400 });
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    return NextResponse.json(
      { error: `Signature verification failed: ${message}` },
      { status: 400 }
    );
  }

  // Handle the event (next section)
  console.log(`Received event: ${event.type}`);

  return NextResponse.json({ received: true });
}

The critical part is await req.text(). Stripe signs the raw request body, byte-for-byte. If you call req.json() instead, Next.js parses the JSON and re-serializes it — the bytes change, and the signature check fails.

This was the #1 source of bugs in the Pages Router (where you had to disable bodyParser config). The App Router fixes this by giving you req.text() directly, but you still have to use it — not req.json().

Step 2: Set Environment Variables

Create .env.local:

STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

You’ll get the STRIPE_WEBHOOK_SECRET value in Step 4 (local) or when you create a real endpoint in Step 6 (production). They are different values for testing vs production — don’t mix them.

Step 3: Handle the Event Types You Care About

Most apps care about a small set of events. Here’s a reasonable starter:

switch (event.type) {
  case "checkout.session.completed": {
    const session = event.data.object as Stripe.Checkout.Session;
    await fulfillOrder(session);
    break;
  }

  case "customer.subscription.created":
  case "customer.subscription.updated": {
    const subscription = event.data.object as Stripe.Subscription;
    await syncSubscription(subscription);
    break;
  }

  case "customer.subscription.deleted": {
    const subscription = event.data.object as Stripe.Subscription;
    await cancelSubscription(subscription);
    break;
  }

  case "invoice.payment_failed": {
    const invoice = event.data.object as Stripe.Invoice;
    await notifyPaymentFailure(invoice);
    break;
  }

  default:
    // Lots of events fire. Log them but don't error.
    console.log(`Unhandled event type: ${event.type}`);
}

A few notes on the events:

  • checkout.session.completed fires once when checkout succeeds. Use this to mark the order as paid and grant access. Read the session’s metadata to know which user the order belongs to (you set this when creating the session).
  • customer.subscription.created/updated fire whenever a subscription changes. Use them to sync your database with the subscription state — status, current period end, cancel-at-period-end flag.
  • invoice.payment_failed fires when a recurring charge fails. Send an email so the user can update their card before they get downgraded.

Step 4: Test It Locally With the Stripe CLI

Webhooks need a public URL. The Stripe CLI gives you one for development:

# Login to your Stripe account
stripe login

# Forward webhook events to your local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe

The CLI prints a webhook signing secret (whsec_...) when it starts. Copy that into .env.local as STRIPE_WEBHOOK_SECRET. This is the secret for local testing only — production gets a different one.

Trigger a test event:

stripe trigger checkout.session.completed

You should see the event in your Next.js terminal. If you get a 400 error, the signing secret is wrong or you’re parsing the body before verification.

Step 5: Make Your Handlers Idempotent

Stripe retries webhooks if your endpoint times out or returns a 5xx. That means the same event can arrive multiple times. If your handler grants 30 days of access every time it fires, retries become a free-access bug.

Two ways to handle this:

Option A: Track processed event IDs. Save event.id in a table after each successful handler run. Check for it at the top of the handler:

const alreadyProcessed = await db.webhookEvents.findUnique({
  where: { id: event.id },
});

if (alreadyProcessed) {
  return NextResponse.json({ received: true });
}

// ...handle event...

await db.webhookEvents.create({ data: { id: event.id, type: event.type } });

Option B: Make the operations idempotent. Use upserts instead of inserts. Set the subscription state to “active” rather than incrementing a counter. If the state is already correct, the second call is a no-op.

Option B is simpler when possible. Option A is bulletproof but adds a database row per event.

Step 6: Deploy and Register the Production Endpoint

Deploy your app. Then in the Stripe Dashboard, go to Developers → Webhooks → Add endpoint:

  • URL: https://yourdomain.com/api/webhooks/stripe
  • Events to send: select only the events you actually handle (the ones from Step 3)

Stripe shows a signing secret on the endpoint detail page. Copy that and add it to your production environment as STRIPE_WEBHOOK_SECRET. This value is different from the CLI’s local secret. Don’t reuse the local one.

Listening to “all events” is tempting but wasteful — Stripe will hammer your endpoint with notifications you don’t care about. Subscribe only to what you handle.

Step 7: Avoid the Common Pitfalls

A few things that catch people:

  • Long-running handlers cause retries. If fulfilling an order takes 30 seconds (sending email, generating a PDF, calling another API), Stripe will time out and retry. Either return a 200 immediately and queue the work, or make the work fast.
  • Edge runtime doesn’t work for verification. Stripe’s constructEvent uses Node’s crypto module. Make sure your route runs on the Node.js runtime (the default for App Router route handlers — don’t add export const runtime = "edge").
  • Vercel function timeout. Webhook handlers run as serverless functions. Heavy work needs a queue (Inngest, Trigger.dev, or just a database row + background worker).
  • Don’t trust client-side data. The webhook is the source of truth. If your success_url page also marks the order paid, you have a race condition. Pick one.

Building This From Scratch Is a Lot

Stripe webhooks are not hard, but doing them right requires:

  • Raw body handling
  • Signature verification
  • Idempotency
  • Event-type dispatch
  • Subscription state syncing
  • Database schema for both
  • Failed-payment recovery flows

Beag.io handles all of this for you. You drop in our SDK, point Stripe at our webhook URL, and we sync everything to your database — subscriptions, payment status, customer records. Your code only needs to read state, not manage it. Free tier covers MVP volume.

If you’d rather build it yourself, the code above is a working starting point.

FAQ

Do I need a webhook if I only do one-time payments?

Yes. Even for one-time payments, the user might close the tab before redirect. The webhook is the only reliable way to know payment succeeded. The redirect is for UX (showing a thank-you page); the webhook is for state changes.

Can I use the same webhook endpoint for test mode and live mode?

You can use the same URL, but you need two separate endpoints in the Stripe Dashboard — one in test mode, one in live mode. Each has its own signing secret. Use environment-specific env vars to load the right one.

What’s the difference between checkout.session.completed and payment_intent.succeeded?

checkout.session.completed fires when the Checkout flow finishes successfully — for both one-time and subscription purchases. payment_intent.succeeded fires for any successful payment, including ones not made through Checkout. If you’re only using Checkout, the session event is what you want. It includes the customer_email, metadata, and line items in one payload.

Why is my signature verification failing in production but not locally?

Two common reasons. First, you’re using the wrong signing secret — the CLI gives you a local-only one, and the Dashboard endpoint has a different one. Second, something is modifying the request body before your handler sees it (a proxy, middleware, or req.json() call). The body must be byte-identical to what Stripe sent.

How do I handle webhook events that arrive out of order?

Stripe doesn’t guarantee delivery order. A subscription can be updated and then the “created” event arrives after. Use the event.created timestamp to detect this and skip stale updates, or always re-fetch the resource from Stripe instead of trusting the event payload (stripe.subscriptions.retrieve(id)).

Should I use stripe.events.list() to backfill missed webhooks?

Yes — if you ever suspect events were missed (your server was down, a deploy failed mid-handler), you can iterate through stripe.events.list({ created: { gte: timestamp } }) and replay them. Idempotent handlers make this safe.

About the Author
Bank K.

Bank K.

Serial entrepreneur & Co-founder of Beag.io

Founder of Beag.io. Indie hacker building tools to help developers ship faster.

Ready to Make Money From Your SaaS?

Turn your SaaS into cash with Beag.io. Get started now!

Start 7-day free trial →