Webhook Channel
The Webhook channel allows you to send notifications to any HTTP endpoint. This is useful for integrating with third-party services, custom APIs, or building notification bridges. This channel uses the ky HTTP client internally.
Batching
This channel does not support batching. Each webhook is called individually. When targeting multiple webhooks, requests are sent sequentially.
Configuration
import { createFacteur } from '@facteurjs/core'
import { webhookChannel } from '@facteurjs/core/channels/webhook'
export const facteur = createFacteur({
discoverer: { searchDirectory: new URL('./notifications/', import.meta.url) },
channels: {
webhook: webhookChannel({
name: 'webhook',
// Define multiple named webhooks
webhooks: {
zapier: 'https://hooks.zapier.com/hooks/catch/123/abc',
slack: 'https://hooks.slack.com/services/xxx/yyy/zzz',
custom: 'https://api.example.com/webhooks/notifications',
},
}),
},
})
Configuration Options
name(required): The channel name identifierwebhooks(optional): An object mapping webhook names to URLswebhookUrl(optional): A single default webhook URL
Provide either webhooks or webhookUrl, not both. If both exist at runtime, webhookUrl takes precedence.
The options below are off by default. The Slack and Discord channels accept them too. Security explains each one.
signingSecret(optional): Standard Webhooks secret (whsec_...). The channel signs every request with it.httpsOnly(optional, defaultfalse): Rejects non-https URLs before any request.followRedirects(optional, defaulttrue): Follows HTTP redirects.falserefuses them.timeout(optional, default10000): Request timeout in milliseconds.falsedisables it.fetch(optional): Thefetchfunction that the channel uses for every request.
Targets
The Webhook channel targets specify which webhooks to call:
// Send to specific named webhooks
await facteur
.notification(MyNotification)
.via({
webhook: {
zapier: true,
slack: true,
},
})
.send()
// Or send to an arbitrary webhook URL
await facteur
.notification(MyNotification)
.via({
webhook: {
webhookUrl: 'https://custom-endpoint.example.com/notify',
},
})
.send()
Target Properties
When using named webhooks, include only the keys to call. Current limitation: keys are selected regardless of their boolean value. custom: false still calls custom; omit it to exclude it. This also applies to Slack and Discord.
When using arbitrary URLs:
webhookUrl: The webhook URL to send tosigningSecret(optional): The signing secret for this send. It replaces the channelsigningSecret.
Message Features
When creating webhook notifications, you can customize the HTTP request:
import { Notification } from '@facteurjs/core/types'
import { WebhookMessage } from '@facteurjs/core/channels/webhook'
export default class WebhookNotification extends Notification<
undefined,
{ orderId: number; customer: { name: string; email: string } }
> {
asWebhookMessage() {
return WebhookMessage.create()
.setBody({
event: 'order.shipped',
orderId: this.params.orderId,
customer: {
name: this.params.customer.name,
email: this.params.customer.email,
},
timestamp: new Date().toISOString(),
})
.setHeader('X-Webhook-Secret', 'your-secret')
.setHeader('X-Event-Type', 'order.shipped')
.setQueryParameters({
version: 'v1',
format: 'json',
})
}
}
Available Methods
setBody(data): Set the JSON body of the webhook requestsetHeader(name, value): Add a custom HTTP headersetQueryParameters(params): Add query parameters to the webhook URLsetId(id): Set thewebhook-idheader of signed requests. See Sign requests.
Error Handling
HTTP failures become WebhookRequestException, then the sender wraps channel failures in an aggregate error by default. Use .throwOnError(false) to inspect the underlying channel error:
import { WebhookRequestException } from '@facteurjs/core/channels/webhook'
const result = await facteur
.notification(MyNotification)
.via({ webhook: { zapier: true } })
.throwOnError(false)
.send()
for (const { error } of result.results) {
if (error instanceof WebhookRequestException) {
console.error('Webhook failed:', error.url, error.responseBody)
}
}
Security
Sign requests
Set signingSecret to sign requests with Standard Webhooks. Each signed request carries three headers:
webhook-id: the message id.webhook-timestamp: the time of the attempt, in Unix seconds.webhook-signature:v1,followed by the base64 HMAC-SHA256 of${id}.${timestamp}.${body}. The key is the base64-decoded secret.
The channel serializes the body once and signs the exact bytes it sends. Any Standard Webhooks library can verify the requests.
Set the secret on the channel, in the targets of a send, or both. A target secret replaces the channel secret for that send. Use a target secret when each organization has its own URL and secret:
const facteur = createFacteur({
channels: {
webhook: webhookChannel({ name: 'webhook', webhooks: {} }),
},
})
await facteur
.notification(OrderShipped)
.via({
webhook: {
webhookUrl: organization.webhookUrl,
signingSecret: organization.webhookSecret, // whsec_...
},
})
.send()
If the channel signingSecret is invalid, webhookChannel() throws E_INVALID_WEBHOOK_SECRET. If a target signingSecret is invalid, send() throws the same error before it makes any request.
By default, the channel generates the webhook-id once per message instance. Facteur's retry option resends the same message instance, so a retry keeps the same webhook-id with a new timestamp and a new signature. A new send() creates a new message with a new id. So does a queued job that rebuilds the notification. To keep one id across those, set it with setId(), for example from your event id:
asWebhookMessage() {
return WebhookMessage.create().setId(`evt_${this.params.eventId}`).setBody({ ... })
}
Restrict requests
webhookChannel({
name: 'webhook',
webhooks: {},
httpsOnly: true,
followRedirects: false,
timeout: 5000,
})
httpsOnly: truemakes the channel check every target URL before the first request. If one URL is not https,send()throwsE_WEBHOOK_INSECURE_URLand calls no endpoint.followRedirects: falsestops the channel from following a 3xx response. The send fails with aWebhookRequestException, like any other non-2xx response.timeoutaborts an HTTP request after this many milliseconds. Facteur'sretry.timeoutis a separate limit on each send attempt.
Protect against SSRF
If your users provide webhook URLs, a URL can point to an internal service or to the cloud metadata endpoint 169.254.169.254. Facteur has no built-in SSRF guard. Pass a fetch that blocks these requests.
A correct guard checks every IP that the hostname resolves to. It also checks IP literals and every redirect, and it connects to the IP that it checked. Otherwise, an attacker can use DNS rebinding. The attacker's DNS server returns a public IP for the check and a private IP for the connection.
We recommend guarded-fetch. Its undici dispatcher runs these checks each time it opens a connection. Pass the dispatcher to the native fetch:
import { getSharedGuardedDispatcher } from 'guarded-fetch'
import { webhookChannel } from '@facteurjs/core/channels/webhook'
const dispatcher = getSharedGuardedDispatcher()
webhookChannel({
name: 'webhook',
webhooks: {},
httpsOnly: true,
// guarded-fetch ships its own undici. Its types can differ from the types of Node's fetch.
fetch: (input, init) => fetch(input, { ...init, dispatcher } as unknown as RequestInit),
})
With this fetch, the dispatcher refuses to connect to private, loopback, link-local, and CGNAT addresses. It also refuses IPv6 addresses that embed one of them. The same checks apply to every redirect.
Don't pass guardedFetch as fetch. The channel calls fetch with a Request object, and guardedFetch accepts only a URL.
To reject an unsafe URL when an organization saves it, call assertUrlIsSafeToFetch(url, { httpsOnly: true }) from guarded-fetch. Keep the dispatcher too, because the DNS record can change after the save.
Building Custom Webhook Channels
The webhook channel is designed to be extended. Discord and Slack channels are built on top of it. You can create your own:
import { webhookChannel } from '@facteurjs/core/channels/webhook'
export function myServiceChannel(options: { webhookUrl: string }) {
return webhookChannel({
name: 'myService',
webhookUrl: options.webhookUrl,
})
}