Webhook
Deliver notifications as an HTTP request to any endpoint. Return a plain object for a JSON POST, or a fluent WebhookMessage for full control over url, method, and headers.
The webhook channel POSTs a notification to an HTTP endpoint. Return a plain object and it becomes
the JSON body; return a fluent WebhookMessage when you need to set the URL per notification, switch
the method, or sign the request with custom headers. Delivery uses the platform fetch.
Install
pnpm add @dudousxd/nestjs-notifications-webhooknpm install @dudousxd/nestjs-notifications-webhookRegister the channel
import { WebhookChannelModule } from '@dudousxd/nestjs-notifications-webhook';
@Module({
imports: [
WebhookChannelModule.forRoot({
url: 'https://example.com/hooks/notifications',
headers: { Authorization: `Bearer ${process.env.WEBHOOK_TOKEN}` },
}),
],
})
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 | — | HMAC-SHA256 secret. When set, every request is signed (see below). |
signatureHeader | string | X-Signature-256 | Header the signature is sent in. |
resolveOptions | (tenant: string) => WebhookChannelOptions | — | Per-tenant options resolver. See Per-tenant config. |
global | boolean | true | Register globally so the channel is discoverable app-wide. |
Signing requests (HMAC)
A receiver can't tell a real webhook from a forged one unless it's signed. Set a secret and every
request gets an X-Signature-256: sha256=<hex> header — an HMAC-SHA256 of the exact JSON body:
WebhookChannelModule.forRoot({
url: 'https://example.com/hooks/notifications',
secret: process.env.WEBHOOK_SIGNING_SECRET, // turns on signing
});The receiver recomputes the digest over the raw request body and compares (use a constant-time compare), matching the GitHub/Stripe webhook convention:
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 signature is computed over the serialized body, so verify against the raw request body, not
a re-stringified parsed object. Per-tenant secrets work through resolveOptions — return a
different secret per tenant.
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, a non-POST method, or signed headers. 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-Signature', sign(this.orderId))
.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. |
.method(m) | Set the HTTP method — POST (default), PUT, PATCH, DELETE, GET. |
.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. A non-2xx response throws.
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().
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)}` },
}),
});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.