Webhooks

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against NestJS 12.1 (npm 12.1.2), TypeScript 6 (6.0.3), Node.js 20.19+ or 22.12+, Express 5 and Fastify 5, and was checked on 2026-10-02. @nestjs/webhooks is a first-party package that is pre-1.0: version 0.0.1 was published to npm on 2026-10-01, one day before this page, so its API can still change between releases. It needs @nestjs/outbox (also pre-1.0, 0.1.0) as a peer dependency. The examples target a NestJS 12 project (ESM layout, Vitest) with PostgreSQL provided in-process by PGlite through Drizzle; the delivery to a partner is shown both with the package’s recording transport and over real HTTP. Parts that need a database server (the PostgreSQL server, MySQL, TypeORM, Prisma and Kysely executors, npx nest-webhooks migrate and status), the ca option of HttpWebhookTransport and encryption at rest are described from the official page and the package’s type declarations.

A webhook is an HTTP request that one system sends to another when something happens, so the receiver does not have to poll. Both directions look simple, and both go wrong in the same ways:

  • Sending. A fetch() after the database commit is lost when the process dies in between, and is sent for a transaction that then rolls back. A retry delivers the same event twice without telling the partner. A partner that is down for an hour is hammered, and a dead endpoint is never switched off. Nobody can answer "did you send it?". And the URL the partner typed is a server-side request forgery (SSRF) waiting to happen, because https://169.254.169.254/ answers with cloud credentials.

  • Receiving. The signature is checked on a body the framework parsed and serialized again (a stray space breaks it, and a tampered body can pass), compared with === instead of in constant time, with no timestamp window, and a redelivered event runs the handler twice, so an order is marked paid twice.

@nestjs/webhooks turns both directions into configuration. Outgoing webhooks travel through the transactional outbox (see Transactional Outbox for the pattern and Outbox & Durable Workflows for the package): Webhooks.dispatch() adds the message inside the same transaction as the business change, and after the commit a worker fans it out to the partners' endpoints, signs each request the way the Standard Webhooks specification describes, retries with backoff for about two days, logs every attempt and replays on request. Incoming webhooks are one decorator, @VerifyWebhook(), which verifies the signature on the raw body and skips redeliveries.

The official page is long and worked through as a tutorial (an online store for cat food that sells to resellers over an API). This page keeps its running example, renames nothing in the API, and follows it in the order a project meets the problems: modules and stores, subscriptions, sending after the commit, retries and replay, receiving, secret rotation, testing and production. The Bookshelf scenario used elsewhere in this section appears in the section on where webhooks fit in Bookshelf.

How the pieces fit

flowchart TD A["Service: update the order and call webhooks.dispatch(tx, message)"] --> B["Outbox message written in the same transaction"] B --> C{"Commit or roll back?"} C -->|roll back| X["Message never existed, nothing is sent"] C -->|commit| D["Outbox relay publishes the message"] D --> E["Fan-out: one delivery per subscribed endpoint of the tenant"] E --> F["Webhook worker claims due deliveries"] F --> G["Sign with the endpoint secrets, POST over the transport"] G --> H{"Partner answers"} H -->|2xx| S["Delivery succeeded"] H -->|"5xx, 4xx, timeout, 429"| R["Retry after backoff, Retry-After honored"] R --> F H -->|410 Gone| Z["Failed, endpoint disabled"] R -->|attempts exhausted| W["Failed: exhausted"]
Figure 1. Outgoing path of a webhook: from the transaction to the partner

The diagram shows the three properties the package adds to a plain fetch(): the message exists only if the transaction commits, the work after the commit survives a crash because it is a stored delivery, and every attempt is recorded.

Receiving is a different, much shorter path: the route is guarded by @VerifyWebhook(name), which runs before the handler.

sequenceDiagram participant S as Sender (payment provider) participant R as Route with VerifyWebhook participant I as Inbox (outbox package) participant H as Handler S->>R: POST raw body with signature headers R->>R: Headers present, HMAC matches a secret, timestamp inside tolerance, body is JSON alt verification fails R-->>S: 401 generic message, reason goes to verification-failed event else webhook id already processed R->>I: Known id? I-->>R: Yes R-->>S: 200 empty, handler skipped else new webhook R->>H: IncomingWebhook with id, timestamp and payload H-->>S: 2xx R->>I: Record the id after the handler succeeded end
Figure 2. Incoming path: verification and deduplication before the handler

Where this sits in the request lifecycle: sending is not part of any request, because the worker is a background process of the application (disable it per instance with the worker.enabled option). Receiving is part of the route’s pipeline: the decorator verifies before the handler runs, so what the handler receives has already been authenticated. See Request lifecycle for the general order of guards, interceptors and pipes.

Installation and prerequisites

npm i --save @nestjs/webhooks @nestjs/outbox

The package’s peer dependencies are @nestjs/common and @nestjs/core 12, @nestjs/outbox (0.0.1 or 0.1.0), reflect-metadata and RxJS 7. Outgoing webhooks are outbox messages and incoming ones are deduplicated with the outbox’s inbox, so the application runs the outbox too. Receiving works without outgoing: a service that only receives webhooks still imports OutboxModule for its inbox (see A service that only receives).

The official tutorial runs on PostgreSQL through Drizzle ORM and @nestjs/drizzle, with the pg driver. The page keeps that choice, and notes what changes with TypeORM and with MySQL. Node.js 20.19 or newer is required by the package itself.

Registering the modules

WebhooksModule and OutboxModule are registered once, in the root module. Both are global, so Webhooks, WebhookEndpoints, WebhookDeliveries, WebhooksEvents and WebhooksStorage can be injected anywhere. Options come from ConfigService (see Configuration), so a missing secret stops the application at startup instead of at the first request:

src/webhooks/webhooks-app.module.ts (abridged)
@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    // PGlite is PostgreSQL in-process; use drizzle-orm/node-postgres and DATABASE_URL in an application.
    DrizzleModule.forRootAsync({
      useFactory: () => ({ drizzle, connection: { dataDir: 'memory://' }, schema }),
    }),
    // Outgoing webhooks travel through the outbox.
    OutboxModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        relay: { enabled: config.get('OUTBOX_RELAY') !== 'off', pollInterval: '1s' },
      }),
    }),
    WebhooksModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        // The types partners may subscribe to; dispatch() refuses any other.
        eventTypes: ['order.shipped', 'order.cancelled'],
        // Ten attempts: 5s, 20s, 80s, ... capped at a day, about two days in all.
        retry: { attempts: 10, backoff: { delay: '5s', factor: 4, maxDelay: '1d' } },
        // An endpoint that fails every attempt for five days is switched off.
        disableEndpointAfter: '5d',
        delivery: { timeout: '15s' },
        worker: { enabled: config.get('WEBHOOKS_WORKER') !== 'off', pollInterval: '1s' },
        receivers: {
          payments: { scheme: 'standard', secret: config.getOrThrow<string>('PAYMENTS_WEBHOOK_SECRET') },
          carrier: { scheme: 'stripe', header: 'Carrier-Signature', secret: config.getOrThrow<string>('CARRIER_WEBHOOK_SECRET') },
        },
      }),
    }),
  ],
  // controllers, the stores (next section) and the application's own providers
})
export class WebhooksAppModule {}

