Skip to content
Chapter 63Lesson 1

Verify before parse

Build the Stripe webhook route as a trust boundary that verifies the HMAC signature on the raw body before trusting the event.

In this lesson you’ll add one file: app/api/webhooks/stripe/route.ts. Once it ships, Stripe sends it a POST every time a customer’s money moves: a checkout completes, a subscription renews, a card gets declined. That route is how billing reality reaches your database.

But the URL is public. The request carries no session cookie and no bearer token, because Stripe’s servers have no account on your app. Anyone who guesses the path, and /api/webhooks/stripe is not a hard guess, can POST to it.

So when a stranger hands your handler a JSON body claiming a customer paid you, what proves it came from Stripe? A cryptographic signature over the exact bytes on the wire. Verifying it is the first thing the handler does, before any business logic runs. Everything downstream rests on that boundary: the deduplication, the database writes, the entitlement that unlocks a paid feature.

What an unverified webhook lets an attacker do

Section titled “What an unverified webhook lets an attacker do”

Suppose your handler does the obvious thing: read the JSON, look at event.type, and on checkout.session.completed flip the customer’s plan to “pro.” Now picture an attacker. They open Stripe’s public docs, where every event shape is spelled out, hand-write a body that looks exactly like a real checkout.session.completed, point it at their own account’s ID, and curl it at your endpoint.

Your handler reads the body, sees a completed checkout, and grants the pro plan. The attacker just upgraded themselves with no payment and no card. A forged customer.subscription.updated could flip someone else’s subscription, and a forged event could trigger whatever fulfillment your handler kicks off: a shipment, a credit, an invoice. An unverified public webhook is a self-service entitlement grant sitting on a URL anyone can find.

So what stops it? Stripe and your app share a secret, a long random string only the two of you know. When Stripe sends a webhook, it uses that secret to compute a cryptographic signature of the request and attaches it as a header. You recompute the signature with the same secret and compare. An attacker can copy the body byte for byte, but without the secret can’t produce a matching signature, so the forgery fails the check.

The webhook secret is the trust root, so you treat it like a session secret. If it leaks, forgery is back on the table and every defense here fails. It lives in env.ts, validated at build time, never shipped to the client, never committed, never logged. You’ll be tempted later by cheaper-looking checks, like allowlisting Stripe’s IP addresses. Don’t. Behind a CDN those IPs are shared and spoofable, so an allowlist proves nothing. The signature is the only proof that counts.

No verification
Attacker
Handler no verify
Free pro plan granted
Verify at the boundary
Stripe
Attacker
Verify signature the only chokepoint
Process event
400, silence
The same forged request, with and without verification. Unverified, it grants a free plan; behind the verify gate, legitimate traffic passes and forgery dies at the chokepoint.

Two terms before we open the mechanism. A webhook is the inbound callback you just saw: Stripe calling you, not you calling Stripe. Your route handler is a trust boundary , the precise spot where the open internet meets your database.

“Verify the signature” has three moving parts: the header Stripe sends, the exact string it signs, and the check you run.

One: the header. Every Stripe webhook carries a Stripe-Signature header:

Stripe-Signature: t=1700000000,v1=5257a869e7 ... 0a68

It’s a comma-separated list of key=value pairs. Two matter: t, the Unix timestamp marking when Stripe signed the request, and v1, the signature as a hex digest. During a secret rotation you may see more than one v1; that comes at the end.

Two: the signed payload. The string Stripe signed is not the body alone. It’s the timestamp, a literal dot, and the raw request body, concatenated:

const signedPayload = `${t}.${rawBody}`;

The timestamp is glued in front so the signature covers it too, which is what makes the freshness check trustworthy. rawBody is the exact bytes Stripe sent, character for character, not a parsed-and-reprinted version. The next section is entirely about it.

Three: the verification. Compute the HMAC -SHA-256 of the signed-payload string, keyed by your webhook secret, to get a digest . Compare it against the header’s v1. A match means the request is authentic; no match means it’s forged or your config is wrong, and you treat both the same way.

You met these primitives in Web Crypto: HMAC as a keyed hash, crypto.subtle.importKey to load the secret, crypto.subtle.sign to produce the digest, and the rule that you compare digests in constant time, never with ===. Here they stop being a demo and start protecting real money.

