Fan-Out: Deliver One Webhook to Many Destinations
A single provider event usually needs to reach your warehouse, Slack, an internal service, and a queue at once. Skip the fan-out service — do it declaratively with one source and many routes, each with its own filter, transform, and destination.
One event, four systems that all want it
A single payment_intent.succeeded from Stripe is never just one thing to your stack. Finance wants it in the warehouse. On-call wants a Slack ping when a large payment lands. Your billing service needs to mark the invoice paid. And some slower downstream job — provisioning, entitlements, an email — should run async off a queue.
That is four destinations for one inbound event. The traditional answer is to build a fan-out service: a server that catches the webhook, verifies the signature, then loops over a list of downstream targets and POSTs to each one. You own the retries. You own the "Slack is down but the warehouse write already happened" partial-failure mess. You own the reshaping, because none of those four systems want the raw Stripe envelope.
That service is pure plumbing, and it is exactly what Hookbase's routing model replaces. Here is how to do fan-out declaratively.
The shape: one source, many routes
Hookbase's pipeline is deliberately simple. One source is your inbound endpoint — the URL you hand to Stripe. Off that source you hang as many routes as you want, and each route is an independent little pipeline:
- a filter — only events matching its conditions proceed down this route
- an optional transform — JSONata or a JS sandbox that reshapes the payload for this specific destination
- one destination — an HTTP endpoint, a message queue, or cloud object storage / a data warehouse
Fan-out is just this picture with more than one route on the same source. The provider posts once; every route whose filter matches gets its own copy of the event, shaped its own way, delivered on its own schedule. There is no orchestrator in the middle deciding the order — the routes run independently.
Four routes off one Stripe source
Point Stripe at your source, then create four routes. Every one of them starts with the same filter so it only reacts to successful payments:
event.type == "payment_intent.succeeded"
That is the whole gate. Events that don't match — payment_intent.created, refunds, disputes — simply don't travel down these routes. (Want to see, per event, which routes matched and which clause failed? That is the filter trace on the event debugger.)
Now the four routes diverge on transform and destination:
- Warehouse route → object-storage / data-warehouse destination. Little to no transform; you want the fields close to raw so analysts can query everything later. Events batch into S3/R2/GCS/Azure as newline-delimited JSON. (Full walkthrough: webhooks into Snowflake, BigQuery, and Databricks.)
- Slack alert route → HTTP destination pointed at a Slack Incoming Webhook. Heavy transform: Slack wants a
text/blocksshape, not a Stripe object. - Internal billing route → HTTP destination pointed at your own API. Transform down to the few fields your service actually consumes.
- Async work route → a queue destination (SQS, Pub/Sub, EventBridge). Minimal reshape; enqueue and let workers pull. See delivering webhooks to queues.
Same trigger, four different endpoints, four different payload shapes. Nothing about this required you to run a server.
Per-destination shaping is the point
The reason you can't just blast the raw Stripe payload at all four is that each system speaks a different dialect. This is where the per-route transform earns its place.
The Slack route's transform builds a human-readable message:
{
"text": ":moneybag: Payment succeeded",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*$250.00* from `cus_Qk3f...` — invoice `in_1P9a...`"
}
}
]
}
The internal billing route, hitting your own API, wants none of that presentation. It wants a tight, typed record:
{
"customer_id": "cus_Qk3f...",
"invoice_id": "in_1P9a...",
"amount_cents": 25000,
"currency": "usd",
"paid_at": "2026-06-29T14:30:00Z"
}
Two transforms, two outputs, from the exact same source event. You write each transform against the destination it feeds, not against some lowest-common-denominator format. If you're drafting the JSONata for these, the JSONata playground lets you paste a sample Stripe payload and shape the output before you attach it to a route.
Isolation: one bad destination can't stall the rest
This is the win that makes fan-out worth doing in the platform instead of in your own code. Each route's delivery is independent.
Say Slack is having a bad afternoon and your Incoming Webhook is timing out. In a hand-rolled fan-out loop, that's a real problem: do you block the warehouse write behind the Slack retry? Fire them in parallel and reconcile partial failures yourself? Either way you're writing careful code around one flaky endpoint.
In Hookbase, the Slack route just retries on its own. Deliveries back off exponentially, up to 5 attempts, and if they still fail they land in the dead-letter queue for you to inspect and replay. Meanwhile the warehouse route, the billing route, and the queue route already delivered — they never knew Slack was down. Backpressure and failure on one route do not block the others, because there is no shared loop to block.
That means:
- A slow destination adds latency to its own route only.
- A destination that's hard-down drains into its DLQ without touching your good deliveries.
- You replay the failed route's deliveries once it recovers — no need to re-fire the three that already succeeded.
Don't double-fire on provider retries
There's a subtle trap in any fan-out setup. Webhook providers retry when they don't get a fast 2xx — and Stripe doesn't know or care that one delivery fanned out into four. If the same payment_intent.succeeded arrives twice at your source, a naive fan-out sends eight deliveries: a duplicate Slack ping, a double warehouse row, a billing service that marks the invoice paid twice.
Turn on deduplication at the source so a provider's retry of the same event is collapsed before it ever fans out. One logical event, one pass through your routes, no matter how many times the provider re-sends it. The mechanics and the idempotency-key options are in the webhook deduplication guide.
Dedup at the source is the right layer for this: you stop the duplicate once, at the front door, instead of defending against it in four separate downstream systems.
What you didn't have to build
Step back and count what's gone. No fan-out server to deploy, scale, or page someone about. No per-target retry logic. No partial-failure bookkeeping when one endpoint is down and three are up. No custom reshaping code buried in a request handler. No idempotency table to keep provider retries from double-firing.
What you have instead is a source and a list of routes you can read top to bottom: this filter, this transform, this destination — repeated once per system that cares about the event. Add a fifth consumer next quarter and it's one more route, not a redeploy of shared plumbing.
Start with two routes off one source, confirm both fire on the same event, then keep adding. Fan-out stops being an architecture project and becomes a config change.