WebhooksModule needs the outbox: dispatch() is an Outbox.add(), and the fan-out that turns a committed message into deliveries is an outbox handler. Without OutboxModule, startup fails and says so. The most useful options:

Option Default Meaning

eventTypes

any type

The message types this application sends. dispatch() refuses any other, and so does an endpoint that tries to subscribe to one (endpoints also accept *).

retry

10 attempts

attempts (including the first), backoff (delay '5s', factor 4, maxDelay '1d', jitter 'equal', 'full' or 'none') and retryIf(error, attempt, delivery). A number sets attempts; false means a single attempt.

disableEndpointAfter

'5d'

Disables an endpoint that has failed every attempt for this long without a success. false never disables one.

secretRotationOverlap

'24h'

How long a rotated-out secret keeps signing next to the new one.

delivery

15 s timeout

timeout, allowHttp, allowPrivateNetworks, allowedAddresses, maxResponseSize (4096 bytes kept in the log) and userAgent (NestJS-Webhooks/1.0). allowHttp, allowPrivateNetworks and allowedAddresses are about SSRF and are covered in Subscriptions and SSRF.

worker

enabled

enabled, pollInterval, batchSize (50), concurrency (10 endpoints in parallel; one endpoint’s deliveries go one at a time) and lease (1 minute, longer than delivery.timeout).

encryption

off

Seals endpoint secrets at rest. keys lists 32-byte buffers or strings of at least 32 characters, newest first: the first key encrypts, every key decrypts.

receivers

none

The senders whose webhooks this application accepts, by the name @VerifyWebhook() uses.

transport

HttpWebhookTransport

Replaces how a delivery is sent (see Transports).

allowInMemoryStorage

false

Lets a production application start without a registered store.

Durations are milliseconds or strings such as '15s', '24h' or '5d'. The top-level forRoot() / forRootAsync() options also include outgoing (false for a service that only receives), imports (modules a transport class injects from) and isGlobal (default true). The options' injection token is WEBHOOKS_MODULE_OPTIONS.

Because shutdown must let the relay and the worker finish what they started, enable the shutdown hooks and create the application with the raw body kept (the verifier needs it):

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

async function bootstrap() {
  // rawBody: incoming webhooks are verified on the bytes that were signed, not on a re-serialized body.
  const app = await NestFactory.create(AppModule, { rawBody: true });
  app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
  // On SIGTERM the outbox relay and the webhook worker finish what they started.
  app.enableShutdownHooks();
  await app.listen(process.env.PORT ?? 3000);
}
await bootstrap();

Source: Webhooks, "Register the modules" and Raw body. See Lifecycle events and shutdown for what enableShutdownHooks() does.

Stores

The package keeps the endpoints (with their secrets), the messages it dispatched, their deliveries and the log of every attempt in a store, which it reads through two contracts, WebhookEndpointStore and WebhookDeliveryStore. Until you register one, the module keeps everything in memory: fine for a first run, but a restart loses every pending delivery and two instances do not share them. With NODE_ENV=production and no store registered, startup fails unless allowInMemoryStorage: true accepts that (the test run confirmed the production guard: the failure message names allowInMemoryStorage).

On PostgreSQL, register PostgresWebhookStore from @nestjs/webhooks/postgres, next to the outbox’s PostgresOutboxStore from @nestjs/outbox/postgres (the outbox carries dispatched messages out of your transaction and keeps the inbox that deduplicates incoming ones). They are ordinary providers, registered once in the root module, created with a factory that injects the database and the package’s storage registry:

src/webhooks/webhooks-app.module.ts (providers)
providers: [
  {
    // Outbox messages and the inbox, in your database, in a schema of their own (nest_outbox)
    provide: PostgresOutboxStore,
    inject: [getDrizzleToken(), OutboxStorage],
    useFactory: (db: Database, outboxStorage: OutboxStorage) =>
      new PostgresOutboxStore({ executor: fromDrizzle(db) }, outboxStorage),
  },
  {
    // Endpoints, deliveries and their log, in your database, in a schema of their own (nest_webhooks)
    provide: PostgresWebhookStore,
    inject: [getDrizzleToken(), WebhooksStorage],
    useFactory: (db: Database, webhooksStorage: WebhooksStorage) =>
      new PostgresWebhookStore({ executor: fromDrizzle(db) }, webhooksStorage),
  },
],

The executor is how the store reaches the database. fromDrizzle(db) runs the store’s statements through a Drizzle database, whichever driver it uses (the example ran on PGlite). The package also exports fromPg(pool), fromTypeOrm(dataSource), fromPrisma(prisma) and fromKysely(db) for a node-postgres pool, TypeORM, Prisma and Kysely, and fromMysql2(pool) on MySQL. Each executor has its own transaction object, which dispatch() and processInTransaction() then take:

Executor Takes Transaction object

fromDrizzle(db)

A Drizzle database

The tx that db.transaction() hands its callback

fromTypeOrm(dataSource)

A DataSource of type postgres or mysql

The EntityManager from dataSource.transaction(), or a QueryRunner after startTransaction()

fromPg(pool)

A node-postgres Pool

A client from pool.connect() after BEGIN (not the pool)

fromMysql2(pool)

A mysql2/promise pool

A connection from pool.getConnection() after beginTransaction()

fromPrisma(prisma, options)

A Prisma client through a driver adapter

The client that prisma.$transaction() hands its callback

fromKysely(db)

A Kysely instance

The transaction from db.transaction().execute()

With TypeORM, TypeOrmModule replaces DrizzleModule and the factories inject the DataSource (new PostgresWebhookStore({ executor: fromTypeOrm(dataSource) }, webhooksStorage)), and the service injects Webhooks<EntityManager>. The outbox’s store refuses the database itself (or dataSource.manager), whose statements would commit on their own, so the transaction handle is mandatory. On MySQL 8.4 or 9.x the stores come from @nestjs/webhooks/mysql and @nestjs/outbox/mysql (MySqlWebhookStore, MySqlOutboxStore); MySQL has no schemas inside a database, so the tables keep the schema name as a prefix (nest_webhooks_endpoints, nest_outbox_messages), and the stores refuse MariaDB and a non-strict sql_mode.

The tables the webhook store creates, in the nest_webhooks schema:

Table What it holds

nest_webhooks.endpoints

The partners' endpoints: URL, event types, tenant and secrets (a JSON array, newest first, with an expiry for secrets that were rotated out; sealed when encryption is set)

