nestjs-notifications
Channels

Webhook

Deliver notifications as webhooks. A signed POST to one URL over @nestjs/webhooks' SSRF-guarded transport, or fan-out to subscribed endpoints through @nestjs/webhooks.

The webhook channel delivers a notification as a webhook, two ways:

  • Direct: return a plain object (the JSON body) or a WebhookMessage, and the channel POSTs it to one URL through @nestjs/webhooks' HttpWebhookTransport: https only, every resolved address checked against private ranges and pinned for the connection, redirects never followed. A non-2xx answer fails the delivery.
  • Subscriptions: return a WebhookEvent, and the channel hands it to @nestjs/webhooks' Webhooks.dispatch(), which delivers it to every endpoint subscribed to its type, with retries, a delivery log and Standard Webhooks signatures. Endpoints, secrets, retries and the worker are configured as the NestJS webhooks docs describe.

Install

pnpm add @dudousxd/nestjs-notifications-webhook @nestjs/webhooks @nestjs/outbox
npm install @dudousxd/nestjs-notifications-webhook @nestjs/webhooks @nestjs/outbox

@nestjs/outbox is needed only for subscription delivery.

Register the channel

app.module.ts
import { WebhookChannelModule } from '@dudousxd/nestjs-notifications-webhook';

@Module({
  imports: [
    WebhookChannelModule.forRoot({
      url: 'https://example.com/hooks/notifications',
      secret: process.env.WEBHOOK_SECRET, // whsec_…
      signing: 'standard',
    }),
  ],
})
export class AppModule {}

WebhookChannelModule.forRoot() takes:

OptionTypeDefaultDescription
urlstring—Default target URL, used when neither the message nor the route supplies one.
headersRecord<string, string>—Default headers merged into every request.
secretstring—Signing secret. When set, every direct request is signed.
signing'sha256' | 'standard''sha256''sha256': X-Signature-256: sha256=<hex HMAC>. 'standard': Standard Webhooks headers.
signatureHeaderstringX-Signature-256Header for the 'sha256' signature.
timeoutMsnumber15000Abort a direct request that takes longer than this.
deliveryHttpWebhookTransportOptions—HttpWebhookTransport's policy: allowHttp, allowPrivateNetworks, allowedAddresses, ca, maxResponseSize, lookup.
transportWebhookTransportHttpWebhookTransportAnother @nestjs/webhooks transport (a proxy, mTLS, InMemoryWebhookTransport in tests).
transaction(work) => Promise—Opens a transaction for subscription delivery, e.g. (work) => db.transaction(work).
resolveOptions(tenant: string) => WebhookChannelOptions—Per-tenant options resolver. See Per-tenant config.
globalbooleantrueRegister globally so the channel is discoverable app-wide.

Standard Webhooks signing

With signing: 'standard' and a whsec_… secret, each request carries webhook-id, webhook-timestamp and webhook-signature: v1,…. A NestJS receiver verifies it with @VerifyWebhook() and a scheme: 'standard' receiver configured with the same secret (docs). The webhook-id is the delivery's idempotency key when it has one (the outbox message id), so a redelivery keeps its id and the receiver's inbox drops the duplicate.

sha256 signing

The default, kept for existing receivers: X-Signature-256: sha256=<hex HMAC-SHA256 of the body>. The receiver recomputes it over the raw body and compares in constant time:

receiver — verify the signature
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody: string, header: string, secret: string): boolean {
  const expected = `sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`;
  return header.length === expected.length &&
    timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}

The notification side

Annotate the payload method with the @Webhook() handle. Return a plain object to POST it as the JSON body:

order-paid.notification.ts
import { type Notifiable, Notification } from '@dudousxd/nestjs-notifications-core';
import { Webhook } from '@dudousxd/nestjs-notifications-webhook';

@Notification()
export class OrderPaid {
  constructor(private orderId: number) {}

  @Webhook()
  toWebhook({ notifiable }: ChannelContext): Record<string, unknown> {
    return { event: 'order.paid', id: this.orderId };
  }
}

The Webhook handle also works as a via() token (via() { return [Webhook]; }) for explicit routing; implement WebhookNotification alongside the decorator for compile-time checks on toWebhook().

WebhookMessage builder

Return a WebhookMessage instead of a plain object when you need control over the request itself — a per-notification URL or extra headers. Requests are always POST. Each method returns this:

order-paid.notification.ts
import { Webhook, WebhookMessage } from '@dudousxd/nestjs-notifications-webhook';

@Webhook()
toWebhook({ notifiable }: ChannelContext): WebhookMessage {
  return new WebhookMessage()
    .url('https://example.com/hooks/orders')
    .header('X-Order-Source', 'checkout')
    .payload({ event: 'order.paid', id: this.orderId });
}
MethodEffect
.url(u)Set the target URL (overrides the route and configured default).
.payload(obj)Set the JSON body.
.header(key, value)Set a single request header.
.headers(obj)Merge multiple request headers.

Every request sends content-type: application/json; message headers override the module's default headers (names are sent lower-case). A non-2xx response throws. The channel's response is { id, statusCode }.

URL resolution

The channel resolves the target URL in order, using the first one available:

  1. The WebhookMessage URL (.url(...)).
  2. routeNotificationFor('webhook'), when it returns a URL string.
  3. The module's url default.
endpoint.ts
export class Endpoint implements Notifiable {
  constructor(public hookUrl: string) {}

  routeNotificationFor(channel: string) {
    if (channel !== 'webhook') return undefined;
    return this.hookUrl;
  }
}

If none of the three supply a URL, the channel throws asking you to set one — on the WebhookMessage via .url(), from routeNotificationFor('webhook'), or as url in forRoot().

Subscription delivery

Return a WebhookEvent to deliver to the endpoints your customers subscribed, instead of one URL. WebhooksModule and OutboxModule are configured as in the NestJS webhooks docs; the channel needs a way to open a transaction, because Webhooks.dispatch() writes the event to the outbox through one:

app.module.ts
OutboxModule.forRoot(),
WebhooksModule.forRoot({ eventTypes: ['invoice.paid'] }),
WebhookChannelModule.forRoot({ transaction: (work) => db.transaction(work) }),
invoice-paid.notification.ts
import { Webhook, WebhookEvent } from '@dudousxd/nestjs-notifications-webhook';

@Webhook()
toWebhook() {
  return new WebhookEvent('invoice.paid', { id: this.invoice.id });
}

The event goes to the endpoints of the delivery's tenant (forTenant(id) or @Tenant(); null without one). The channel's response is the dispatched WebhookMessage; the per-endpoint outcome is in @nestjs/webhooks' delivery log.

Retries of a subscription delivery

A delivery with an idempotencyKey (the outbox dispatcher's message id) dispatches with a webhook-id derived from that key. A webhook-id may only contain letters, digits, _ and -, up to 128 characters, so:

  • a key made of those characters that fits is sent as msg_<key>;
  • any other key is hashed and sent as msgh_<sha256 hex>, so distinct keys never share an id.

Direct delivery (a WebhookMessage to a URL) still sends the raw key as the webhook-id.

@nestjs/webhooks writes the event to the outbox under that id, so dispatching it twice would add a duplicate outbox id (an error while the first message is still pending). The channel therefore records the webhook-id in the outbox inbox (consumer aviary.notifications.webhook-dispatch, exported as WEBHOOK_DISPATCH_CONSUMER) through OutboxInbox.processInTransaction(), in the same transaction as Webhooks.dispatch(): the attempt whose transaction commits is the only one that dispatches. A retry of it dispatches nothing, succeeds, and responds { id, duplicate: true }.

Use a transaction runner on the same database as your outbox store, so the record and the event commit together.

Per-tenant config

In a multi-tenant app each tenant can post to its own endpoint with its own headers. Pass resolveOptions — when a send is scoped to a tenant (via forTenant(id) or a @Tenant() property), the channel uses the options you return (default url, headers) instead of the module defaults:

app.module.ts
WebhookChannelModule.forRoot({
  url: 'https://example.com/hooks/notifications',
  resolveOptions: (tenant) => ({
    url: tenantHooks.get(tenant),
    headers: { Authorization: `Bearer ${tenantTokens.get(tenant)}` },
  }),
});

The channel keeps one HttpWebhookTransport per tenant and reuses it. It builds a new one only when that tenant's delivery options change (compared by value, so a resolver that returns a fresh object with the same settings reuses the transport).

Sends with no tenant scope keep the module defaults. See Multi-tenancy for the full picture.

On this page