Webhook
Contract
@modularityjs/webhook provides outgoing webhook dispatch with HMAC signing, subscription management, and pluggable delivery.
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
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
| Driver | Package | Description |
|---|---|---|
| Memory | @modularityjs/webhook-memory | In-memory Map. For dev/testing. |
Apps provide database-backed stores via WebhookSubscriptionStore sub-contract (same pattern as ApiKeyStore, LocalUserStore).
Delivery
| Driver | Package | Description |
|---|---|---|
| Direct HTTP | @modularityjs/webhook-direct | fetch() with configurable retry. Basic, no queue dependency. |
| Queue-based | @modularityjs/webhook-queue | Publishes to QueueService. Gets retry, DLQ, and concurrency from queue infrastructure. |
Direct delivery config:
| Option | Default | Description |
|---|---|---|
maxRetries | 3 | Maximum delivery attempts |
retryDelayMs | 1000 | Base delay between retries — multiplied by 2 ** (attempt - 1) for exponential backoff |
timeoutMs | 10000 | Request timeout per attempt |
jitterRatio | 0.1 | Random jitter applied to each backoff (0 disables, 1 = ±100%) |
Outgoing Webhooks
Register a subscription
await webhookService.register({
event: 'user.created',
url: 'https://partner.example.com/webhooks',
secret: 'shared-secret',
active: true,
});Dispatch
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
{
"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/jsonx-webhook-signature: <HMAC-SHA256 hex digest of "{timestamp}.{body}">x-webhook-event: user.createdx-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:
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:
@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.
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:
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:
@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:
@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 {}