nest_webhooks.messages

What was dispatched, byte for byte (text, not jsonb, because the exact JSON that was signed must be replayable)

nest_webhooks.deliveries

One per message and endpoint, with its status, attempts and lease

nest_webhooks.delivery_attempts

The log: every attempt, with the status code, the response and how long it took

nest_webhooks.migrations

The versions of the store’s schema applied

Migrations are versioned and the stores apply them themselves at startup (the migrate store option defaults to true, except with NODE_ENV=production, in one transaction under an advisory lock). In production, apply them before the new version of the application starts, from the deploy step, with the package’s command lines (they read DATABASE_URL, or --url, and need pg installed):

npx nest-webhooks status                 # exits with 1 while the schema is behind: a CI check
npx nest-webhooks migrate
npx nest-outbox migrate
npx nest-webhooks sql --statement-breakpoints > drizzle/0003_webhooks.sql   # for your own migration tool

Source: Webhooks, "Keep webhooks in your database". The sql command and its flags are listed by npx nest-webhooks --help (for example sql --statement-breakpoints); migrate and status need a database server.

With migrate off, a store whose schema is behind fails the startup (and every call) with a WebhookSchemaError. There are no down migrations. On PostgreSQL the stores rely on READ COMMITTED, the default isolation level, and check it at startup.

The store contract

For another database, implement WebhookEndpointStore and WebhookDeliveryStore in a provider of your own. It is an ordinary singleton that calls registerSource() on the injectable WebhooksStorage in its constructor, naming the contracts it implements (endpoints, deliveries, or both). The registry checks the shape, refuses a second registration unless it passes replace: true, and locks when WebhooksModule initializes. No method takes your transaction: the outbox carries the message out of it. The official page lists which methods must be atomic (for example claimDeliveries with FOR UPDATE SKIP LOCKED, createDeliveries with a unique key on message and endpoint). Test it with the runner-agnostic suites from @nestjs/webhooks/testing:

test/webhooks-http.e2e-spec.ts
import { InMemoryWebhookStore } from '@nestjs/webhooks';
import { webhookDeliveryStoreContract, webhookEndpointStoreContract } from '@nestjs/webhooks/testing';

const harness = () => ({ store: new InMemoryWebhookStore() });
for (const c of webhookEndpointStoreContract(harness, { concurrent: true })) {
  it(`endpoint: ${c.name}`, c.run);
}
for (const c of webhookDeliveryStoreContract(harness, { concurrent: true })) {
  it(`delivery: ${c.name}`, c.run);
}

Source: Webhooks, "The store contract". The suites pass against the package’s own InMemoryWebhookStore; the official tutorial runs them against PostgresWebhookStore through Drizzle, on PGlite, PostgreSQL and MySQL.

Subscriptions and SSRF

A partner subscribes through your API with a URL, the event types it wants and a description. The controller below is a thin layer over WebhookEndpoints. Every call passes the partner’s id as the tenant: the endpoint is created for that tenant, listed for that tenant, and another partner’s endpoint id behaves as if it did not exist. In the example the partner id comes from a header, as a stand-in for a guard that authenticates an API key or a token (see Authentication and Guards):

src/webhooks/webhook-endpoints.controller.ts (abridged)
@Controller('partner/webhook-endpoints')
export class WebhookEndpointsController {
  constructor(private readonly webhookEndpoints: WebhookEndpoints) {}

  /** Returns the endpoint and its secret. The secret is shown this once. */
  @Post()
  create(@CurrentPartner() partner: string, @Body() dto: CreateEndpointDto) {
    return this.webhookEndpoints.create({
      url: dto.url,
      eventTypes: dto.eventTypes,
      description: dto.description ?? null,
      tenant: partner,
    });
  }

  @Get()
  list(@CurrentPartner() partner: string) {
    return this.webhookEndpoints.list({ tenant: partner });
  }

  @Patch(':id')
  update(@CurrentPartner() partner: string, @Param('id') id: string, @Body() dto: UpdateEndpointDto) {
    return this.webhookEndpoints.update(id, dto, { tenant: partner });
  }

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  async delete(@CurrentPartner() partner: string, @Param('id') id: string) {
    await this.webhookEndpoints.delete(id, { tenant: partner });
  }
}

Source: Webhooks, "Let partners subscribe". The DTOs are plain class-validator classes (url as a string, eventTypes as an array of strings), because the package checks the URL and the types itself; see Pipes and validation. The official page shows every DTO a second time as a Zod schema with @Body({ schema }) and StandardSchemaValidationPipe, which Nest 12.1 supports.

create() returns the endpoint together with its secret, a Standard Webhooks whsec_… value of 32 random bytes, generated by the package. The API shows it this once: list() and get() never include it, and a partner that lost it rotates. The package stores the secret (sealed when encryption is set) because it signs with it, and getSecret() reads the current one back, for a "reveal" button behind a fresh sign-in if the API offers one. Endpoints migrated from Svix or another Standard Webhooks sender keep their secrets: pass the partner’s existing whsec_… as secret to create() or to rotateSecret().

The package refuses what a partner may not subscribe to, with an InvalidWebhookEndpointError that carries a 400 status: a URL that is not https:// (or http:// with allowHttp), that carries credentials, or whose host is a blocked address such as https://169.254.169.254/latest/meta-data/ or https://localhost/, and an event type that is not in eventTypes. A not-found endpoint or delivery of another tenant is a WebhookEndpointNotFoundError or WebhookDeliveryNotFoundError with a 404 status. All of them extend WebhooksError, and one exception filter turns them into HTTP responses:

src/webhooks/webhooks-error.filter.ts
@Catch(WebhooksError)
export class WebhooksErrorFilter extends BaseExceptionFilter {
  override catch(error: WebhooksError, host: ArgumentsHost) {
    const status = (error as { status?: number }).status;
    super.catch(status ? new HttpException(error.message, status, { cause: error }) : error, host);
  }
}

Registered as { provide: APP_FILTER, useClass: WebhooksErrorFilter } in the module, as in Exception filters and error handling. Source: Webhooks, "Let partners subscribe".

The behaviour confirmed by the e2e test, with the responses a partner sees:

curl -s -X POST localhost:3000/partner/webhook-endpoints \
  -H 'x-partner-id: northside' \
  --json '{"url":"https://169.254.169.254/latest/meta-data/","eventTypes":["*"]}'
{"statusCode":400,"message":"Invalid webhook URL: 169.254.169.254 is link-local (cloud metadata)"}

An event type outside eventTypes gets a 400 whose message lists the known types (Known: order.shipped, order.cancelled), and an endpoint of another partner gets a 404. Source: Webhooks, "Try it".

