Skip to content
Chapter 65Lesson 2

Verify before you parse

Right now POST /api/webhooks/stripe is a dead end: the handler the starter shipped returns a bare 404, so stripe trigger checkout.session.completed gets you nothing.

By the end of this lesson that trigger returns 200, while a forged or header-less request returns 400 invalid_signature as application/problem+json. The processed_events panel stays empty: a verified event does no business work yet. You build the gate, not the machinery behind it.

This handler is the trust boundary of the billing system. Everything downstream — the dedup claim, the entitlement write, the audit log — assumes the event in front of it really came from Stripe, which holds only if the gate holds. The gate is one rule in one order: read the raw body exactly once, verify the signature against your endpoint secret, then trust the payload. Reverse the steps and you parse attacker-controlled bytes before you know who sent them. Order is the entire lesson.

A few rules follow from that order. A bad signature and a missing stripe-signature header get the same answer, 400 not 401: a request with no signature has failed the contract as surely as one that forged it, and a 4xx tells Stripe to stop retrying. Read the body with request.text() exactly once; a second read returns an empty string, the silent webhook bug from chapter 063. Never log the body before verification: an attacker-controlled string in a structured log is a log-injection vector. The verification call does the real work — timestamp, HMAC, constant-time compare, five-minute replay tolerance — and throws one specific error type on failure. That type is a 400; any other error is a real bug, a 500, and must be re-thrown rather than swallowed.

Out of scope until the next lesson: claiming the event, the dispatch switch, and every database write.

A valid stripe trigger checkout.session.completed returns 200.
tested
The inspector’s “Tamper signature” button returns 400 application/problem+json with title: 'invalid_signature'.
tested
A POST with no stripe-signature header returns the same 400 invalid_signature.
tested
Firing a trigger adds no processed_events row — the 200 carries no business effect yet.
tested
The structured log records one disposition per request keyed by event id — verified on success, invalid_signature on a bad signature, missing_header on a null header — and no request body is logged before the signature verifies.
untested

Open src/app/api/webhooks/stripe/route.ts, implement the verification gate against the brief above and the lesson tests, then read the walkthrough below.

Reference solution and walkthrough

Everything this handler needs already ships in the starter: the stripe singleton (lib/billing/stripe.ts), the problemJson helper (lib/problem.ts), the Pino logger (lib/logger.ts), and the boot-validated env.STRIPE_WEBHOOK_SECRET.

const log = logger.child({ seam: 'webhook.stripe' });
export const POST = async (request: Request): Promise<Response> => {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (signature === null) {
log.warn('missing_header');
return problemJson(400, 'invalid_signature');
}
let event: ReturnType<typeof stripe.webhooks.constructEvent>;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
env.STRIPE_WEBHOOK_SECRET,
);
} catch (error) {
if (error instanceof stripe.errors.StripeSignatureVerificationError) {
log.warn('invalid_signature');
return problemJson(400, 'invalid_signature');
}
throw error;
}
log.info({ eventId: event.id, eventType: event.type }, 'verified');
return Response.json({ received: true }, { status: 200 });
};

A child logger scoped to this seam, so every line carries seam: 'webhook.stripe' and you filter by seam and event id instead of grepping JSON.

const log = logger.child({ seam: 'webhook.stripe' });
export const POST = async (request: Request): Promise<Response> => {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (signature === null) {
log.warn('missing_header');
return problemJson(400, 'invalid_signature');
}
let event: ReturnType<typeof stripe.webhooks.constructEvent>;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
env.STRIPE_WEBHOOK_SECRET,
);
} catch (error) {
if (error instanceof stripe.errors.StripeSignatureVerificationError) {
log.warn('invalid_signature');
return problemJson(400, 'invalid_signature');
}
throw error;
}
log.info({ eventId: event.id, eventType: event.type }, 'verified');
return Response.json({ received: true }, { status: 200 });
};

Read the raw body once, as text; constructEvent verifies against the exact bytes Stripe signed.

