Aviary
Channels

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-webhook
npm install @dudousxd/nestjs-notifications-webhook

Register the channel

app.module.ts
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:

OptionTypeDefaultDescription
urlstringDefault target URL, used when neither the message nor the route supplies one.
headersRecord<string, string>Default headers merged into every request.
secretstringHMAC-SHA256 secret. When set, every request is signed (see below).
signatureHeaderstringX-Signature-256Header the signature is sent in.
resolveOptions(tenant: string) => WebhookChannelOptionsPer-tenant options resolver. See Per-tenant config.
globalbooleantrueRegister 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:

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 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:

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, a non-POST method, or signed headers. 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-Signature', sign(this.orderId))
    .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.
.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:

  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().

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)}` },
  }),
});

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

On this page