SSRF protection is not only checked at subscription time. The URL is untrusted input, and DNS can change after it was accepted, so the default transport resolves the host itself, checks every address it resolves to, pins the socket to the checked address, never follows redirects and ignores proxy settings in the environment. delivery.allowHttp and delivery.allowPrivateNetworks are off by default: only public https:// endpoints are accepted. Turn them on in development only (the official tutorial uses WEBHOOKS_ALLOW_LOCAL=1 for a receiver on the same machine). Link-local and cloud metadata addresses stay blocked even then. Use delivery.allowedAddresses (CIDR ranges such as 10.20.0.0/16) for the one private range you really deliver to, and alert on the destination-blocked event: a customer pointing a webhook at your metadata service is worth a look. For the equivalent when your own code calls arbitrary URLs, see HTTP client.

Sending after the commit

Webhooks.dispatch() takes your transaction handle first, like Outbox.add(). With Drizzle that is the tx that db.transaction() passes to its callback, and the service injects Webhooks<Transaction> so the handle is typed. tenant is the partner that owns the data, so only that partner’s endpoints receive the message:

src/webhooks/orders.service.ts (abridged)
@Injectable()
export class OrdersService {
  constructor(
    @InjectDrizzle() private readonly db: Database,
    private readonly webhooks: Webhooks<Transaction>,
  ) {}

  /** Cancels an order that has not shipped and tells the partner in the same transaction. */
  async cancelOrder(partnerId: string, id: string, reason: string): Promise<Order> {
    const order = await this.db.transaction(async (tx) => {
      const [row] = await tx
        .select()
        .from(orders)
        .where(and(eq(orders.id, id), eq(orders.partnerId, partnerId)))
        .for('update');
      if (!row) {
        throw new NotFoundException(`Order ${id} not found`);
      }
      if (row.status === 'shipped' || row.status === 'cancelled') {
        throw new ConflictException(`Order ${id} is ${row.status}`);
      }
      await tx.update(orders).set({ status: 'cancelled' }).where(eq(orders.id, id));

      // The transaction's handle: the webhook exists only if this transaction commits.
      const data: OrderCancelled = { orderId: id, reason };
      await this.webhooks.dispatch(tx, { type: 'order.cancelled', tenant: row.partnerId, data });
      return { ...row, status: 'cancelled' };
    });

    this.webhooks.notify(); // run the relay now instead of at the next poll
    return order;
  }
}

Nothing leaves the process inside the transaction. If it rolls back, the message never existed (the e2e test forces a failure after dispatch() and finds the order still placed and no request recorded). If it commits, the outbox relay picks the message up (notify() asks it to do so now instead of at its next poll) and hands it to the package’s fan-out, which creates one delivery per enabled endpoint of the tenant that subscribed to the type; the worker then signs and sends each. dispatch() accepts one message or an array, with type, data, tenant and an optional id, and throws a TypeError for an unknown type, data that is not JSON-serializable or an invalid id. This is the request the partner’s server receives, on every attempt of the delivery, with the same webhook-id and the exact body that was serialized at dispatch():

POST /store/webhooks HTTP/1.1
content-type: application/json
user-agent: NestJS-Webhooks/1.0
webhook-id: msg_<id>
webhook-timestamp: <epoch seconds>
webhook-signature: v1,<base64 HMAC-SHA256 of "msg_<id>.<timestamp>.<body>">

{"type":"order.shipped","timestamp":"<ISO 8601>","data":{"orderId":"...","trackingNumber":"TRK-4471-0001"}}

Source: Webhooks, "Send order.shipped and order.cancelled after the commit". The header names, the v1, signature prefix, the msg_ id prefix and the user agent are those of the requests recorded by InMemoryWebhookTransport in the e2e test.

The type and data fields belong to your event, and the timestamp in the body is when the message was dispatched (not when this attempt was signed, which webhook-timestamp records). Because the signature follows Standard Webhooks, any Standard Webhooks library can verify it on the partner’s side, or the same package can, as the section on receiving shows.

Retries, the delivery log and replay

A 2xx from the partner is a success. Anything else is a failed attempt: a 5xx, a 4xx, a 3xx (redirects are never followed), a timeout or a refused connection. The worker retries after the backoff and honors a Retry-After header in seconds or as an HTTP date, up to the backoff’s cap (any other value is ignored). On a 429, 502, 503 or 504 it also pauses that endpoint until then (five seconds without a header): the endpoint’s other deliveries wait without an attempt, so a partner asking for a breather gets one probe, not one per batch, while other partners' deliveries go on. Three answers end a delivery early: a 410 Gone, which also disables the endpoint (reason gone); a destination the transport refuses (the checks run again at every send, because DNS changes); and retry.retryIf returning false. After the last attempt, the delivery is failed with the reason exhausted.

An endpoint that fails every attempt for disableEndpointAfter is disabled with the reason failing, and WebhooksEvents emits endpoint-disabled: tell the partner. A success at any point resets the clock. A disabled endpoint, whether disabled by the worker or by the partner (enabled: false in the subscription update()), gets no delivery for anything dispatched while it stays disabled, and a replay does not bring those back, because a replay re-sends deliveries that exist. What does exist are deliveries that were pending when the endpoint was disabled: they fail with the reason endpoint-disabled, and after re-enabling, the partner can ask for those again as the failed deliveries they are.

Every attempt is logged: the status code, the duration, the first 4 KB of the response body (delivery.maxResponseSize) and the error. The partner reads its own log, and asks for one delivery or every failed one since an outage began to be sent again:

src/webhooks/webhook-deliveries.controller.ts (abridged)
@Controller('partner/webhook-deliveries')
export class WebhookDeliveriesController {
  constructor(private readonly webhookDeliveries: WebhookDeliveries) {}

  @Get()
  list(
    @CurrentPartner() partner: string,
    @Query('endpointId') endpointId?: string,
    @Query('status') status?: WebhookDeliveryStatus,
    @Query('type') type?: string,
  ) {
    return this.webhookDeliveries.list({ tenant: partner, endpointId, status, type });
  }

  /** The delivery, the message that was sent, and every attempt. */
  @Get(':id')
  async get(@CurrentPartner() partner: string, @Param('id') id: string) {
    const delivery = await this.webhookDeliveries.get(id, { tenant: partner });
    if (!delivery) {
      throw new NotFoundException(`Webhook delivery ${id} not found`);
    }
    return delivery;
  }

  /** Sends one delivery again with the same webhook-id. */
  @Post(':id/retry')
  @HttpCode(HttpStatus.OK)
  async retry(@CurrentPartner() partner: string, @Param('id') id: string) {
    return { retried: await this.webhookDeliveries.retry(id, { tenant: partner }) };
  }

