Skip to content
ExponaBLOG
NESTJS · DAY 060 / 100

Receiving webhooks in NestJS: what developers should know

The raw body problem and how to solve it.

Expona Admin7 min read

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:

typescript
// ❌ 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.

typescript
// 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:

typescript
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:

typescript
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:

typescript
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: true in 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.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles