Back to Blog
·9 min
BI

BreafIO Team

Product & Engineering

How to Secure Stripe Webhooks in Next.js App Router (2026)

Introduction

Stripe webhooks are the backbone of SaaS subscription billing. They notify your server when a customer subscribes, cancels, or fails to pay. But getting webhooks right in Next.js App Router is harder than it looks — the most common issue is a 400 error caused by incorrect body parsing. In this guide, we walk through the exact implementation of secure Stripe webhooks in Next.js 14+, including signature verification, event handling, and Prisma database updates.

The Common 400 Error and Why It Happens

The most frequent Stripe webhook error in Next.js is: "Webhook signature verification failed." This happens because Next.js App Router parses the request body as JSON by default, but Stripe requires the raw body buffer for signature verification. When you call req.json(), you destroy the raw bytes that Stripe's verification library needs. The fix is simple but non-obvious: read the body as text before verification.

Step 1: The Webhook Route

Create the webhook route at app/api/webhook/stripe/route.ts:

import { headers } from "next/headers"
import { NextResponse } from "next/server"
import Stripe from "stripe"
import { prisma } from "@/lib/db"

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-12-18.acacia",
})

const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!

export async function POST(req: Request) {
  const body = await req.text()
  const headersList = await headers()
  const signature = headersList.get("stripe-signature")

  if (!signature) {
    return new NextResponse("Missing stripe-signature header", { status: 400 })
  }

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(body, signature, endpointSecret)
  } catch (err: any) {
    console.error("Webhook signature verification failed:", err.message)
    return new NextResponse("Webhook Error: " + err.message, { status: 400 })
  }

  try {
    switch (event.type) {
      case "checkout.session.completed":
        await handleCheckoutComplete(event.data.object)
        break
      case "customer.subscription.updated":
        await handleSubscriptionUpdate(event.data.object)
        break
      case "customer.subscription.deleted":
        await handleSubscriptionDeleted(event.data.object)
        break
      default:
        console.log("Unhandled event type:", event.type)
    }
  } catch (dbError) {
    console.error("Database update failed:", dbError)
    return new NextResponse("Database error", { status: 500 })
  }

  return NextResponse.json({ received: true }, { status: 200 })
}

The critical line is `const body = await req.text()` — this reads the raw text body before any JSON parsing, preserving the bytes needed for signature verification.

Step 2: Handling Events

Each event type needs its own handler function. Here are the three most important:

async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
  const email = session.customer_details?.email
  const customerId = session.customer as string
  if (!email) return

  await prisma.user.update({
    where: { email },
    data: {
      stripeCustomerId: customerId,
      stripePriceId: session.subscription as string,
      isActive: true,
    },
  })
}

async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
  await prisma.user.update({
    where: { stripeCustomerId: subscription.customer as string },
    data: { isActive: false },
  })
}

Step 3: Environment Variables

Add to your .env.local:

STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...

Step 4: Testing Locally with Stripe CLI

Do not push to production blindly. Download the Stripe CLI and forward events locally:

stripe login
stripe listen --forward-to localhost:3000/api/webhook/stripe

The CLI prints a local webhook secret (whsec_...). Use this value in .env.local during development.

Step 5: Production Checklist

Before going live: register the webhook endpoint in the Stripe Dashboard under Developers > Webhooks. Select the events you need: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted. Use the signing secret from the Dashboard (not the CLI secret) in your production environment variables.

Common Mistakes to Avoid

1. Using req.json() instead of req.text() — the number one cause of webhook failures.

2. Not verifying the signature — you must call stripe.webhooks.constructEvent() for every request.

3. Returning non-200 status codes for unhandled events — Stripe retries on non-2xx responses, which can cause duplicate processing.

4. Not using idempotency — if your webhook handler runs twice (Stripe retries), your database updates should be idempotent. Use upsert() or check current state before updating.

Using BreafIO

Wiring up Stripe webhooks correctly takes hours of careful configuration. BreafIO provides a pre-configured, production-hardened webhook handler with signature verification, event routing, and Prisma database updates — all working out of the box. Plug in your environment variables and start processing payments in minutes.

Ready to Build?

Get started with our production-ready starter kits and ship your project faster.

Browse Starter Kits