In production Stripe’s SDK does this in one line, and that’s the line you’ll ship. We hand-roll it once here, because a one-liner you don’t understand is one you can’t debug when webhooks start failing.

const TOLERANCE_SECONDS = 300;
export const verifyStripeSignature = async (
rawBody: string,
sigHeader: string | null,
secret: string,
): Promise<boolean> => {
if (!sigHeader) return false;
const parts = Object.fromEntries(
sigHeader.split(',').map((pair) => pair.split('=')),
);
const timestamp = parts.t;
const expected = parts.v1;
if (!timestamp || !expected) return false;
const signedPayload = `${timestamp}.${rawBody}`;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const actual = new Uint8Array(
await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signedPayload)),
);
const expectedBytes = hexToBytes(expected);
if (!constantTimeEqual(actual, expectedBytes)) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
return age <= TOLERANCE_SECONDS;
};

Reject a missing header. No Stripe-Signature means the request isn’t from Stripe, or your config is broken. Either way it can’t pass, so bail before touching the body.

const TOLERANCE_SECONDS = 300;
export const verifyStripeSignature = async (
rawBody: string,
sigHeader: string | null,
secret: string,
): Promise<boolean> => {
if (!sigHeader) return false;
const parts = Object.fromEntries(
sigHeader.split(',').map((pair) => pair.split('=')),
);
const timestamp = parts.t;
const expected = parts.v1;
if (!timestamp || !expected) return false;
const signedPayload = `${timestamp}.${rawBody}`;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const actual = new Uint8Array(
await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signedPayload)),
);
const expectedBytes = hexToBytes(expected);
if (!constantTimeEqual(actual, expectedBytes)) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
return age <= TOLERANCE_SECONDS;
};

Parse t and v1 out of the header. Split on commas, then on =, into a small map, and pull out timestamp and expected. If either is absent the header is malformed, so reject it.

const TOLERANCE_SECONDS = 300;
export const verifyStripeSignature = async (
rawBody: string,
sigHeader: string | null,
secret: string,
): Promise<boolean> => {
if (!sigHeader) return false;
const parts = Object.fromEntries(
sigHeader.split(',').map((pair) => pair.split('=')),
);
const timestamp = parts.t;
const expected = parts.v1;
if (!timestamp || !expected) return false;
const signedPayload = `${timestamp}.${rawBody}`;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const actual = new Uint8Array(
await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signedPayload)),
);
const expectedBytes = hexToBytes(expected);
if (!constantTimeEqual(actual, expectedBytes)) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
return age <= TOLERANCE_SECONDS;
};

Build the signed payload. Timestamp, a literal dot, then the exact raw body off the wire, never re-stringified JSON. The whole verification hinges on this line.

const TOLERANCE_SECONDS = 300;
export const verifyStripeSignature = async (
rawBody: string,
sigHeader: string | null,
secret: string,
): Promise<boolean> => {
if (!sigHeader) return false;
const parts = Object.fromEntries(
sigHeader.split(',').map((pair) => pair.split('=')),
);
const timestamp = parts.t;
const expected = parts.v1;
if (!timestamp || !expected) return false;
const signedPayload = `${timestamp}.${rawBody}`;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const actual = new Uint8Array(
await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signedPayload)),
);
const expectedBytes = hexToBytes(expected);
if (!constantTimeEqual(actual, expectedBytes)) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
return age <= TOLERANCE_SECONDS;
};

Import the secret as an HMAC key. The same crypto.subtle.importKey call from the Web Crypto lesson: 'raw' key material, HMAC with SHA-256, not extractable, usable only to 'sign'.

const TOLERANCE_SECONDS = 300;
export const verifyStripeSignature = async (
rawBody: string,
sigHeader: string | null,
secret: string,
): Promise<boolean> => {
if (!sigHeader) return false;
const parts = Object.fromEntries(
sigHeader.split(',').map((pair) => pair.split('=')),
);
const timestamp = parts.t;
const expected = parts.v1;
if (!timestamp || !expected) return false;
const signedPayload = `${timestamp}.${rawBody}`;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const actual = new Uint8Array(
await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signedPayload)),
);
const expectedBytes = hexToBytes(expected);
if (!constantTimeEqual(actual, expectedBytes)) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
return age <= TOLERANCE_SECONDS;
};