const log = logger.child({ seam: 'webhook.stripe' });
export const POST = async (request: Request): Promise<Response> => {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (signature === null) {
log.warn('missing_header');
return problemJson(400, 'invalid_signature');
}
let event: ReturnType<typeof stripe.webhooks.constructEvent>;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
env.STRIPE_WEBHOOK_SECRET,
);
} catch (error) {
if (error instanceof stripe.errors.StripeSignatureVerificationError) {
log.warn('invalid_signature');
return problemJson(400, 'invalid_signature');
}
throw error;
}
log.info({ eventId: event.id, eventType: event.type }, 'verified');
return Response.json({ received: true }, { status: 200 });
};

Pull the signature header and null-check it first; a missing header short-circuits to the same 400 a bad signature gets.

const log = logger.child({ seam: 'webhook.stripe' });
export const POST = async (request: Request): Promise<Response> => {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (signature === null) {
log.warn('missing_header');
return problemJson(400, 'invalid_signature');
}
let event: ReturnType<typeof stripe.webhooks.constructEvent>;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
env.STRIPE_WEBHOOK_SECRET,
);
} catch (error) {
if (error instanceof stripe.errors.StripeSignatureVerificationError) {
log.warn('invalid_signature');
return problemJson(400, 'invalid_signature');
}
throw error;
}
log.info({ eventId: event.id, eventType: event.type }, 'verified');
return Response.json({ received: true }, { status: 200 });
};

The verification primitive parses the timestamp, computes the HMAC, runs the constant-time compare, and enforces the five-minute tolerance. The event it returns IS the parsed payload, so there is no separate parse step to write.

const log = logger.child({ seam: 'webhook.stripe' });
export const POST = async (request: Request): Promise<Response> => {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (signature === null) {
log.warn('missing_header');
return problemJson(400, 'invalid_signature');
}
let event: ReturnType<typeof stripe.webhooks.constructEvent>;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
env.STRIPE_WEBHOOK_SECRET,
);
} catch (error) {
if (error instanceof stripe.errors.StripeSignatureVerificationError) {
log.warn('invalid_signature');
return problemJson(400, 'invalid_signature');
}
throw error;
}
log.info({ eventId: event.id, eventType: event.type }, 'verified');
return Response.json({ received: true }, { status: 200 });
};

Discriminate the failure: a StripeSignatureVerificationError is the only thing that earns a 400, so the instanceof check is load-bearing.

const log = logger.child({ seam: 'webhook.stripe' });
export const POST = async (request: Request): Promise<Response> => {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (signature === null) {
log.warn('missing_header');
return problemJson(400, 'invalid_signature');
}
let event: ReturnType<typeof stripe.webhooks.constructEvent>;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
env.STRIPE_WEBHOOK_SECRET,
);
} catch (error) {
if (error instanceof stripe.errors.StripeSignatureVerificationError) {
log.warn('invalid_signature');
return problemJson(400, 'invalid_signature');
}
throw error;
}
log.info({ eventId: event.id, eventType: event.type }, 'verified');
return Response.json({ received: true }, { status: 200 });
};

Anything else re-throws untouched as a genuine 500. Swallowing a TypeError into a 400 would tell Stripe to stop retrying a delivery the next attempt might handle.

const log = logger.child({ seam: 'webhook.stripe' });
export const POST = async (request: Request): Promise<Response> => {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (signature === null) {
log.warn('missing_header');
return problemJson(400, 'invalid_signature');
}
let event: ReturnType<typeof stripe.webhooks.constructEvent>;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
env.STRIPE_WEBHOOK_SECRET,
);
} catch (error) {
if (error instanceof stripe.errors.StripeSignatureVerificationError) {
log.warn('invalid_signature');
return problemJson(400, 'invalid_signature');
}
throw error;
}
log.info({ eventId: event.id, eventType: event.type }, 'verified');
return Response.json({ received: true }, { status: 200 });
};

One verified line on success, keyed by event id and type. Nothing above this point logged the body: the first attacker-shaped data to touch your logs has already passed verification.

