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%) |
maxRedirects | 0 | Redirect 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 allowlist —
WebhookConfig.urlAllowedSchemes(default['https']). - Non-routable destinations —
WebhookConfig.urlAllowPrivateNetworks(defaultfalse) 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::/166to4,64:ff9b::/96NAT64) 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 to —
assertWebhookUrlAllowedreturns the approved addresses andwebhook-directpins 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 reply302 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
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: <unix seconds>
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);
}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-webhook—HttpWebhookConfig(path, secret, signature/timestamp headers,toleranceMs), theWebhookVerifier, and theHttpWebhookHandlersPool. No route — verifying over raw bytes is driver-specific.http-fastify-webhook— mounts the route atHttpWebhookConfig.pathin an encapsulated Fastify plugin that reads the raw body, verifies viaWebhookVerifier, and dispatches to the pool. Returns204on success,401on a bad/expired signature.
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:
@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 {}