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

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.

WebhookMon's Signature tab showing a plain-English verdict for a Stripe event: the stripe-signature header, its timestamp, the configured secret and whether the computed signature matched
Download WebhookMon free trial Learn more

Related guides