const log = logger.child({ seam: 'webhook.stripe' });
export const POST = async (request: Request): Promise<Response> => {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (signature === null) {
log.warn('missing_header');
return problemJson(400, 'invalid_signature');
}
let event: ReturnType<typeof stripe.webhooks.constructEvent>;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
env.STRIPE_WEBHOOK_SECRET,
);
} catch (error) {
if (error instanceof stripe.errors.StripeSignatureVerificationError) {
log.warn('invalid_signature');
return problemJson(400, 'invalid_signature');
}
throw error;
}
log.info({ eventId: event.id, eventType: event.type }, 'verified');
return Response.json({ received: true }, { status: 200 });
};

A plain 200 acknowledges the event; nothing is written yet. The next lesson wraps everything below the verified log in db.transaction to claim and dispatch it.

1 / 1

Why 400, never 401. Stripe retries 5xx and treats 4xx as terminal, so a 4xx is correct: a signature failure never succeeds on retry, and this stops Stripe from hammering your endpoint forever. Prefer 400 over 401 for the operator reading the failed-delivery panel: 401 reads as “your endpoint rejected our auth” and sends them chasing a credentials problem that does not exist, while 400 reads as “your request was malformed”, the truth.

Why the body is read exactly once. A Request body is a one-shot stream, so reading it twice returns empty on the second read:

// Broken — the stream is consumed twice
const body = await request.text(); // first read drains the stream
const event = await request.json(); // second read returns ""; constructEvent never sees the bytes

The stream is read twice. The second read returns an empty string, so constructEvent never sees the bytes Stripe signed, and the failure is silent, not thrown.

Why a missing header is the same 400. A request that omits the signature has failed the contract exactly as a forged one has, so it returns the identical 400 invalid_signature document: a verification failure must never leak what it expected. Only the internal log differs, missing_header versus invalid_signature, so you can tell the two apart while debugging.

Why there is no runtime export. Node is the default runtime for route handlers in Next 16, which is what you need, since the Stripe SDK is Node-only and constructEvent runs synchronously. With Cache Components enabled, Next rejects an explicit runtime segment export, so export const runtime = 'nodejs' would break the build.

Why the five-minute tolerance is left alone. constructEvent rejects any signature whose timestamp is more than five minutes old, defending against a replayed delivery. Do not tighten the window: ordinary clock skew between your server and Stripe’s would then surface as a stream of invalid_signature errors that look exactly like an attack, sending you to chase a security incident that is really NTP drift.

The rejections return the starter’s problemJson helper, worth seeing once:

export const problemJson = (status: number, title: string): Response =>
new Response(JSON.stringify({ type: 'about:blank', title, status }), {
status,
headers: { 'content-type': 'application/problem+json' },
});

It is RFC 9457 problem+json carrying only type, title, and status, with no detail and no echo of the request body: a verification failure must never reflect what the caller sent back at them.

Run the lesson’s test suite:

Terminal window
pnpm test:lesson 2

The suite sends real POST requests to your handler and inspects each Response. It signs a valid body with Stripe’s generateTestHeaderString against the same STRIPE_WEBHOOK_SECRET the route uses, so a genuine delivery passes the crypto. Four checks should turn green: the valid-trigger 200, the tampered-signature 400 titled invalid_signature, the missing-header 400, and proof that a rejected request opens no transaction — no processed_events row, no state touched.

The tests cover the HTTP contract, not the logs or the live inspector. Confirm those by hand:

stripe trigger checkout.session.completed returns 200, and your terminal shows exactly one verified line with the event id and type.
untested
The inspector’s “Tamper signature” button renders a 400 application/problem+json titled invalid_signature, inline in the debug panel.
untested
The inspector’s “Missing header” button renders the same 400 invalid_signature inline.
untested
The processed_events panel stays empty after every trigger, since a verified event does no business work yet.
untested

With the gate holding, the next lesson claims the verified event and dispatches it inside one transaction.