Receiving webhooks in NestJS
Webhooks are one of those integration points that look deceptively simple until you get the signature verification wrong and spend three hours debugging why Stripe keeps rejecting your handler. Receiving webhooks in NestJS requires a specific setup: you need the raw request body, not the parsed JSON that NestJS normally hands you. This article walks you through the problem, the solution, and the patterns that keep your webhook handlers reliable in production.
Why Signatures Need the Raw Body
When Stripe, Meta, GitHub, or any reputable provider sends you a webhook, they sign it. That signature proves the request actually came from them. The signature is computed over the exact bytes—the raw body—before any parsing or transformation happens.
Here's the trap: by the time NestJS's built-in JSON middleware parses the request, those bytes are gone. You're holding a JavaScript object, which you could theoretically stringify back, but the whitespace, key ordering, and encoding might differ from the original bytes. When you pass this reconstructed string to your verification function, the signature check fails.
I learned this the hard way on a trading platform integration. The webhook arrived, parsed fine, contained valid data, but the signature verification threw an error every single time. The fix was realizing I needed to intercept the raw stream before any parsing.
Let me show you what happens without raw body access:
// ❌ This will NOT work reliably
@Post('stripe')
handle(@Body() body: any, @Headers('stripe-signature') sig: string) {
// body is already parsed JSON, original bytes are lost
const reconstructed = JSON.stringify(body); // Wrong format/order
const event = stripe.webhooks.constructEvent(
reconstructed, // Signature verification fails
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
}The verification fails because the bytes don't match what was originally signed. Stripe's server signed the exact bytes it sent; you're trying to verify against a reconstructed approximation.
Enable rawBody in NestFactory
The solution is to tell NestFactory to preserve the raw body before any middleware parses it. This is a single configuration option.
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
rawBody: true, // Preserve raw body for signature verification
});
await app.listen(3000);
}
bootstrap();With rawBody: true, NestJS stores the original request body as a buffer in req.rawBody. The parsed JSON is still available via @Body(), so your regular endpoints work normally.
Now you can access both the raw bytes and the parsed object:
import { Controller, Post, Req, Headers } from '@nestjs/common';
import { Request } from 'express';
import Stripe from 'stripe';
interface RawBodyRequest extends T {
rawBody?: Buffer;
}
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
@Controller('webhooks')
export class WebhookController {
@Post('stripe')
handleStripeWebhook(
@Req() req: RawBodyRequest,
@Headers('stripe-signature') sig: string,
) {
// req.rawBody contains the exact bytes that were signed
const event = stripe.webhooks.constructEvent(
req.rawBody!,
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
);
// event is now verified and safe to use
console.log(`Received ${event.type}:`, event.data.object);
return { received: true };
}
}The type annotation RawBodyRequest<Request> tells TypeScript that this request has the additional rawBody property. When signature verification succeeds, you know the webhook genuinely came from Stripe.
Respond Fast—Verify, Store, Enqueue
Webhook providers have timeouts, usually 30 seconds. If your handler takes too long, they'll assume the request failed and retry. Your handler should be fast: verify the signature, store the event, queue it for processing, and return a 200 response. Do the heavy lifting asynchronously.
Here's the pattern I use across all platforms:
import { Controller, Post, Req, Headers, Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { PrismaService } from './prisma.service';
import { KafkaService } from './kafka.service';
import Stripe from 'stripe';
import { Cache } from 'cache-manager';
interface RawBodyRequest extends T {
rawBody?: Buffer;
}
@Controller('webhooks')
export class WebhookController {
constructor(
private prisma: PrismaService,
private kafka: KafkaService,
@Inject(CACHE_MANAGER) private cache: Cache,
) {}
@Post('stripe')
async handleStripeWebhook(
@Req() req: RawBodyRequest,
@Headers('stripe-signature') sig: string,
) {
// 1. Verify signature (fast)
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.rawBody!,
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
// Invalid signature, return 400
return { error: 'Invalid signature' };
}
// 2. Store the raw event (fast write)
await this.prisma.webhookEvent.create({
data: {
provider: 'stripe',
externalId: event.id,
type: event.type,
payload: event as any,
processedAt: null,
},
});
// 3. Enqueue for processing (fast async)
await this.kafka.emit('stripe.webhook', {
eventId: event.id,
type: event.type,
});
// 4. Return immediately
return { received: true };
}
}A separate worker process (or Kafka consumer) handles the actual business logic: updating the database, calling external APIs, sending emails. If that work takes 10 seconds, the webhook handler still returns in 100ms.
Idempotent Processing—Track Event IDs
Providers send webhooks multiple times. Stripe, for example, retries for 3 days if it doesn't get a 200 response. If you process the same event twice without checking, you might charge a customer twice, send two emails, or double-count a refund.
Every webhook has a unique ID. Track which ones you've processed and skip duplicates:
import { Processor, Process } from '@nestjs/bull';
import { Job } from 'bull';
import { PrismaService } from './prisma.service';
interface StripeWebhookJob {
eventId: string;
type: string;
}
@Processor('stripe-webhooks')
export class StripeWebhookProcessor {
constructor(private prisma: PrismaService) {}
@Process()
async process(job: Job) {
const { eventId, type } = job.data;
// Check if we've already processed this event
const existing = await this.prisma.processedWebhookEvent.findUnique({
where: { externalId: eventId },
});
if (existing) {
// Duplicate—already handled, skip
return;
}
// Process based on type
switch (type) {
case 'payment_intent.succeeded':
await this.handlePaymentSuccess(eventId);
break;
case 'charge.refunded':
await this.handleRefund(eventId);
break;
// ...other types
}
// Mark as processed
await this.prisma.processedWebhookEvent.create({
data: {
provider: 'stripe',
externalId: eventId,
processedAt: new Date(),
},
});
}
private async handlePaymentSuccess(eventId: string) {
// Business logic here
}
private async handleRefund(eventId: string) {
// Business logic here
}
}This pattern ensures that even if Stripe retries 10 times, you only process the webhook once. The idempotency key is the provider's event ID, which is guaranteed unique.
Common Mistakes
- Forgetting rawBody: true — Most common. You enable it in NestFactory, then wonder why signature verification keeps failing. Always check your main.ts first.
- Parsing the body twice — If you use both @Body() and req.rawBody, you're fine. But don't manually parse req.rawBody as JSON and then try to verify the signature against the parsed object.
- Blocking on webhook handlers — Calling external APIs, querying databases, sending emails—do this in a worker. Return 200 immediately from the handler.
- No idempotency tracking — Providers always retry. Without tracking processed events, you'll get subtle bugs (double-charged transactions, duplicate records) that are hard to trace.
- Logging the entire webhook payload — Webhooks often contain sensitive data (payment tokens, PII). Log event types and IDs, not the full payload.
- Not handling signature verification errors gracefully — Return 400 for bad signatures, not 500. A 5xx makes the provider think something is wrong with your service and triggers retries.
Key Takeaways
- Enable
rawBody: truein NestFactory.create() so signature verification works correctly. - Access the raw bytes via
req.rawBody; pass them directly to your provider's verification function. - Return 200 fast: verify, store, enqueue, exit. Process the webhook asynchronously in a worker.
- Track processed event IDs (provider's ID, not yours) to handle retries idempotently.
- Return 400 for bad signatures (invalid request) and 200 for successful verification, even if async processing fails later.
- Never reconstruct the request body for signature verification—the bytes must match exactly what was signed.






Comments
Loading comments…