Here is what failure looks like. Someone signs up for your free tier, opens DevTools, and notices that your checkout flow involves an endpoint at /api/webhooks/stripe — or they simply guess it, because that is the path every tutorial uses. They copy the shape of a checkout.session.completed event from Stripe's public documentation, fill in their own user ID in the metadata, and POST it to your endpoint. If your handler does not verify the signature, it processes that request exactly as if Stripe had sent it. Database row updated, premium flag set, product delivered. No payment ever happened.
This works because a webhook is just an HTTP request arriving over the public internet. HTTPS does not save you here: TLS proves that nobody tampered with the request in transit, but it says nothing about who sent it. A request from an attacker's laptop and a request from Stripe's servers look identical to your route handler. The body is plain JSON in a documented format that anyone can construct in thirty seconds.
The signature header is the one thing in the entire request an attacker cannot forge, because computing it requires a secret only you and Stripe hold. That makes the verification block the highest-stakes fifty lines in your application — more sensitive than your login form, because failed logins get noticed and forged webhooks usually do not. If Stripe is not wired into your app yet, start with the full Stripe and Next.js setup and come back; everything below assumes checkout already works.
Every webhook request carries a Stripe-Signature header that looks roughly like t=1718011625,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd. Two parts matter. The t value is a Unix timestamp of when Stripe sent the request. The v1 value is an HMAC-SHA256 signature: a cryptographic fingerprint computed over the timestamp, a dot, and the exact bytes of the request body, keyed with your endpoint's signing secret — the whsec_ value from the dashboard.
HMAC sounds intimidating, but the idea is plain: it is a hash that only someone holding the secret can produce. Your server recomputes the same fingerprint from the body it received and compares it to the one in the header. If they match, two things are proven at once — the sender holds the secret, so it was Stripe, and the body was not modified by even a single byte on the way.
The timestamp exists for replay protection. Without it, an attacker who captured one legitimate request — from a log file, a misconfigured proxy, a compromised dependency — could re-send it forever, and every copy would carry a perfectly valid signature. Because the timestamp sits inside the signed material, it cannot be swapped out, and the official libraries reject any event older than a tolerance window, 300 seconds by default. A captured request goes stale in five minutes.
info
Each webhook endpoint has its own signing secret, and the Stripe CLI generates yet another one for local forwarding. Most 'verification suddenly fails' mysteries turn out to be one of these secrets being used in the wrong environment.
The incident this section prevents: every single delivery to your endpoint fails with 'No signatures found matching the expected signature for payload', in development and production alike, and you spend an afternoon convinced your secret is wrong. It is not. Something parsed the request body before the signature check ran.
The HMAC was computed over the exact bytes Stripe sent. If you call req.json(), you get back a JavaScript object — and any serialization of that object back to a string produces a different sequence of bytes: whitespace normalised, unicode escapes rewritten, number formatting changed. The fingerprint no longer matches, and it never will. The same applies to any middleware, body parser, or proxy that re-encodes the body before your handler sees it.
In the App Router this is easy to get right, because route handlers hand you the raw Request object. Read the body with req.text() and pass the string straight to constructEvent:
import Stripe from "stripe";
import { NextResponse } from "next/server";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const payload = await req.text(); // raw body — never req.json()
const signature = req.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
payload,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
console.error("Webhook signature verification failed", err);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
// handle event.type here
return NextResponse.json({ received: true });
}constructEvent does all of it: recomputes the HMAC, compares it in constant time, checks the timestamp tolerance, and only then parses the JSON into a typed Stripe.Event. If anything fails it throws, and you return 400 — which matters, because rejected deliveries show up in the Stripe dashboard where you can actually see them.
warning
Audit everything that sits between the internet and your handler. A middleware that logs request bodies, a body-size plugin, an API gateway that pretty-prints JSON — any of them silently breaks verification. The diagnostic tell is that every event fails, not some.
The incident: a customer pays once and receives two welcome emails, two license keys, or — worse — two database rows that your billing logic later disagrees about. Nobody attacked you. Stripe delivered the same event twice, exactly as documented.
Stripe retries any delivery that does not get a 2xx response, with exponential backoff, for up to three days in live mode. A retry is triggered by your server returning 500, but also by a timeout, a deploy that briefly killed the route, or a network blip that ate the response after you had already processed the event. From Stripe's side the delivery failed; from your side the work was done. The retry arrives, and your handler does the work again.
Every event carries a globally unique event.id, and that is your dedupe key. The pattern is a processed-events table with the event ID as primary key:
model StripeEvent {
id String @id
type String
createdAt DateTime @default(now())
}import { Prisma } from "@prisma/client";
try {
await prisma.stripeEvent.create({
data: { id: event.id, type: event.type },
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002" // unique violation: already processed
) {
return NextResponse.json({ received: true });
}
throw err;
}
// first delivery — safe to fulfilThe reason this is a database constraint and not an if-check matters. Two retries can arrive concurrently — both would read 'not processed', and both would proceed. The unique constraint turns that race into one success and one catchable failure you acknowledge with a 200. You can add a cheap lookup before the insert as an optimisation, but the constraint is the actual guarantee.
The incident: your fulfilment logic generates a PDF invoice, calls a license-key API, and sends an email — twelve seconds on a slow day. Stripe's delivery times out, gets marked failed, and is retried. Now the same heavy work runs again while the first run is still going, your dashboard fills with failed deliveries, and the load on an already-bad day is multiplied by the retry schedule. People call this a retry storm, and it reliably hits during your busiest hour, because that is when things are slow.
The fix is a hard rule: the webhook handler verifies, records, and acknowledges — nothing else. Get the 200 out within a couple of seconds and run fulfilment after the response. In Next.js, the after() function from next/server exists for exactly this: it schedules work to run once the response has been sent.
import { after } from "next/server";
if (event.type === "checkout.session.completed") {
const session = event.data.object;
after(async () => {
await fulfillOrder(session); // runs after the 200 is sent
});
}
return NextResponse.json({ received: true });For a small SaaS, after() is enough. When fulfilment grows real failure modes of its own — third-party APIs that go down, emails that bounce — move it into a proper job queue with its own retry logic, and let the webhook handler do nothing but verify and enqueue. The handler's job is to be boring.
The incident is quieter here: an endpoint subscribed to all events. Every subscription renewal, every invoice finalisation, every customer update across your whole account hits your route. Your logs are noise, your processed-events table fills with entries nothing reads, and the one event type you actually care about is buried in traffic. Worse, every delivered event is attack surface, and many carry customer data that now sits in your logging pipeline for no reason.
In the dashboard, configure the endpoint to receive only the event types your code handles — for a typical checkout integration that is checkout.session.completed plus a small set of subscription and invoice events. In code, switch on event.type explicitly and acknowledge anything unexpected with a 200. An unhandled type is not an error; it is a configuration-drift signal worth logging once, not a reason to make Stripe retry.
One more boundary, and it is the one people skip: the signature proves Stripe sent the envelope, not that the contents match your assumptions. API versions change payload shapes, metadata fields are loosely-typed strings, and the user ID you stuffed into metadata at checkout time can be missing or malformed. Verify the envelope with constructEvent, then validate the object inside it with a schema — the same discipline covered in validating at trust boundaries with Zod. Two different threats, two different tools.
The classic first hour with webhooks: checkout works on localhost, the dashboard shows the event was sent, but your success page never flips to paid. Of course it does not — Stripe's servers cannot POST to localhost:3000. The event went to a URL that does not exist from the internet's point of view, or to your production endpoint while you were staring at your local database.
The Stripe CLI solves this by opening a tunnel and forwarding events to your machine:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# > Ready! Your webhook signing secret is whsec_3f2a... (^C to quit)
stripe trigger checkout.session.completedNote the secret it prints. It is not the one from your dashboard — the CLI signs forwarded events with its own secret, so STRIPE_WEBHOOK_SECRET in .env.local must hold the CLI's value while each deployed environment holds its own endpoint's secret. stripe trigger then fires realistic test events on demand, which beats clicking through checkout with a test card every time you change a line.
Webhook handling is one line in the broader production-grade code checklist, but it is the line where mistakes convert directly into money. Before you call the integration done:
- Separate secrets per environment: the live endpoint's whsec_ in production, the test endpoint's in staging, the CLI's in .env.local — never shared, never committed.
- Check the webhook delivery view in the Stripe dashboard weekly at first. Failed deliveries stay visible there for days, and a slow accumulation of 400s or timeouts is your earliest warning that something drifted.
- Alert on signature verification failures. One failure is a misconfiguration; a steady trickle from the same source is someone probing your endpoint with forged payloads.
- Log event IDs and types, never full payloads — webhook bodies carry customer emails and payment details that do not belong in your logging pipeline.
- Confirm the route is excluded from any auth middleware. Stripe cannot log in; the signature is the authentication.
If you already have a Stripe integration, open the webhook route now and check four things: the body is read raw with req.text(), constructEvent runs before anything else touches the payload, the event ID is deduplicated against a table with a unique constraint, and the response goes out before the heavy work begins. Each one is ten minutes to fix, and each prevents a different production incident — free products, doubled fulfilment, retry storms.
If you are building your first paid product, this is the part to get right before launch, not after. Nobody ever notices a webhook handler that works. The unverified one gets noticed eventually too — usually by the person who finds it before you do.
