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 channelPOSTs 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/outboxnpm install @dudousxd/nestjs-notifications-webhook @nestjs/webhooks @nestjs/outbox@nestjs/outbox is needed only for subscription delivery.
Register the channel
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:
| Option | Type | Default | Description |
|---|---|---|---|
url | string | — | Default target URL, used when neither the message nor the route supplies one. |
headers | Record<string, string> | — | Default headers merged into every request. |
secret | string | — | Signing secret. When set, every direct request is signed. |
signing | 'sha256' | 'standard' | 'sha256' | 'sha256': X-Signature-256: sha256=<hex HMAC>. 'standard': Standard Webhooks headers. |
signatureHeader | string | X-Signature-256 | Header for the 'sha256' signature. |
timeoutMs | number | 15000 | Abort a direct request that takes longer than this. |
delivery | HttpWebhookTransportOptions | — | HttpWebhookTransport's policy: allowHttp, allowPrivateNetworks, allowedAddresses, ca, maxResponseSize, lookup. |
transport | WebhookTransport | HttpWebhookTransport | Another @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. |
global | boolean | true | Register 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:
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:
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:
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 });
}| Method | Effect |
|---|---|
.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:
- The
WebhookMessageURL (.url(...)). routeNotificationFor('webhook'), when it returns a URL string.- The module's
urldefault.
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:
OutboxModule.forRoot(),
WebhooksModule.forRoot({ eventTypes: ['invoice.paid'] }),
WebhookChannelModule.forRoot({ transaction: (work) => db.transaction(work) }),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:
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.
Server-Sent Events
Push notifications to the browser over native NestJS Server-Sent Events. This channel feeds an SseHub; you mount the stream with Nest's own @Sse() decorator — tenant-aware.
SMS
Text a notification through a pluggable SMS transport. Ships with a Twilio transport; return a plain string or a fluent SmsMessage, and route the recipient per notifiable.