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:
await req.text()gives you the body exactly as Stripe sent it. If you callreq.json()and thenJSON.stringifythe result, whitespace and key order can change and the signature will never match.- Route handlers run on the Node.js runtime by default, which the
stripepackage supports. If you run on an edge runtime (Cloudflare, Vercel Edge), useawait stripe.webhooks.constructEventAsync(body, signature, endpointSecret)instead, since edge runtimes only offer async Web Crypto. - The signing secret is per endpoint. The
whsec_fromstripe listenon your laptop is different from the one for your production endpoint in the dashboard. A common launch-day bug is deploying with the CLI's secret.
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:
- Return 2xx quickly. If your handler takes too long or errors, Stripe retries with backoff for up to three days. Do slow work (emails, PDF generation) in a background job.
- Subscribe only to the events you handle in the dashboard's webhook settings. Fewer events means less surface area and less noise.
- Use test mode end to end with
stripe listen --forward-to localhost:3000/api/stripe/webhookandstripe trigger checkout.session.completedbefore going live.
Quick checklist
- ☐ Raw body passed to
constructEvent(req.text()orbodyParser: false) - ☐ Production
whsec_secret set for the production endpoint - ☐ Invalid signature returns 400 and stops
- ☐ Orders marked paid only from verified webhook events
- ☐ Event IDs stored for idempotency
- ☐ Prices chosen server-side
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.