Skip to content

Webhook

Contract

@modularityjs/webhook provides outgoing webhook dispatch with HMAC signing, subscription management, and pluggable delivery.

typescript
class WebhookService {
  dispatch(event: string, data: unknown): Promise<WebhookDeliveryResult[]>;
  register(
    subscription: Omit<WebhookSubscription, 'id'>,
  ): Promise<WebhookSubscription>;
  unregister(id: string): Promise<void>;
  listSubscriptions(): Promise<WebhookSubscription[]>;
  verifyIncoming(
    payload: string,
    signature: string,
    secret: string,
    timestamp: number,
  ): boolean;
}

Two abstract contracts that drivers must implement:

  • WebhookSubscriptionStore — where subscriptions are stored (memory, database)
  • WebhookDelivery — how payloads are delivered (direct HTTP, queue)

Setup

typescript
import { WebhookModule } from '@modularityjs/webhook';
import { WebhookMemoryModule } from '@modularityjs/webhook-memory';
import { WebhookDirectModule } from '@modularityjs/webhook-direct';

const modules = [
  WebhookModule,
  WebhookMemoryModule, // in-memory subscription store
  WebhookDirectModule.forRoot({ maxRetries: 3, retryDelayMs: 1000 }),
];

Drivers

Subscription Store

DriverPackageDescription
Memory@modularityjs/webhook-memoryIn-memory Map. For dev/testing.

Apps provide database-backed stores via WebhookSubscriptionStore sub-contract (same pattern as ApiKeyStore, LocalUserStore).

Delivery

DriverPackageDescription
Direct HTTP@modularityjs/webhook-directfetch() with configurable retry. Basic, no queue dependency.
Queue-based@modularityjs/webhook-queuePublishes to QueueService. Gets retry, DLQ, and concurrency from queue infrastructure.

Direct delivery config:

OptionDefaultDescription
maxRetries3Maximum delivery attempts
retryDelayMs1000Base delay between retries — multiplied by 2 ** (attempt - 1) for exponential backoff
timeoutMs10000Request timeout per attempt
jitterRatio0.1Random jitter applied to each backoff (0 disables, 1 = ±100%)
maxRedirects0Redirect hops a delivery may follow. Every hop is re-validated against the SSRF checks

SSRF protection

Subscription URLs are attacker-influenced by definition, so every outgoing delivery is guarded:

  • Scheme allowlistWebhookConfig.urlAllowedSchemes (default ['https']).
  • Non-routable destinationsWebhookConfig.urlAllowPrivateNetworks (default false) rejects hosts in the IANA special-purpose ranges for both families: loopback, RFC 1918, CGNAT (100.64.0.0/10), link-local (169.254.0.0/16 — cloud metadata), benchmarking, documentation, multicast and reserved for IPv4; unique-local, link-local, site-local, multicast, and the IPv4-carrying transition prefixes (::/96, ::ffff:0:0/96, 2002::/16 6to4, 64:ff9b::/96 NAT64) for IPv6.
  • At registration and at delivery — the lexical check runs in register(); the delivery driver re-runs it plus a DNS resolution check before every attempt.
  • The validated address is the one connected toassertWebhookUrlAllowed returns the approved addresses and webhook-direct pins them into the socket, so the target hostname is never resolved a second time.
  • Redirects are not followed by default (WebhookDirectConfig.maxRedirects = 0), because a legitimately-public target can reply 302 Location: http://169.254.169.254/. Raising it follows a bounded chain, re-validating and re-pinning every hop; a cross-origin hop is always refused, since replaying the delivery signature to another host hands that credential away.

Rejections throw a ValidationException whose message deliberately omits the resolved address — otherwise a public "register a webhook" endpoint becomes a DNS oracle for internal hostnames. A host that cannot be resolved and a host that resolves somewhere blocked produce the same message for the same reason. Set NODE_DEBUG=modularityjs:webhook to see which address and range matched.

DNS rebinding is closed

The resolve-time check hands its approved addresses to the delivery driver, which passes them to node:http / node:https as a lookup hook with agent: false. The socket can only reach an address that was validated, while the URL's hostname still travels on the wire for Host and TLS SNI (so certificate verification is unaffected). A 0-TTL record has no second resolution to answer differently. A network-level egress policy is still worth having as defence in depth.

Outgoing Webhooks

Register a subscription

typescript
await webhookService.register({
  event: 'user.created',
  url: 'https://partner.example.com/webhooks',
  secret: 'shared-secret',
  active: true,
});

Dispatch

typescript
await webhookService.dispatch('user.created', {
  id: user.id,
  email: user.email,
});

This finds all active subscriptions matching user.created (plus wildcard * subscriptions), signs the payload with each subscription's secret, and delivers via the configured WebhookDelivery driver.

Payload format

json
{
  "event": "user.created",
  "timestamp": "2026-04-14T12:00:00.000Z",
  "data": { "id": "123", "email": "alice@example.com" }
}

Headers sent with each delivery (names are taken from WebhookConfig.{signatureHeader,eventHeader,timestampHeader} — defaults shown):

  • content-type: application/json
  • x-webhook-signature: <HMAC-SHA256 hex digest of "{timestamp}.{body}">
  • x-webhook-event: user.created
  • x-webhook-timestamp: <unix seconds>

Event Bridge (@modularityjs/webhook-events)

