Webhook Debugging Guide
Stripe webhook signature fails in Next.js, NestJS or Fastify: getting the raw body
Short answer
Stripe signs the exact bytes it sent. If your framework parsed the JSON before your handler ran, the object you hand to constructEvent gets re-serialized, and re-serialized JSON is almost never byte-for-byte identical (whitespace, key order, number formatting, unicode escapes). The HMAC no longer matches, and stripe-node says:
No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?
The fix is the same everywhere: get the request body as a string or Buffer before anything parses it, and pass that to constructEvent. How you get it depends on the framework. Express is covered in the general Stripe signature guide; the others are below.
Prove it's the body and not the secret
Before changing code, check one number. Stripe sends a Content-Length header for the bytes it sent. If the body you're verifying has a different length, it was rebuilt somewhere between the socket and your handler:
const received = Buffer.byteLength(body);
const declared = Number(req.headers.get('content-length'));
console.log({ received, declared, same: received === declared });
Lengths match and it still fails? Then it's the secret (each endpoint in the Stripe dashboard has its own whsec_, and stripe listen prints a different temporary one) or the timestamp. Lengths differ? Keep reading.
Next.js App Router (app/api/…/route.ts)
Route handlers receive a standard Request that hasn't been parsed. The only mistake available is calling req.json(). Call req.text() instead:
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text(); // raw string, not req.json()
const signature = req.headers.get('stripe-signature');
if (!signature) return new Response('Missing stripe-signature', { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 });
}
// handle event.type here
return new Response(null, { status: 200 });
}
If the route runs on the Edge runtime, Node's crypto isn't available. Use stripe.webhooks.constructEventAsync(body, signature, secret, undefined, Stripe.createSubtleCryptoProvider()), which verifies with Web Crypto. Everything else stays the same.
Next.js Pages Router (pages/api/…)
API routes parse the body by default, and by the time your handler runs req.body is already an object. Turn the parser off for this route with the config export and read the stream yourself:
// pages/api/webhooks/stripe.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export const config = { api: { bodyParser: false } };
async function readRawBody(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();
const rawBody = await readRawBody(req);
const signature = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return res.status(400).send(`Webhook Error: ${(err as Error).message}`);
}
res.status(200).end();
}
The config export has to be in the same file as the handler. A common failure is exporting it from a shared module, which Next.js ignores, so the body is still parsed.
NestJS
NestJS 9 and later can keep a copy of the raw body alongside the parsed one. Turn it on when creating the app:
// main.ts
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
rawBody: true,
});
Then read req.rawBody in the controller. It's a Buffer, populated for every request the built-in JSON and urlencoded parsers handle:
// stripe-webhook.controller.ts
import { BadRequestException, Controller, Headers, Post, RawBodyRequest, Req } from '@nestjs/common';
import type { Request } from 'express';
@Controller('webhooks')
export class StripeWebhookController {
@Post('stripe')
handle(@Req() req: RawBodyRequest<Request>, @Headers('stripe-signature') signature: string) {
if (!req.rawBody) {
throw new BadRequestException('rawBody is undefined: pass { rawBody: true } to NestFactory.create');
}
const event = stripe.webhooks.constructEvent(req.rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET!);
return { received: true };
}
}
Two things that leave rawBody undefined: passing bodyParser: false (you've replaced the parser that captures it) and a request whose Content-Type the built-in parsers don't handle. Stripe sends application/json; charset=utf-8, so the second one isn't a problem unless a proxy in front rewrote the header. The same option works with the Fastify adapter.
Fastify
Fastify parses JSON by default and doesn't keep the original bytes. Register a content type parser that returns a Buffer, scoped to a plugin so the rest of the app still gets parsed JSON:
import Fastify from 'fastify';
const app = Fastify();
app.register(async (instance) => {
// Inside this plugin only: hand JSON bodies over as a Buffer instead of parsing them
instance.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
done(null, body);
});
instance.post('/webhooks/stripe', async (request, reply) => {
const event = stripe.webhooks.constructEvent(
request.body, // Buffer
request.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
return reply.code(200).send();
});
});
Content type parsers are encapsulated: one added inside a plugin applies to that plugin's routes only. If you'd rather keep parsed JSON on the webhook route as well, the fastify-raw-body plugin adds request.rawBody on routes that set config: { rawBody: true }.
The things that don't work
JSON.stringify(req.body). This is the bug, not the fix. It rebuilds the bytes.- Widening the tolerance.
constructEvent's fourth argument only affects the timestamp check. A body mismatch fails before the timestamp matters. - Trimming or re-encoding. Stripe's payload ends without a trailing newline and uses two-space indentation. Anything that normalizes it, including some logging middleware and some tunnels that pretty-print JSON, changes the signature. Verify before you log.
The easier way: WebhookMon
WebhookMon verifies each Stripe event against your endpoint secret as it arrives, before it reaches your framework, and forwards the body to localhost byte for byte. So if WebhookMon shows a valid signature and your app rejects the same event, the body was altered inside your app, and you know which side to look at. Once you've changed the route, Replay sends the same event again, re-signed with a fresh timestamp, so you don't need to trigger a new payment to test the fix.