hooksentinel
Frameworks

NestJS

The full NestJS integration guide for hooksentinel — module setup, rawBody, dependency injection, and the

NestJS is the most common place teams get webhook raw-body handling wrong, because Nest wraps Express (or Fastify) behind its own module and pipe system, and the raw body has to survive that layer intact. This guide covers the full setup, plus two gotchas that account for most of the missing_raw_body reports we see from Nest apps.

Install

npm install @hooksentinel/core

@hooksentinel/core/nestjs exports a HookSentinelModule and a controller-level decorator. Neither pulls in Express or Fastify directly — they adapt whichever HTTP adapter your Nest app is already using.

Step 1: enable rawBody on the Nest application

Nest can capture the raw request body for you, but only if you ask for it at bootstrap:

src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    rawBody: true, // required — without this, req.rawBody is always undefined
  });
  await app.listen(3000);
}
bootstrap();

Without rawBody: true, every hooksentinel-protected route in your Nest app fails signature verification with missing_raw_body, because there's no raw buffer to check the signature against — only the already-parsed JSON object.

Step 2: register the module

src/webhooks/webhooks.module.ts
import { Module } from '@nestjs/common';
import { HookSentinelModule } from '@hooksentinel/core/nestjs';
import { stripe } from '@hooksentinel/core';
import { redisStore } from '@hooksentinel/core/stores';
import { StripeWebhookController } from './stripe-webhook.controller';
import { StripeWebhookService } from './stripe-webhook.service';

@Module({
  imports: [
    HookSentinelModule.forRoot({
      provider: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),
      idempotency: redisStore({ url: process.env.REDIS_URL! }),
    }),
  ],
  controllers: [StripeWebhookController],
  providers: [StripeWebhookService],
})
export class WebhooksModule {}

Step 3: the controller

@VerifiedWebhook() is a method decorator that runs the hooksentinel pipeline as a guard before your handler executes. If verification fails, the guard short-circuits with the correct status code and your method body never runs.

src/webhooks/stripe-webhook.controller.ts
import { Controller, Post, HttpCode } from '@nestjs/common';
import { VerifiedWebhook, WebhookEvent } from '@hooksentinel/core/nestjs';
import type { StripeEvent } from '@hooksentinel/core';
import { StripeWebhookService } from './stripe-webhook.service';

@Controller('webhooks/stripe')
export class StripeWebhookController {
  constructor(private readonly service: StripeWebhookService) {}

  @Post()
  @HttpCode(200)
  @VerifiedWebhook()
  async handle(@WebhookEvent() event: StripeEvent) {
    switch (event.type) {
      case 'checkout.session.completed':
        await this.service.fulfillOrder(event.data.object.id);
        break;
      case 'invoice.payment_failed':
        await this.service.notifyDunning(event.data.object.customer as string);
        break;
    }
  }
}

@WebhookEvent() is a param decorator, analogous to @Body(), that injects the already-verified, already-typed event — you never touch req.rawBody directly in application code.

Dependency injection for idempotency stores

redisStore() and prismaStore() accept either a config object (as above) or an existing client instance, so you can reuse a client you already manage through Nest's DI container instead of opening a second connection:

src/webhooks/webhooks.module.ts
import { Module } from '@nestjs/common';
import { HookSentinelModule } from '@hooksentinel/core/nestjs';
import { stripe } from '@hooksentinel/core';
import { redisStore } from '@hooksentinel/core/stores';
import { RedisModule, InjectRedis } from './redis'; // your existing Redis module
import type Redis from 'ioredis';

@Module({
  imports: [
    RedisModule,
    HookSentinelModule.forRootAsync({
      imports: [RedisModule],
      inject: [InjectRedis()],
      useFactory: (redis: Redis) => ({
        provider: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),
        idempotency: redisStore({ client: redis }),
      }),
    }),
  ],
})
export class WebhooksModule {}

The rawBody + custom body-parser override bug (nestjs/nest#10471)

If your app also registers its own body-parser middleware — commonly to raise the JSON size limit past Nest's default — the custom parser silently replaces the one Nest uses to populate req.rawBody, and rawBody: true stops working even though it's still set in NestFactory.create(). This is the exact interaction tracked in nestjs/nest#10471.

This bites teams that write something like:

src/main.ts (breaks rawBody)
import { NestFactory } from '@nestjs/core';
import { json } from 'express';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, { rawBody: true });

  // this line silently overrides Nest's raw-body-capturing parser
  app.use(json({ limit: '10mb' }));

  await app.listen(3000);
}
bootstrap();

The fix is to re-attach the raw body yourself in the verify hook of your custom parser, using the same rawBody property Nest expects:

src/main.ts (fixed)
import { NestFactory } from '@nestjs/core';
import { json } from 'express';
import type { IncomingMessage } from 'http';
import { AppModule } from './app.module';

function rawBodySaver(req: IncomingMessage & { rawBody?: Buffer }, _res: unknown, buf: Buffer) {
  if (buf?.length) {
    req.rawBody = buf;
  }
}

async function bootstrap() {
  const app = await NestFactory.create(AppModule, { rawBody: true });

  app.use(json({ limit: '10mb', verify: rawBodySaver }));

  await app.listen(3000);
}
bootstrap();

Or, if you don't need a larger limit for every route, scope the bigger limit to specific non-webhook controllers instead of overriding the global parser at all — that sidesteps the bug entirely and is what we recommend.

Testing

Combine @hooksentinel/core/testing's createTestSigner with Nest's Test.createTestingModule and supertest — see Testing for a full NestJS e2e example.

Last updated on

On this page