Auto-dispatches webhooks when domain events fire, using a pool of event-to-webhook mappings:

typescript
import {
  WebhookEventsModule,
  WebhookEventMappingsPool,
} from '@modularityjs/webhook-events';

@Module({
  name: 'my-webhook-mappings',
  imports: [WebhookEventsModule],
  pools: [
    {
      pool: WebhookEventMappingsPool,
      key: 'user.created',
      useValue: {
        event: UserCreatedEvent,
        webhookEvent: 'user.created',
        payloadMapper: (e: UserCreatedEvent) => ({
          id: e.userId,
          email: e.email,
        }),
      },
    },
  ],
})
class MyWebhookMappingsModule {}

Then in your event handlers, use WebhookEventDispatcher:

typescript
@OnEvent(UserCreatedEvent, { name: 'webhook-dispatch-user-created' })
async onUserCreated(event: UserCreatedEvent) {
  await this.webhookDispatcher.dispatchForEvent(event);
}

Incoming webhook endpoint (@modularityjs/http-webhook)

To receive webhooks, @modularityjs/http-webhook provides a mounted endpoint that verifies the HMAC signature over the raw request body and dispatches verified payloads to a handler pool. It splits into an abstract half and a Fastify driver (mirroring http-upload / http-fastify-upload):

  • http-webhookHttpWebhookConfig (path, secret, signature/timestamp headers, toleranceMs), the WebhookVerifier, and the HttpWebhookHandlersPool. No route — verifying over raw bytes is driver-specific.
  • http-fastify-webhook — mounts the route at HttpWebhookConfig.path in an encapsulated Fastify plugin that reads the raw body, verifies via WebhookVerifier, and dispatches to the pool. Returns 204 on success, 401 on a bad/expired signature.
typescript
import { Injectable } from '@modularityjs/di';
import {
  HttpWebhookHandlersPool,
  HttpWebhookModule,
  type HttpWebhookHandler,
  type IncomingWebhook,
} from '@modularityjs/http-webhook';
import { HttpFastifyWebhookModule } from '@modularityjs/http-fastify-webhook';
import { Module } from '@modularityjs/modularity';

@Injectable()
class OrderWebhookHandler implements HttpWebhookHandler {
  async handle(webhook: IncomingWebhook): Promise<void> {
    // webhook.payload is the parsed, HMAC-verified body
  }
}

@Module({
  name: 'my-webhooks',
  imports: [HttpWebhookModule],
  providers: [OrderWebhookHandler],
  pools: [
    {
      pool: HttpWebhookHandlersPool,
      key: 'orders',
      useClass: OrderWebhookHandler,
    },
  ],
})
class MyWebhookModule {}

const modules = [
  HttpWebhookModule.forRoot({
    path: '/webhooks/incoming', // default
    secret: process.env.WEBHOOK_SECRET!, // required, >= 32 bytes — boot fails otherwise
  }),
  HttpFastifyWebhookModule,
  MyWebhookModule,
];

The signature is HMAC-SHA256(secret, "<timestamp>.<rawBody>") in hex, sent in signatureHeader (default x-webhook-signature) with the unix-seconds timestamp in timestampHeader (default x-webhook-timestamp). Requests whose timestamp is outside toleranceMs (default 5 min) are rejected — the replay window.

Incoming Webhooks

Verify signatures

Use WebhookSigner or WebhookService.verifyIncoming() to verify HMAC signatures. The signature covers `${timestamp}.${body}` where timestamp is unix seconds — the same unit dispatch() sends and http-webhook's WebhookVerifier parses, so a ModularityJS app can verify another one's deliveries. verifyIncoming() takes the signed timestamp in seconds and now in milliseconds, and rejects timestamps outside WebhookConfig.replayWindowMs (default 5 minutes) to prevent replay attacks:

typescript
@Controller('/webhooks')
class IncomingController {
  constructor(
    @Inject(WebhookService) private readonly webhooks: WebhookService,
  ) {}

  @Post('/partner')
  async receive(
    @Body() body: string,
    @Headers('x-webhook-signature') signature: string,
    @Headers('x-webhook-timestamp') timestamp: string,
  ) {
    const valid = this.webhooks.verifyIncoming(
      body,
      signature,
      partnerSecret,
      Number(timestamp),
    );
    if (!valid) throw new AuthenticationException();
    // Process the payload
  }
}

For third-party providers (Stripe, GitHub) with custom signing schemes, use WebhookSigner.sign() and WebhookSigner.verify() directly with the provider's specific format.

Custom Subscription Store

Implement WebhookSubscriptionStore for database-backed subscriptions:

typescript
@Injectable()
class DatabaseWebhookStore extends WebhookSubscriptionStore {
  constructor(
    @Inject(DatabaseConnection) private readonly db: DatabaseConnection,
  ) {}

  async findByEvent(event: string): Promise<WebhookSubscription[]> {
    return this.db.query(
      'SELECT * FROM webhook_subscriptions WHERE event = ? OR event = ? AND active = true',
      [event, '*'],
    );
  }

  // ... implement other methods
}

@Module({
  name: 'my-webhook-store',
  imports: [WebhookModule, DatabaseModule],
  providers: [DatabaseWebhookStore],
  preferences: [
    { provide: WebhookSubscriptionStore, useClass: DatabaseWebhookStore },
  ],
})
class MyWebhookStoreModule {}