Sign, then decode v1 to bytes. crypto.subtle.sign returns an ArrayBuffer, so you wrap it in a Uint8Array. The header’s v1 is hex, so hexToBytes decodes it to a matching Uint8Array, putting both sides in the same shape to compare.

const TOLERANCE_SECONDS = 300;
export const verifyStripeSignature = async (
rawBody: string,
sigHeader: string | null,
secret: string,
): Promise<boolean> => {
if (!sigHeader) return false;
const parts = Object.fromEntries(
sigHeader.split(',').map((pair) => pair.split('=')),
);
const timestamp = parts.t;
const expected = parts.v1;
if (!timestamp || !expected) return false;
const signedPayload = `${timestamp}.${rawBody}`;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const actual = new Uint8Array(
await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signedPayload)),
);
const expectedBytes = hexToBytes(expected);
if (!constantTimeEqual(actual, expectedBytes)) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
return age <= TOLERANCE_SECONDS;
};

Constant-time compare the two byte buffers. constantTimeEqual is the length-checked XOR loop from the Web Crypto lesson: it always runs the full length. Never === here, because a byte-by-byte early exit leaks timing.

const TOLERANCE_SECONDS = 300;
export const verifyStripeSignature = async (
rawBody: string,
sigHeader: string | null,
secret: string,
): Promise<boolean> => {
if (!sigHeader) return false;
const parts = Object.fromEntries(
sigHeader.split(',').map((pair) => pair.split('=')),
);
const timestamp = parts.t;
const expected = parts.v1;
if (!timestamp || !expected) return false;
const signedPayload = `${timestamp}.${rawBody}`;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const actual = new Uint8Array(
await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signedPayload)),
);
const expectedBytes = hexToBytes(expected);
if (!constantTimeEqual(actual, expectedBytes)) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
return age <= TOLERANCE_SECONDS;
};

Check the timestamp tolerance. A digest can match and the event still be a stale replay. Rejecting anything older than 300 seconds closes that hole; the next section explains why 300.

1 / 1

The helper is async because crypto.subtle returns promises, so anything built on it must await. That wrinkle is exactly what makes the SDK’s design choices make sense in a moment.

A webhook body is just JSON, so your hands type the familiar thing:

const event = await request.json();
const recomputed = JSON.stringify(event);
// HMAC(recomputed) ... and now nothing matches

You parse the body, then stringify it back because the verifier hashes a string. It looks symmetric, but JSON.parse followed by JSON.stringify does not return the original bytes: whitespace, key order, and number formatting all drift. Because HMAC is a hash, one changed byte changes the entire digest, so your digest over the re-stringified version no longer matches the v1 Stripe signed over the original bytes, and verification fails on legitimate events.

Here is how that becomes an outage. The bug is invisible on your machine: a synthetic test payload has a simple shape and no awkward numbers, so it round-trips cleanly, verification passes, and you ship. In production, real Stripe bodies diverge on the round trip and every event returns a 400. The dangerous fix is the panicked one: someone assumes the verifier is broken and disables it to unblock, and now the door is wide open.

The rule that erases this whole class of failure: read the body once as raw text, verify against those exact bytes, and parse only after the signature passes.

const event = await request.json();
const recomputed = JSON.stringify(event);
const ok = await verify(recomputed, signature, secret);

Verification fails on legitimate events. request.json() discards the original bytes, and JSON.stringify rebuilds a JSON string, not the one Stripe signed. Whitespace, key order, and number formatting all drift, so the HMAC over recomputed can never match the v1 Stripe produced over the real bytes.

One trap hides inside this rule: reading the body twice. A request body is a one-shot stream . The moment you call request.text() (or request.json()), the bytes drain, and a second call returns an empty string silently, with no error. So the discipline isn’t only “read text instead of JSON,” it’s read once, hold the string in a variable, and reuse that variable for both the verify and the later parse.

The hand-rolled verifier’s last step rejected any request where |now − t| exceeded five minutes. Here is why.

An attacker without the secret can’t forge a signature, but they can capture a real one: a valid request that leaked from a proxy log or a crash dump gives them a body and a matching signature that genuinely came from Stripe. Without a freshness check, that pair is a permanent skeleton key. The attacker resends the same bytes, your HMAC recomputes the same digest, the comparison passes, and your handler reprocesses an event Stripe sent once. Constant-time compare doesn’t help, because the signature is authentic.

