← All guides

Stripe Webhook Signature Verification in Next.js (App Router)

Updated 2026-10-14

Stripe webhooks are how your app learns that a payment actually happened. If your webhook endpoint doesn't verify Stripe's signature, anyone who finds the URL can send a fake checkout.session.completed event and give themselves a paid plan for free.

This guide shows the correct pattern for the Next.js App Router, explains the mistakes that commonly show up in AI-generated code, and covers what to do after verification.

What signature verification does

When Stripe sends an event, it includes a Stripe-Signature header. The header contains a timestamp and an HMAC-SHA256 signature computed over the timestamp and the exact raw request body, using your endpoint's signing secret (whsec_...).

Your server recomputes that signature with the same secret. If they match, the request came from Stripe and wasn't modified. The timestamp check (5 minutes by default in Stripe's libraries) also limits replay attacks.

The correct App Router handler

// app/api/stripe/webhook/route.ts
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(req: Request) {
  const signature = req.headers.get("stripe-signature");
  if (!signature) return new Response("Missing signature", { status: 400 });

  const body = await req.text(); // raw body, NOT req.json()

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, endpointSecret);
  } catch (err) {
    console.error("Webhook signature verification failed", err);
    return new Response("Invalid signature", { status: 400 });
  }

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      if (session.payment_status === "paid") {
        await fulfillOrder(session.id);
      }
      break;
    }
    case "customer.subscription.deleted": {
      const sub = event.data.object as Stripe.Subscription;
      await cancelAccess(sub.id);
      break;
    }
  }

  return new Response("ok", { status: 200 });
}

A few details that matter:

Pages Router version

If your project uses pages/api, Next.js parses the body for you by default, which breaks verification. Turn the parser off and read the raw stream:

// pages/api/stripe/webhook.ts
import type { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe";

export const config = { api: { bodyParser: false } };
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

async function rawBody(req: NextApiRequest): Promise<Buffer> {
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
  return Buffer.concat(chunks);
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== "POST") return res.status(405).end();
  try {
    const event = stripe.webhooks.constructEvent(
      await rawBody(req),
      req.headers["stripe-signature"] as string,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
    // handle event...
    res.status(200).send("ok");
  } catch {
    res.status(400).send("Invalid signature");
  }
}

Mistakes we see in AI-generated webhook code

1. No verification at all. The handler does const event = await req.json() and trusts event.type. This is the most serious version: the endpoint will accept anything.

2. Verification wrapped in a try/catch that continues anyway. Code like this looks safe but isn't:

let event;
try {
  event = stripe.webhooks.constructEvent(body, sig, secret);
} catch {
  event = JSON.parse(body); // ❌ falls back to the unverified payload
}

If verification fails, return a 400 and stop.

3. Fulfilling from the success page. Stripe redirects the buyer to your success_url after payment, but anyone can visit that URL. Never mark an order paid because someone loaded /success?session_id=.... If you want instant feedback on that page, retrieve the session server-side with your secret key and check payment_status, but treat the webhook as the source of truth.

4. Trusting amounts or plan names from the client. Create Checkout Sessions with server-side Price IDs. Don't accept a price or plan from the request body without validating it against your own list.

5. Missing or empty secret in production. If STRIPE_WEBHOOK_SECRET is undefined, some code paths silently skip the check. Fail loudly at startup if it's missing.

After verification: make fulfillment idempotent

Stripe may deliver the same event more than once, and doesn't guarantee order. Make your handler safe to run twice:

// Record each event id once; skip if we've already seen it.
const inserted = await db.query(
  "insert into stripe_events (id) values ($1) on conflict (id) do nothing returning id",
  [event.id],
);
if (inserted.rowCount === 0) return new Response("duplicate", { status: 200 });

Also:

Quick checklist

Let a scanner catch it

PreflightPR includes a check for Stripe webhook handlers that don't call constructEvent, alongside leaked keys, Supabase RLS gaps and unprotected API routes. On our deliberately vulnerable sample app it flagged 16 issues (12 critical) and its fix PR resolved 8 automatically.

Scan a public repo for free to see whether your webhook and the rest of your launch basics pass. It's an automated best-effort review, not a penetration test.

Check your repo in about a minute

Paste a public GitHub URL and get a free launch score.

Run a free scan

More guides