  /** Retries every failed delivery, of one endpoint or all, since an outage began. */
  @Post('retry')
  @HttpCode(HttpStatus.OK)
  async retryFailed(@CurrentPartner() partner: string, @Body() dto: RetryDeliveriesDto) {
    const since = dto.since === undefined ? undefined : new Date(dto.since);
    const retried = await this.webhookDeliveries.retry({ endpointId: dto.endpointId, status: 'failed', since }, { tenant: partner });
    return { retried };
  }
}

Source: Webhooks, "Retries, the delivery log and replay". In the e2e test a 503 produced a pending delivery with attempts: 1 and lastStatusCode: 503, the clock was moved past the backoff, the next worker run delivered it, the log showed attempts [503, 200], and a replay appended a third attempt with the same webhook-id.

A retry starts a new round: attempts back to 0, sent now, with the same webhook-id and the same body. The log of the earlier rounds stays. Because the id is the same, a partner whose inbox kept it answers 2xx without running its handler, so a replay is safe for a delivered webhook too: a partner can ask for the last hour again after losing its own database and get only what it has not processed. The over-HTTP test confirmed this: a replayed delivery was answered 204 and the receiver’s handler did not run again.

Operators use the same services without a tenant: WebhookDeliveries.stats() (pending, due, leased and failed counts and lagMs, how long the most overdue delivery has waited, the number to alert on), retry() across tenants, and prune('30d'), which nothing calls for you (schedule it, see Events, scheduling and queues). WebhooksEvents.events$ emits delivered, retry-scheduled, delivery-failed, endpoint-disabled, destination-blocked and verification-failed, and each is also published on a node:diagnostics_channel channel named nestjs:webhooks:<type>:

app.get(WebhooksEvents).events$.subscribe((event) => {
  if (event.type === 'endpoint-disabled') {
    // tell the partner, by email, that the endpoint was switched off and why (event.reason)
  }
});

Source: Webhooks, "Reference" (Events). An e2e test that subscribes to events$ receives retry-scheduled, delivered, endpoint-disabled (after a 410) and verification-failed.

Receiving signed webhooks

Verification needs the body exactly as the sender signed it, so create the application with rawBody: true (shown above). It works on Express and on Fastify: test the Express path through supertest and the Fastify path through app.inject(). Senders go into the module’s receivers option, by the name the route will use. Each receiver takes:

Option Applies to Default Description

scheme

all

required

'standard', 'stripe', 'github', or an instance of a class extending WebhookSignatureScheme

secret

all

required

One secret, or an array while the sender rotates; every one is tried

tolerance

standard, stripe, custom

'5m'

The largest accepted distance between the signed timestamp and now, in either direction

header

stripe

'stripe-signature'

The signature header, for senders that copied Stripe’s scheme

id

stripe, github

the payload’s id; the X-GitHub-Delivery header

A function of the payload (and the headers for GitHub) that returns the deduplication id, of at most 255 characters

dedupe

all

true

Skips a webhook id this receiver already processed

consumer

all

webhooks:<name>

The inbox consumer name; keep it stable, at most 255 characters

@VerifyWebhook('payments') on a route (or a controller) verifies the request before the handler runs, and @IncomingWebhook() injects what was verified: the receiver’s name, the sender’s id for the webhook, the signed timestamp, the payload parsed from the signed bytes, the rawBody, and processInTransaction(). @WebhookPayload() injects the payload alone and takes pipes like @Body(). This handler marks an order paid exactly once:

src/webhooks/provider-webhooks.controller.ts (abridged)
@Controller('webhooks/payments')
export class PaymentProviderWebhooksController {
  constructor(
    @InjectDrizzle() private readonly db: Database,
    private readonly ordersService: OrdersService,
  ) {}

  @Post()
  @HttpCode(HttpStatus.OK)
  @VerifyWebhook('payments')
  async receive(@IncomingWebhook() webhook: IncomingWebhook<PaymentProviderEvent>) {
    const { type, data } = webhook.payload;
    if (type !== 'payment.succeeded') {
      return;
    }
    // Exactly once: the webhook's id is recorded in the same transaction as the order's new status.
    const result = await this.db.transaction(async (tx) =>
      webhook.processInTransaction(tx, () => this.ordersService.markPaid(tx, data.orderId, data.paymentId, data.amount)),
    );
    if (!result.duplicate) {
      // first time: log, notify, ...
    }
  }
}

What the decorator checks, in order: the three webhook- headers are present and well formed (a webhook-id of at most 255 characters); one v1, signature in webhook-signature matches the HMAC of id.timestamp.body with one of the configured secrets, compared in constant time (several secrets can be listed while the provider rotates); the signed timestamp is within the tolerance (5 minutes) of now, in either direction; and the body is JSON. A request that fails gets a 401 with a generic message, and the reason never goes to the sender: it goes to the verification-failed event with the receiver’s name. A signed body that is not JSON gets a 400 from the framework’s body parser before the decorator sees it. A request whose body was not kept raw gets a 415 if it is not JSON or a form, and a 500 that logs the missing rawBody: true otherwise.

The e2e test confirmed this behaviour on Express: a valid request returned 200; a tampered body (the verifier works on the raw bytes, so one extra space is enough), a wrong secret and a timestamp one hour old each returned 401 with the body {"message":"Webhook signature verification failed","error":"Unauthorized","statusCode":401}, and the verification-failed events carried the reasons invalid-signature and timestamp-out-of-tolerance.

Deduplication and exactly-once effects

Payment providers retry until they get a 2xx, so a timeout after the handler succeeded makes the sender repeat the same webhook-id. The receiver keeps an inbox of the ids it processed (the outbox’s inbox, consumer webhooks:<name>): a known id is answered with an empty 200 without running the handler, and an id is recorded only after the handler succeeded, so a handler that threw runs again on the sender’s retry. Two copies arriving together at one instance run the handler once. Two instances receiving them at the same moment could both run it, and a crash between the handler and the inbox write repeats it, which is what processInTransaction() is for: it records the webhook’s id through your transaction and runs the work only if the id is new, so the record commits together with the work’s writes. A redelivery, even one racing the first on another instance, resolves with duplicate: true and changes nothing; and when the work throws (a payment for the wrong amount), the transaction rolls back with the record, the sender gets the error, and its retry gets another chance. The e2e test sent the same signed webhook twice (the second answer was a 200 without a second status change, which would have been a 409 had the handler run) and confirmed that a webhook whose work threw a 409 was not recorded, so it ran again on retry.

A carrier that copied Stripe’s scheme

The stripe scheme verifies a t=<timestamp>,v1=<hex> header (the hex HMAC-SHA256 of t.body, keyed with the secret’s text) and takes the event id from inside the payload. Stripe itself uses the default header stripe-signature; a sender that copied the scheme under another header names it with header:

src/webhooks/provider-webhooks.controller.ts (carrier)
@Controller('webhooks/carrier')
export class CarrierWebhooksController {
  constructor(
    @InjectDrizzle() private readonly db: Database,
    private readonly ordersService: OrdersService,
    private readonly webhooks: Webhooks,
  ) {}