The timestamp closes the window. Because t is part of the signed payload, the attacker can’t change it without breaking the signature, so the handler reads t, compares it to the current time, and rejects anything older than the tolerance. A captured request now works for five minutes, not forever.

Why five minutes? Your clock and Stripe’s are never perfectly in sync, so too tight a value rejects legitimate events whenever clock skew or network delay pushes a request past the cutoff; too loose a value forgives skew but widens the replay window. Five minutes (300 seconds) is Stripe’s default; use it rather than inventing your own number.

You’ve seen every step: parse the header, rebuild `${t}.${rawBody}`, HMAC it, constant-time compare, check the tolerance. Stripe’s SDK does all of it in one call.

const event = stripe.webhooks.constructEvent(rawBody, signature, secret);

That line parses the Stripe-Signature header, recomputes the HMAC over the raw body, compares it in constant time, and enforces the 300-second tolerance. On success it returns a fully typed Stripe.Event. On any failure, a bad signature, a missing header, a stale timestamp, it throws a Stripe.errors.StripeSignatureVerificationError. There’s no boolean to check and forget; the failure is an exception you catch.

You hand-roll the HMAC exactly once, here, to make this one-liner legible. Production code uses the helper, because reimplementing crypto primitives in application code is how subtle, exploitable bugs ship.

constructEvent is synchronous, with no await, because Node’s crypto is synchronous. Stripe also ships constructEventAsync for environments whose crypto is promise-based, like the Edge runtime built on Web Crypto, the same API that forced our hand-rolled helper to be async. This course runs the handler on Node, so you’ll write the synchronous constructEvent.

The handler needs a stripe client to call constructEvent. Install the SDK.

Terminal window
pnpm add stripe

Then create one shared instance and export it, so every call site imports the same client instead of writing new Stripe(...) per request.

src/lib/stripe.ts
import Stripe from 'stripe';
import { env } from '@/env';
export const stripe = new Stripe(env.STRIPE_SECRET_KEY);

A singleton gives you one place to set the API version, one place that reads the key, and no config drift between call sites; scattered new Stripe() calls become several subtly different clients waiting to disagree. The deeper SDK surface, creating checkouts and reading subscriptions, arrives next chapter with the billing flow.

The client reads env.STRIPE_SECRET_KEY, so both Stripe secrets go into your validated env: server-only, Zod-validated, with the build failing if one is missing rather than blowing up at runtime.

src/env.ts
server: {
// ...existing server vars
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
},

These two secrets pull in opposite directions. STRIPE_SECRET_KEY authenticates your outbound calls to Stripe’s API. STRIPE_WEBHOOK_SECRET is the shared trust root that verifies Stripe’s inbound calls to you, and it’s the secret argument constructEvent needs. Keep the “who’s calling whom” direction straight and you won’t confuse them.

The reference handler: verify first, return early

Section titled “The reference handler: verify first, return early”

This POST is the chapter’s skeleton; the next lessons extend it. The structure is verify first, return early: nothing the handler does, not parsing, not the database, not logging the payload, happens before the signature check clears.

import type { NextRequest } from 'next/server';
import Stripe from 'stripe';
import { env } from '@/env';
import { stripe } from '@/lib/stripe';
export const runtime = 'nodejs';
export const POST = async (request: NextRequest) => {
const rawBody = await request.text();
const signature = request.headers.get('stripe-signature');
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature ?? '',
env.STRIPE_WEBHOOK_SECRET,
);
} catch {
return Response.json(
{
type: 'about:blank',
title: 'invalid_signature',
status: 400,
instance: '/api/webhooks/stripe',
},
{ status: 400, headers: { 'content-type': 'application/problem+json' } },
);
}
// signature verified — hand off to dedup + business logic (next lesson)
return new Response(null, { status: 200 });
};

Declare the Node runtime. Next.js 16 already defaults route handlers to Node, so this line states intent rather than changing behavior. Rationale in the prose below.

import type { NextRequest } from 'next/server';
import Stripe from 'stripe';
import { env } from '@/env';
import { stripe } from '@/lib/stripe';
export const runtime = 'nodejs';
export const POST = async (request: NextRequest) => {
const rawBody = await request.text();
const signature = request.headers.get('stripe-signature');
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature ?? '',
env.STRIPE_WEBHOOK_SECRET,
);
} catch {
return Response.json(
{
type: 'about:blank',
title: 'invalid_signature',
status: 400,
instance: '/api/webhooks/stripe',
},
{ status: 400, headers: { 'content-type': 'application/problem+json' } },
);
}
// signature verified — hand off to dedup + business logic (next lesson)
return new Response(null, { status: 200 });
};

