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%)

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: <ms since epoch>

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

HTTP Incoming Bridge (@modularityjs/http-webhook)

HttpWebhookModule makes HttpWebhookConfig injectable — useful when you want a single configurable path and secret header for your incoming webhook endpoint rather than hardcoding them.

typescript
import { HttpWebhookModule } from '@modularityjs/http-webhook';

const modules = [
  WebhookModule,
  HttpWebhookModule.forRoot({
    path: '/webhooks/incoming', // default
    secretHeader: 'x-webhook-secret', // default
  }),
];

Route paths are pinned by @Post(...) at class definition time, so the controller hardcodes the path it owns; inject HttpWebhookConfig only to read secretHeader (or other dynamic values) inside the handler:

typescript
import { Inject, Injectable } from '@modularityjs/di';
import { Controller, Post, Body, Headers } from '@modularityjs/http';
import { HttpWebhookConfig } from '@modularityjs/http-webhook';
import { WebhookService } from '@modularityjs/webhook';

@Controller('/webhooks/incoming')
class WebhookController {
  constructor(
    @Inject(HttpWebhookConfig) private readonly config: HttpWebhookConfig,
    @Inject(WebhookService) private readonly webhooks: WebhookService,
  ) {}

  @Post('/')
  async receive(
    @Body() body: string,
    @Headers() headers: Record<string, string>,
  ) {
    const signature = headers[this.config.secretHeader];
    // ... verify and process
  }
}

Incoming Webhooks

Verify signatures

Use WebhookSigner or WebhookService.verifyIncoming() to verify HMAC signatures. The signature covers `${timestamp}.${body}`, and verifyIncoming() also rejects timestamps older than 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 {}