Stripe Webhook Signature Verification Keeps Failing in NestJS? Here's the Fix
Stripe webhook signatures fail in NestJS because the global body parser rewrites your JSON before verification runs. Here's how raw body capture fixes it, for Express and Fastify.
Stripe Webhook Signature Verification Keeps Failing in NestJS? Here's the Fix
You wire up a Stripe webhook in NestJS, point stripe listen at your local server, and the very first event bounces back with a 400:
Webhook signature verification failed. No signatures found matching the expected signature for payload.
The credentials are right. The endpoint is reachable. Nothing about your code looks wrong. And that's exactly the problem, because the bug isn't in your webhook logic at all. It's in how NestJS handles the request body before your controller ever sees it.
Why this happens
Stripe verifies webhooks with an HMAC-SHA256 signature, computed over the exact byte sequence of the request body. NestJS's global body parser (Express or Fastify under the hood) doesn't preserve that byte sequence — it parses the incoming stream into a JSON object, and in doing so it can shift whitespace, key order, or encoding just enough to break the signature. stripe.webhooks.constructEvent() then hashes a payload that no longer matches what Stripe originally sent, and verification fails every time.
The fix isn't a Stripe SDK setting. It's making sure the raw, unparsed buffer survives long enough to reach your verification code.
Enabling raw body capture (Express)
NestJS has built-in support for this — you don't need extra middleware. Pass rawBody: true when the app is created, and it attaches the untouched buffer to req.rawBody while every other route keeps getting normal parsed JSON.
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
rawBody: true,
});
app.setGlobalPrefix('api');
await app.listen(3000);
}
bootstrap();
That one flag is doing all the work here. No custom middleware, no manual buffer handling on your end — NestJS keeps both copies of the body and hands you the right one when you ask for it.
The webhook controller
With rawBody: true set, pull req.rawBody and the stripe-signature header out of the request and pass them straight to constructEvent(). Don't touch, stringify, or re-serialize the buffer anywhere along the way — that's the mistake that reintroduces the bug.
// src/billing/stripe-webhook.controller.ts
import {
Controller,
Post,
Headers,
Req,
BadRequestException,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { Request } from 'express';
import Stripe from 'stripe';
@Controller('webhooks/stripe')
export class StripeWebhookController {
private readonly stripe: Stripe;
private readonly webhookSecret: string;
constructor() {
this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
this.webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
}
@Post()
@HttpCode(HttpStatus.OK)
async handleStripeWebhook(
@Headers('stripe-signature') signature: string,
@Req() req: Request & { rawBody?: Buffer }
) {
if (!signature) {
throw new BadRequestException('Missing stripe-signature header');
}
if (!req.rawBody) {
throw new BadRequestException(
'Raw body buffer not found. Ensure rawBody: true is configured in NestFactory.create().'
);
}
let event: Stripe.Event;
try {
event = this.stripe.webhooks.constructEvent(
req.rawBody,
signature,
this.webhookSecret
);
} catch (err: unknown) {
const error = err as Error;
console.error(`Webhook verification failed: ${error.message}`);
throw new BadRequestException(`Webhook Error: ${error.message}`);
}
switch (event.type) {
case 'payment_intent.succeeded': {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
console.log(`Payment succeeded for amount: ${paymentIntent.amount}`);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
console.log(`Subscription canceled: ${subscription.id}`);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
return { received: true };
}
}
Notice the guard clause checking for req.rawBody before it ever touches constructEvent(). That's not defensive paranoia — if you forget the rawBody: true flag, this throws a message that tells you exactly what's missing instead of a cryptic signature error that sends you down the wrong debugging path.
If you're on Fastify
Express gets the flag baked in. Fastify doesn't — you need fastify-raw-body registered explicitly, scoped to the webhook route so you're not carrying raw buffers through routes that don't need them.
// src/main.ts (Fastify)
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { AppModule } from './app.module';
import fastifyRawBody from 'fastify-raw-body';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter()
);
await app.register(fastifyRawBody, {
field: 'rawBody',
global: false,
encoding: false,
routes: ['/api/webhooks/stripe'],
});
await app.listen(3000, '0.0.0.0');
}
bootstrap();
encoding: false matters here — leave it as true and Fastify hands you a string instead of a Buffer, which fails signature verification for the same underlying reason as the parsed-JSON problem you were trying to avoid.
Troubleshooting checklist
| Symptom | Root cause | Fix |
|---|---|---|
| "No signatures found matching expected signature" | Payload passed as a parsed object instead of raw bytes | Pass the unaltered req.rawBody Buffer, not JSON.stringify(req.body) |
req.rawBody is undefined |
Missing application factory flag | Add { rawBody: true } in NestFactory.create() |
| Verification fails locally but not in production | Stripe CLI using the wrong webhook secret | Confirm your whsec_... matches the output of stripe listen --forward-to |
| Route returns 404 | Global prefix mismatch | Check the endpoint path against app.setGlobalPrefix('api') |
Most of these come back to one root cause wearing different symptoms: something between Stripe and your verification call touched the body. Fix the raw body pipeline once, at the framework level, and you stop debugging this category of bug entirely — it doesn't come back per-endpoint or per-event-type.
Accelerate your Backend & Payments Modernization Roadmap
Need custom architecture auditing, automated OpenAPI contract generation, or zero-downtime microservice migration guidance for your engineering team?
Subscribe to RenovateAPI
Get weekly architectural guides, API refactoring strategies, and technical SEO updates delivered directly to your inbox.
Discussion (2)
Extremely helpful breakdown of the Strangler Fig pattern! We're currently refactoring a legacy Java monolith at work and the OpenAPI gateway routing tips saved us weeks of experimentation.
The schema JSON-LD and FAQ block structure really helps with indexing. Great technical detail on entity mentions too.
Suggested Related Articles
Building a Stripe Dunning System in NestJS That Actually Recovers Revenue
A NestJS architecture for Stripe subscription dunning that syncs Postgres state, handles 3D Secure retries, and recovers failed payments instead of just canceling users.
Stripe Dunning in NestJS: Stop Losing Subscribers to Failed Card Charges
A production dunning architecture for NestJS SaaS apps — grace periods, 3D Secure recovery emails, and webhook-driven state sync that recovers most failed subscription payments.
Stripe Payments in React: 3D Secure, Apple Pay, and Surviving Network Drops
How to wire Stripe's Payment Element into a React app so 3D Secure, Apple Pay, and Google Pay don't leave you with orphaned orders or duplicate charges.