  @Post()
  @HttpCode(HttpStatus.OK)
  @VerifyWebhook('carrier') // receivers.carrier = { scheme: 'stripe', header: 'Carrier-Signature', secret }
  async receive(@IncomingWebhook() webhook: IncomingWebhook<CarrierEvent>) {
    const { type, data } = webhook.payload;
    if (type !== 'shipment.shipped') {
      return;
    }
    // One transaction: the order's status, the partner's order.shipped message (through the
    // outbox) and this webhook's inbox record commit together, or not at all.
    const result = await this.db.transaction(async (tx) =>
      webhook.processInTransaction(tx, () => this.ordersService.markShipped(tx, data.orderId, data.trackingNumber)),
    );
    if (!result.duplicate) {
      this.webhooks.notify(); // deliver order.shipped now instead of at the next poll
    }
  }
}

This is where both directions meet: marking the order shipped, the partner’s order.shipped message (dispatched on the same tx through markShipped()) and the inbox record for the carrier’s event commit together, or none of them does. The deduplication id is the event’s id from the signed payload, not a header, because that is what Stripe’s scheme signs. The test sent a signed carrier event twice and the partner received exactly one order.shipped message.

Other senders

GitHub’s webhooks take scheme: 'github': the X-Hub-Signature-256 header, the hex HMAC-SHA256 of the body keyed with the secret’s text. GitHub signs neither a timestamp nor an id, so a captured request verifies forever, and deduplication keys by default on the X-GitHub-Delivery header, which is not signed: the same request replayed with a new value in that header would run the handler again. Key the deduplication on the signed body instead, with the receiver’s id option:

receivers: {
  github: {
    scheme: 'github',
    secret: config.getOrThrow<string>('GITHUB_WEBHOOK_SECRET'),
    // The signed body, not the unsigned X-GitHub-Delivery header: a replayed request has a known id.
    id: (payload) => createHash('sha256').update(JSON.stringify(payload)).digest('hex'),
  },
},

Source: Webhooks, "Other senders". In the test, the same GitHub request replayed with a different x-github-delivery header was recognized and the handler ran once. The inbox remembers an id until OutboxInbox.prune() removes it, so for GitHub its window is also how long a replay is caught, and accepting GitHub’s webhooks only from the addresses it publishes narrows who can send one.

A sender with a scheme of its own is a class extending WebhookSignatureScheme. This marketplace signs the URL it posts to followed by the body, and puts the event’s id in the body:

src/webhooks/marketplace.scheme.ts
export class MarketplaceScheme extends WebhookSignatureScheme {
  constructor(private readonly url: string) {
    super();
  }

  verify({ headers, rawBody }: WebhookSignedRequest, keys: readonly Buffer[]): WebhookSignatureCheck {
    const signature = headers['x-marketplace-signature'];
    if (signature === undefined) {
      return { valid: false, reason: 'missing-header', detail: 'no x-marketplace-signature header' };
    }
    if (typeof signature !== 'string') {
      return { valid: false, reason: 'malformed-header', detail: 'x-marketplace-signature is repeated' };
    }

    const candidates = [Buffer.from(signature, 'base64')];
    for (const key of keys) {
      const expected = createHmac('sha256', key).update(this.url).update(rawBody).digest();
      // In constant time, never with ===.
      if (WebhookSignatureScheme.matches(expected, candidates)) {
        return { valid: true };
      }
    }
    return { valid: false, reason: 'invalid-signature', detail: 'no signature matches a configured secret' };
  }

  /** The deduplication id: the event's, from the signed body. */
  override idFromPayload(payload: unknown): string | undefined {
    const id = (payload as { eventId?: unknown } | null)?.eventId;
    return typeof id === 'string' ? id : undefined;
  }
}

The receiver takes an instance, with the secret from configuration:

marketplace: {
  scheme: new MarketplaceScheme('https://api.store.example.com/webhooks/marketplace'),
  secret: config.getOrThrow<string>('MARKETPLACE_WEBHOOK_SECRET'),
},

Source: Webhooks, "Other senders". verify(request, keys) gets the request’s headers (lower-case names), its rawBody and one key per configured secret, runs synchronously before the body is parsed as JSON, and returns valid: true (optionally with the sender’s id and the signed timestamp in epoch seconds, to which tolerance then applies) or valid: false with a reason for the verification-failed event. WebhookSignatureScheme.matches(expected, candidates) compares in constant time; never compare signatures with ===. idFromPayload(payload, headers) gives the deduplication id when no header carries one (an id longer than 255 characters is refused with malformed-header), and key(secret) turns each configured secret into the key verify() receives, once, at startup (the default takes the secret’s text and refuses an empty one). The test signed requests with a hand-computed HMAC: valid ones returned 200, a repeated eventId ran the handler once, and a wrong or missing signature returned 401.

A request that did not arrive on an HTTP route, such as one taken from a queue, is checked with WebhookVerifier.verify(receiver, { headers, rawBody }). It throws a WebhookVerificationError (status 401, or 400 for invalid-payload) that carries the receiver and the reason.

A service that only receives

A partner such as the reseller in the example runs its own Nest application that only receives the store’s webhooks. outgoing: false leaves out the endpoints, deliveries, worker and stores; the outbox’s inbox still deduplicates, in memory by default and on the partner’s own database in production:

src/webhooks/receiver-app.module.ts
@Controller('store')
export class StoreWebhooksController {
  @Post('webhooks')
  @HttpCode(HttpStatus.NO_CONTENT)
  @VerifyWebhook('store')
  receive(@IncomingWebhook() webhook: IncomingWebhook<StoreEvent>) {
    // webhook.payload.type is 'order.shipped' or 'order.cancelled'; webhook.id is the webhook-id
  }
}

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    OutboxModule.forRoot({ relay: { enabled: false } }),
    WebhooksModule.forRootAsync({
      outgoing: false,
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        receivers: {
          // A comma-separated list: while the store rotates, both secrets are accepted.
          store: { scheme: 'standard', secret: config.getOrThrow<string>('STORE_WEBHOOK_SECRET').split(',') },
        },
      }),
    }),
  ],
  controllers: [StoreWebhooksController],
})
export class ReceiverAppModule {}

Source: Webhooks, "Send order.shipped and order.cancelled after the commit" (the partner’s receiver). The test posted requests signed with the current secret and with the previous secret (both listed) and got 204; a replayed webhook-id ran the handler once, and a modified body was a 401.

Secret rotation

A secret leaks, or a partner’s policy says a year is enough. Rotating returns a new secret, and the old one keeps signing next to it for secretRotationOverlap (24 hours by default): every delivery carries two v1, signatures separated by a space, and a Standard Webhooks verifier accepts any one that matches. The partner adds the new secret to its list, deploys, and removes the old one, on its own schedule. After the overlap only the new secret signs. rotateSecret() with overlap: 0 retires the old one at once, for a leaked secret:

// The controller's POST :id/rotate-secret
const secret = await this.webhookEndpoints.rotateSecret(id, { tenant: partner });

// A leaked secret: no overlap, the old one stops signing immediately
const replacement = await this.webhookEndpoints.rotateSecret(id, { tenant: partner, overlap: 0 });

Source: Webhooks, "Rotate secrets". The test confirmed both: after a rotation the recorded request had two signatures and isSignedWith() accepted the old and the new secret; after overlap: 0 it had one signature and the old secret no longer matched.

Set WEBHOOKS_ENCRYPTION_KEYS (the official tutorial generates each key with openssl rand -base64 32, newest first) to seal secrets at rest through the encryption option; endpoints created before are still read and are sealed at their next rotation. Keep the keys in a secret manager. The rotation of the receiving side works the same way in reverse: list the old and the new secret in receivers.<name>.secret while the provider rotates.

Transports

The worker sends through the WebhookTransport provider. The default, HttpWebhookTransport, is built from the delivery options and does one POST per attempt over node:http and node:https. Its constructor takes two options that delivery does not: ca, certificate authorities to trust besides Node’s own (for endpoints behind a private CA), and lookup, a replacement for the system resolver (every address it returns is still checked). Pass an instance as transport, with the same allowHttp, allowPrivateNetworks and allowedAddresses as delivery; delivery still decides which URLs WebhookEndpoints accepts, and the transport decides what it connects to:

WebhooksModule.forRoot({
  delivery: { allowedAddresses: ['10.20.0.0/16'] },
  transport: new HttpWebhookTransport({
    allowedAddresses: ['10.20.0.0/16'],
    ca: readFileSync('/etc/ssl/partners-ca.pem'),
  }),
}),

Source: Webhooks, "Reference" (Transports). The module definition (with the allow-list) was built in the test; the ca option was not exercised against a private CA.

A transport of your own extends WebhookTransport and implements send(request, options), where options carries the attempt’s signal and its attempt number; it resolves with the response’s status code, headers and the start of its body whatever the status, and throws only when there is no response. NonRetryableWebhookError and WebhookDestinationBlockedError fail the delivery without a retry. A custom transport replaces the SSRF guard with whatever it does itself, so one that sends through an egress proxy that enforces it is a reason to write one, while wrapping HttpWebhookTransport keeps the guard. InMemoryWebhookTransport is the test double described next.

Testing

In an e2e test, keep the outbox, both stores, the migrations, the fan-out, the signing and the verification real, and replace only the network. Turn the relay and the worker off with the options the module reads (OUTBOX_RELAY=off, WEBHOOKS_WORKER=off in the example), drive them by hand with relay.runOnce() and worker.runOnce(), override WebhookTransport with InMemoryWebhookTransport, and sign the providers' requests with signWebhook() from @nestjs/webhooks/testing:

test/webhooks.e2e-spec.ts (abridged)
const transport = new InMemoryWebhookTransport(); // records requests instead of sending them

const moduleRef = await Test.createTestingModule({ imports: [WebhooksAppModule] })
  .overrideProvider(WebhookTransport)
  .useValue(transport)
  .compile();
const app = moduleRef.createNestApplication({ rawBody: true });
await app.init();
const relay = app.get(OutboxRelay);
const worker = app.get(WebhookWorker);

/** Publishes committed messages (the fan-out creates the deliveries), then runs one worker batch. */
const deliver = async () => {
  await relay.runOnce();
  return worker.runOnce();
};

it('sends order.cancelled only after the commit', async () => {
  const order = await app.get(OrdersService).placeOrder('northside', 1599);
  await app.get(OrdersService).cancelOrder('northside', order.id, 'out of stock');
  expect(transport.sent).toEqual([]); // nothing until the outbox relays the commit

  expect(await deliver()).toMatchObject({ claimed: 1, delivered: 1 });
  const sent = transport.single({ type: 'order.cancelled' });
  expect(sent.data).toEqual({ orderId: order.id, reason: 'out of stock' });
  expect(sent.isSignedWith(secret)).toBe(true);
});

it('verifies a signed provider webhook', async () => {
  const paid = signWebhook({
    scheme: 'standard',
    secret: PAYMENTS_SECRET,
    payload: { type: 'payment.succeeded', data: { paymentId: 'pay_1', orderId: order.id, amount: 4998 } },
  });
  await request(app.getHttpServer()).post('/webhooks/payments').set(paid.headers).send(paid.body).expect(200);
});
  • InMemoryWebhookTransport answers 200 and keeps every request as a SentWebhook (sent, filter(query), single(query), clear()); respondWith(503), a response object (statusCode, headers such as retry-after, body) or a function of the request and the attempt number simulates a partner that is down, slow or picky, and a function may throw to simulate no response.

  • A SentWebhook has url, type, messageId, endpointId, deliveryId, headers, body, data (the body’s data, parsed), the attempt number within its round, and isSignedWith(secret), which checks a v1 signature against an endpoint’s secret.

  • worker.runOnce() resolves to how many deliveries were claimed, delivered, retried, failed and released and how many lost their lease (leaseLost), and works with the worker stopped.

  • signWebhook() produces the body and headers the payment provider (standard), Stripe or GitHub would send. Reuse its id for a redelivery, move timestamp to test the tolerance, change a byte of the body for a tampered request.

  • For behaviour that depends on time, such as backoff, leases and the rotation overlap, move Date.now() forward with vi.spyOn(Date, 'now'): the worker and the stores take the time from it. The retry test above does exactly that, advancing 60 seconds past the first backoff.

The official tutorial’s own tests replace the database with PGlite and run the real migrations; the e2e tests behind this page used the same idea with DrizzleModule creating the in-process database and the stores migrating their schemas at startup.

Delivery guarantees

Outgoing webhooks are delivered at least once, and the receiver deduplicates by webhook-id:

Situation Outcome

The order’s transaction rolls back

The message never existed and nothing is sent

The order’s transaction commits

One delivery per subscribed endpoint of the tenant, eventually delivered or failed with its attempt log

The outbox redelivers the message (a relay crashed after the fan-out)

The fan-out is idempotent: one delivery per message and endpoint

The endpoint was created after the message, or is disabled

It does not receive it, and a replay does not bring it back

A worker dies after claiming

Sent by another worker once the lease expires (worker.lease, 1 minute)

A worker dies after sending, before recording

Sent twice, with the same webhook-id, once the lease expires

Two workers, same delivery

A claim is exclusive for the lease, and every later write is fenced by it

The partner answers 2xx

Delivered

It answers anything else, or nothing

Retried with backoff, Retry-After honored; 429, 502, 503 and 504 pace the endpoint

It answers 410 Gone

