Webhook fan-out is delivering one incoming webhook to several destinations. The provider sends each event once, to one URL, and a fan-out layer copies it to every service that needs it: production and staging, a data warehouse, a Slack channel, a teammate's laptop. Each destination receives the same payload and is delivered and retried independently.
┌──▶ https://api.example.com/webhooks (production)
Stripe ──▶ one URL ────┼──▶ https://staging.example.com/webhooks (staging)
├──▶ http://localhost:3000/webhooks (developer laptop)
└──▶ Slack #payments (people)
Why fan-out is needed
- One URL per provider. Many providers allow a single webhook URL per account or app, or a small number. If two services need the same events, something has to copy them.
- Separate concerns. Billing, analytics and notifications can each react to
invoice.paidwithout one service calling the others. - Safer testing. Send real production events to staging or a local build alongside production, without changing what production receives.
- Easy changes. Add or remove a consumer without editing the provider's settings or rotating its secret.
Ways to implement fan-out
| Approach | How | Trade-offs |
|---|---|---|
| Loop in your endpoint | Receive, then POST to each destination | Simple, but one slow destination slows the response and a failure mid-loop is hard to retry |
| Queue or pub/sub | Endpoint publishes to SNS, Pub/Sub, Kafka or EventBridge; each consumer subscribes | Reliable and scalable, but you run the infrastructure and the endpoint |
| Webhook gateway | Provider sends to the gateway, which delivers to each configured destination | No code; per-destination retries and logs built in |
The naive loop, and why it breaks
app.post('/webhooks', async (req, res) => {
for (const url of DESTINATIONS) {
await fetch(url, { method: 'POST', headers: req.headers, body: req.rawBody }) // ✗
}
res.sendStatus(200)
})
If the third destination hangs, the provider times out and retries, and destinations one and two get the event again. If the third returns 502, there's no record of it to retry. Fan-out needs a stored copy of the event and an independent delivery per destination.
Getting fan-out right
- Acknowledge the provider first. Store the event, return
2xx, then fan out asynchronously. - Independent delivery per destination. Each gets its own timeout, retry schedule and status. A failing staging server must not affect production.
- Pass the body through unchanged so each destination can still verify the provider's signature. Strip or replace headers only on purpose.
- Make every consumer idempotent. Each destination can see retries independently.
- Filter per destination. Not every consumer needs every event type. Forwarding rules or a transform can drop the rest.
- Watch per-destination health so a dead consumer is noticed instead of silently failing.
Fan-out with Webhook Relay
A Webhook Relay bucket has one input URL and can deliver to up to 200 destinations. A destination can be a public HTTPS endpoint, a server on localhost or a private network reached through the relay agent, or a notification channel such as Slack, Discord, Telegram, Microsoft Teams or email. Each output has its own durable retries, forwarding rules and optional transform function, and every delivery shows up in the logs. Several developers can also run relay forward against the same bucket, and every connected agent receives every webhook.
For sending your own events to many customer endpoints, the same idea in reverse, the Outbound Gateway fans each published message out to every endpoint subscribed to its event type, and fan-out doesn't count as extra messages. For a worked example on the receiving side, see Mailgun webhook fan-out.