Read the raw body once. request.text() gives you the exact bytes off the wire, read a single time. This is the source of truth for the HMAC, as the raw-bytes rule demands.

import type { NextRequest } from 'next/server';
import Stripe from 'stripe';
import { env } from '@/env';
import { stripe } from '@/lib/stripe';
export const runtime = 'nodejs';
export const POST = async (request: NextRequest) => {
const rawBody = await request.text();
const signature = request.headers.get('stripe-signature');
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature ?? '',
env.STRIPE_WEBHOOK_SECRET,
);
} catch {
return Response.json(
{
type: 'about:blank',
title: 'invalid_signature',
status: 400,
instance: '/api/webhooks/stripe',
},
{ status: 400, headers: { 'content-type': 'application/problem+json' } },
);
}
// signature verified — hand off to dedup + business logic (next lesson)
return new Response(null, { status: 200 });
};

Read the signature header, treat null as failure. A missing header gets no benefit of the doubt: null means not from Stripe, or misconfigured. It flows into the same 400 via signature ?? '', which constructEvent rejects.

import type { NextRequest } from 'next/server';
import Stripe from 'stripe';
import { env } from '@/env';
import { stripe } from '@/lib/stripe';
export const runtime = 'nodejs';
export const POST = async (request: NextRequest) => {
const rawBody = await request.text();
const signature = request.headers.get('stripe-signature');
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature ?? '',
env.STRIPE_WEBHOOK_SECRET,
);
} catch {
return Response.json(
{
type: 'about:blank',
title: 'invalid_signature',
status: 400,
instance: '/api/webhooks/stripe',
},
{ status: 400, headers: { 'content-type': 'application/problem+json' } },
);
}
// signature verified — hand off to dedup + business logic (next lesson)
return new Response(null, { status: 200 });
};

Verify inside try/catch. The try is the trust gate; constructEvent does the whole check in one call. A thrown error is the deny, and the catch is the fail-closed default.

import type { NextRequest } from 'next/server';
import Stripe from 'stripe';
import { env } from '@/env';
import { stripe } from '@/lib/stripe';
export const runtime = 'nodejs';
export const POST = async (request: NextRequest) => {
const rawBody = await request.text();
const signature = request.headers.get('stripe-signature');
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature ?? '',
env.STRIPE_WEBHOOK_SECRET,
);
} catch {
return Response.json(
{
type: 'about:blank',
title: 'invalid_signature',
status: 400,
instance: '/api/webhooks/stripe',
},
{ status: 400, headers: { 'content-type': 'application/problem+json' } },
);
}
// signature verified — hand off to dedup + business logic (next lesson)
return new Response(null, { status: 200 });
};

On failure, return 400 problem+json, and leak nothing. A bad proof is a 400, not a 401, so senders stop retrying. The body is RFC 9457 { type, title, status, instance } and echoes none of the request.

import type { NextRequest } from 'next/server';
import Stripe from 'stripe';
import { env } from '@/env';
import { stripe } from '@/lib/stripe';
export const runtime = 'nodejs';
export const POST = async (request: NextRequest) => {
const rawBody = await request.text();
const signature = request.headers.get('stripe-signature');
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature ?? '',
env.STRIPE_WEBHOOK_SECRET,
);
} catch {
return Response.json(
{
type: 'about:blank',
title: 'invalid_signature',
status: 400,
instance: '/api/webhooks/stripe',
},
{ status: 400, headers: { 'content-type': 'application/problem+json' } },
);
}
// signature verified — hand off to dedup + business logic (next lesson)
return new Response(null, { status: 200 });
};

On success, hand off. A deliberate stub: claiming the event and writing to the database is the next lesson. This lesson ends the moment the signature passes.

1 / 1

Three decisions inside that handler are worth stating in prose, because each is where juniors guess wrong.