Failed (rejected), and the endpoint is disabled (gone)

Every attempt fails for disableEndpointAfter

The endpoint is disabled (failing), once

A partner asks for a replay

A new round with the same webhook-id, so its inbox skips it

Deliveries have no order. A worker sends one endpoint’s deliveries one at a time, most overdue first, but a failed attempt waits out its backoff while later messages go out, several workers send to the same endpoint at once, and a replay sends an old message now. Two updates of one order can reach the partner in either order, so a receiver ignores an event older than the state it already holds (the body’s timestamp is when the message was dispatched) or treats the webhook as a signal and reads the current state from the API. Incoming webhooks run their handler once per webhook id and receiver in the common case with the default inbox, and their processInTransaction() work exactly once. See Delivery Guarantees for the general vocabulary.

The established alternative: a hand-written guard

Before @nestjs/webhooks, the pattern for receiving was a guard that checks an HMAC on the raw body. It is still a valid choice for a single sender with a simple scheme and no need for a delivery log, and it is the right size when the package’s pre-1.0 status is a concern. The essentials are the same as the package’s: keep the raw body (rawBody: true), compute the HMAC over the exact bytes, compare in constant time with timingSafeEqual (compare lengths first, because it throws on different lengths), and reject old timestamps. Deduplication, retries and the delivery log are then yours to build:

src/webhooks/hmac-webhook.guard.ts
@Injectable()
export class HmacWebhookGuard implements CanActivate {
  constructor(private readonly config: ConfigService) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest<RawBodyRequest<{ headers: Record<string, string | undefined> }>>();
    const timestamp = request.headers['x-signature-timestamp'];
    const signature = request.headers['x-signature'];
    if (!request.rawBody) {
      throw new BadRequestException('Create the application with rawBody: true');
    }
    if (!timestamp || !signature || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
      throw new UnauthorizedException();
    }
    const expected = createHmac('sha256', this.config.getOrThrow<string>('MANUAL_WEBHOOK_SECRET'))
      .update(`${timestamp}.`)
      .update(request.rawBody)
      .digest();
    const received = Buffer.from(signature, 'hex');
    // timingSafeEqual throws when the lengths differ, so compare them first.
    if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
      throw new UnauthorizedException();
    }
    return true;
  }
}

Source: Raw body (the RawBodyRequest type and rawBody: true) and Guards; the HMAC check itself uses node:crypto and is not from the official docs. The test confirmed that the exact signed body passes and that a re-serialized body (the same JSON without the space) is refused, as are a wrong signature and a stale timestamp. See Guards and Custom decorators for wiring a guard and a decorator together.

Production checklist

  • Keep the endpoints and the deliveries in your application’s database, on a database server: register PostgresWebhookStore or MySqlWebhookStore and the outbox’s store next to it. The production guard refuses to start in memory; allowInMemoryStorage is for a process whose pending deliveries you can afford to lose.

  • Apply both stores' migrations before a new version starts (npx nest-webhooks migrate, npx nest-outbox migrate, or your migration tool) and check them in CI with status. With NODE_ENV=production the stores do not migrate at startup, and a schema that is behind fails it instead.

  • On PostgreSQL keep the default isolation at READ COMMITTED. On MySQL, run a transaction again when MySQL breaks a deadlock in it (error 1213): it rolled back, with the messages it dispatched and the webhooks it recorded.

  • Leave delivery.allowHttp and delivery.allowPrivateNetworks off. Use allowedAddresses for the one private range you really deliver to, and alert on destination-blocked.

  • Run the worker in a few instances (the worker.enabled option), not in every API pod: each claim is a short transaction and the leases keep workers from sending the same delivery twice.

  • Alert on lagMs and failed from deliveries.stats() and on endpoint-disabled, and tell the partner by email that the endpoint was switched off and why.

  • Version the payloads. A partner keeps its handler for years, and retry() re-sends the body as it was dispatched. Add fields to a type’s data, never rename or remove one. For a breaking change add a new type (order.shipped.v2 in eventTypes) and dispatch both in the same transaction (dispatch() takes an array) while partners move their subscriptions; an endpoint subscribed to * gets both.

  • Give partners the delivery log and the replay: it answers "did you send it?" before they open a ticket.

  • Rotate secrets with the overlap, never with overlap: 0 unless a secret leaked, and seal them at rest with encryption.

  • Prune the log: deliveries.prune('30d') from a scheduled job, because nothing schedules it for you.

  • For incoming webhooks keep rawBody: true, keep the receivers' secrets in the environment (loaded through ConfigService), and list the old and the new secret while a provider rotates. Prune the inbox with OutboxInbox.prune() on a window longer than any provider’s retry schedule.

  • Keep provider handlers short and idempotent in their own right (a status check inside the transaction), even with processInTransaction(): the inbox protects against a redelivery, the check against a provider that sends two different events for one fact.

  • Call app.enableShutdownHooks() so a deploy lets attempts in flight finish and releases the rest.

Where webhooks fit in Bookshelf

In the Bookshelf scenario used across this section, webhooks are how the application tells the outside world what it cannot reach over a WebSocket or a microservice transport: a publisher or a library partner could subscribe (tenant is the partner) to events such as book.published, and the service that publishes a book would dispatch book.published in the same transaction that sets the book’s state (see Data and TypeORM for the transactions); this scenario is illustrative and was not built. The cover-upload flow and the in-process events described in Events, scheduling and queues stay inside the application; webhooks are the same idea across a trust boundary, with signatures, retries and a log because the receiver is not yours. For one-way streaming to browsers use Server-Sent Events instead.

What changed recently, and where the book is outdated

  • @nestjs/webhooks did not exist when the 2025 book listed in the bibliography was written: it was first published on 2026-10-01 as 0.0.1, one of several first-party packages introduced in the same period (authentication, authorization, HTTP client, i18n, mail, storage, Drizzle, resilience, idempotency, outbox, workflows, locks). Before it, webhooks in Nest meant writing the guard shown above, a queue-based retry loop and a delivery table by hand, and any material from before the package should be read as covering only that hand-written approach.

  • The docs page lives under /http/webhooks, after the September 2026 restructure of the documentation (/http/, /reliability/, /application/*).

  • Nest 12.1 validates request bodies with any Standard Schema object (Zod, Valibot, ArkType) through @Body({ schema }) and StandardSchemaValidationPipe, which the webhook controllers can use instead of class-validator DTOs (the official webhooks page shows both styles).

  • All @nestjs/* 12 packages are ESM-only, so the .js import extensions in the examples are what the default ESM project template uses; older material written for CommonJS projects omits them.

  • Express 5 and Fastify 5 are the supported platforms; rawBody: true works on both.

  • Because the package is pre-1.0 (and @nestjs/outbox too), pin exact versions, read the release notes on every upgrade and keep the hand-written guard in mind as the fallback for the receiving side.