400, not 401. A request without a valid signature feels unauthorized, a 401. It isn’t. A 401 means missing identity: “I don’t know who you are, try logging in.” A signature failure is a malformed proof: the request claims to be from Stripe and the proof doesn’t check out, which is a bad request, a 400. The code matters because third-party senders, Stripe included, treat 5xx as “retry later” and 4xx as “terminal, stop.” A 401 reads as a transient auth blip worth retrying, hammering your endpoint with doomed requests; a 400 says cleanly, “this request is wrong, don’t send it again.”

The error body carries nothing the caller controls. The failure response follows the RFC 9457 problem+json contract from the route-handler chapter: Content-Type: application/problem+json and a body of { type, title, status, instance }, here titled "invalid_signature". The body echoes none of the request: no payload fragment, no internal error message, no stack detail. This request might be an attacker’s, so you don’t reflect their input back, and you don’t log the unverified body. A failed verification produces a 400 and silence.

The Node runtime, named on purpose. Both runtimes are defensible here: the Edge runtime’s Web Crypto handles HMAC verification fine, as our hand-rolled helper showed. The course picks Node because the Stripe SDK and the synchronous constructEvent are most ergonomic there. The cost is slightly heavier cold starts than Edge, in exchange for the full Node API surface.

Testing webhooks locally with the Stripe CLI

Section titled “Testing webhooks locally with the Stripe CLI”

Stripe’s servers can’t reach http://localhost:3000, so you can’t test the handler against real traffic. The Stripe CLI bridges that gap.

Open the tunnel: this forwards every event for your account to your local handler.

Terminal window
stripe listen --forward-to localhost:3000/api/webhooks/stripe

On startup, stripe listen prints a webhook signing secret beginning with whsec_, scoped to this local session. Paste it into STRIPE_WEBHOOK_SECRET in your local .env so constructEvent can verify the forwarded events.

With the tunnel open, fire a synthetic event through it from a second terminal:

Terminal window
stripe trigger checkout.session.completed

Stripe generates a realistic checkout.session.completed event and sends it down the tunnel: no real checkout, no real card, just a properly-signed event you can set a breakpoint on.

Stripe CLI
docs.stripe.com

Install the Stripe CLI and explore the full listen / trigger command surface.

Eventually you’ll rotate the signing secret, on a schedule or because you suspect it leaked. Since constructEvent verifies against one secret, the swap looks like it must reject in-flight events signed with the old one.

It doesn’t: Stripe lets you keep more than one signing secret active during a rotation window. Hold both in env for the overlap, try the new one first, and fall back to the old.

let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, env.STRIPE_WEBHOOK_SECRET);
} catch {
event = stripe.webhooks.constructEvent(rawBody, signature, env.STRIPE_WEBHOOK_SECRET_OLD);
}

Once traffic has fully cut over, delete the old env entry and the fallback catch.

Two drills. The first cements the order: verify before parse before process.

Order the lines of the verifying handler. The whole point is *when* each one runs: the parse comes after the gate, not before it. Drag the items into the correct order, then press Check.

export const POST = async (request: NextRequest) => {
// 1 ____
// 2 ____
let event: Stripe.Event;
try {
// 3 ____
} catch {
// 4 ____
}
// 5 ____
// 6 ____
};
Read the raw request body once as text — const rawBody = await request.text()
Read the stripe-signature header off the request
Verify with constructEvent (HMAC compare + 5-minute tolerance)
On a thrown error, return 400 with a problem+json body
Parse the verified bytes — JSON.parse(rawBody)
Hand the event off to the dedup + business logic

The second targets the costliest misconceptions.

A teammate is reviewing a draft of the webhook handler and lists the decisions they think are correct. Select every statement that is actually true about the verification boundary.

When verification fails, the handler replies with a status code in the 4xx family rather than 401, so Stripe stops resending the event instead of retrying.
The bytes fed to the HMAC must be the string read straight off the wire — a value rebuilt from a parsed object can compute a different digest.
A request that arrives with no stripe-signature header takes the same rejection path as a request whose signature is wrong.
Even a signature that genuinely came from Stripe is refused once it is more than a few minutes old.
A plain === between the two hex digests is safe to use whenever the webhook secret is long and random.
If you restrict the route to Stripe’s published IP ranges, you can skip recomputing the HMAC altogether.

A verified event can still arrive twice, and processing it twice is its own expensive bug. The next lesson, Claim once, mutate once, picks up where the success path stubs out.