Fan out one webhook to many endpoints
5 min readThe Eventeer team
Many webhook providers let you register a single URL per event type, or charge for extra endpoints, or make you add each one by hand. Meanwhile you have a billing service, an analytics pipeline, a Slack notifier and a staging environment that all want the same events. You can chain them yourself: your endpoint receives the event and calls the others. That works until one of them is slow, and the provider times out on a request that was mostly waiting for your own downstream calls.
The cleaner shape is to receive once and fan out. This post shows how to do that with Eventeer: one listener in, several destinations out, plus a live stream for browsers. It also explains what delivery guarantees you get.
The pattern
provider ──► listener ──► destination A (billing service)
├──► destination B (analytics)
├──► destination C (staging)
└──► stream (dashboard in a browser)
The provider has one URL: the listener. The listener stores each event. Each destination gets its own copy, with its own retries and its own delivery record, so a failure in B does not delay A.
Step 1: one listener
curl -X POST https://app.eventeer.io/api/v1/listeners \
-H "Authorization: Bearer $EVENTEER_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"shop-events","type":"webhook","auth_mode":"anonymous"}'
The response includes the ingress_url to give the provider. Use auth_mode: "hmac" instead if the provider signs requests and you want unsigned ones rejected at the door; verifying webhook signatures explains the scheme.
Step 2: one destination per consumer
A destination is an HTTPS endpoint that receives matching events as signed POST requests. Create one per consumer:
curl -X POST https://app.eventeer.io/api/v1/destinations \
-H "Authorization: Bearer $EVENTEER_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"billing","url":"https://billing.example.com/hooks/eventeer",
"listener_ids":["7c1e4b92-3a5d-4f60-8b2e-9d0a1c3e5f71"],
"headers":{"Authorization":"Bearer s3cr3t"}}'
curl -X POST https://app.eventeer.io/api/v1/destinations \
-H "Authorization: Bearer $EVENTEER_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"analytics","url":"https://analytics.example.com/ingest",
"listener_ids":["7c1e4b92-3a5d-4f60-8b2e-9d0a1c3e5f71"],
"filter":{"path":"$.type","op":"eq","value":"order.created"}}'
Two things are worth noticing.
listener_idsandfilterselect what each destination gets. An emptylistener_idsmeans all listeners in the organization. The optionalfilteris a single condition on the payload: a JSONpath, anop(such aseq,neq,gt,lt,contains,in,exists) and avalue. Here analytics only receivesorder.createdevents, while billing receives everything from the listener.headersare static request headers, handy for a bearer token your endpoint expects. They are write-only: responses list only the header names. Headers starting withX-Eventeer-are reserved.
Each response includes a signing_secret, shown once. Every delivery carries X-Eventeer-Signature (t=<unix time>,v1=<hex>), so each consumer can check that the request came from Eventeer and reject one more than five minutes old. The body is the stored event:
{
"type": "event",
"id": "0192f3a4-7b1e-7c55-9a3e-5d2f8b6c1a3f",
"listener": "lst_7Qp2xK9vLmN3rT5wY8zA1b",
"device": null,
"source": "webhook",
"received_at": "2026-10-31T18:21:04.123456Z",
"content_type": "application/json",
"data": {"type": "order.created", "amount": 42.5}
}
Step 3: understand the delivery guarantees
Fan-out raises the question “what if one destination is down?” The behavior is this.
- At-least-once. A delivery is successful when the endpoint answers any 2xx within 10 seconds. Anything else is retried with backoff: 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours, 6 hours, 12 hours. That is eight retries over about 24 hours, after which the delivery is marked
dead. - Independent per destination. Each destination has its own delivery rows and retry schedule. A slow analytics service does not hold up billing.
- Duplicates are possible. A retry after a timeout can arrive after the first attempt actually succeeded. Each request has an
X-Eventeer-Event-Idheader; consumers should record the ids they have handled and skip repeats. - No ordering guarantee across retries. If event 1 fails and event 2 succeeds, event 1 can arrive later. Consumers that care should use
received_ator their own sequence field. 410 Gonedisables the destination immediately, and three consecutive days of only failures disable it and email the organization’s admins.- Successful deliveries are billed, retries are not. Each success counts one delivered event, so fan-out to three destinations is three deliveries per event. Take that into account when you estimate usage on pricing.
Step 4: look at the delivery log
Per destination, the log shows every delivery, newest first:
curl "https://app.eventeer.io/api/v1/destinations/dst_8Wm3Kq1Zp6Vt9Nb2Lc5Xr4/deliveries?status=failed" \
-H "Authorization: Bearer $EVENTEER_KEY"
Each row has status (pending, succeeded, failed or dead), attempts, last_status_code, last_error, next_attempt_at and the event_id. When you have fixed the consumer, retry a delivery without waiting for the backoff:
curl -X POST https://app.eventeer.io/api/v1/deliveries/1c7e9a24-5b3f-4d6e-8a1b-0f2c3d4e5f60/retry \
-H "Authorization: Bearer $EVENTEER_KEY"
To pause one consumer during maintenance without losing the others, PATCH the destination with {"status":"paused"} and set it back to "active" afterwards. The guide to replaying failed webhooks covers the long-outage case.
Step 5: add a live stream for browsers
Not every consumer is an HTTP endpoint. A dashboard in a browser cannot receive a webhook, but it can open a WebSocket. A stream gives it one:
curl -X POST https://app.eventeer.io/api/v1/streams \
-H "Authorization: Bearer $EVENTEER_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"dashboard","auth_mode":"token",
"listener_ids":["7c1e4b92-3a5d-4f60-8b2e-9d0a1c3e5f71"]}'
The stream response includes a subscribe_url. A client connects, and gets the same event frame shown above for each new event:
const ws = new WebSocket(`wss://app.eventeer.io/out/s/${key}?token=${token}&since=${lastId}`);
ws.onmessage = (m) => {
const f = JSON.parse(m.data);
if (f.type === "event") lastId = f.id;
};
Live frames are at-most-once: if the client is disconnected, it misses them. When it reconnects with since=<last event id>, the server first replays stored events after that id (up to 1,000 per batch, oldest first), sends a {"type":"live"} marker, then continues live. Replay can overlap slightly with what the client already has, so dedupe by event id. A consumer that falls too far behind is closed with 4408 slow_consumer and reconnects with since.
When this is the wrong tool
If you need strict ordering across consumers, exactly-once processing, or transformation of payloads before they are forwarded, you will do that work in your own services. Eventeer routes and stores events; it does not rewrite them.
For a single consumer with a low volume, receiving the webhook directly in your app is fine. Fan-out pays off when the consumer list grows, when one of them is flaky, or when you want a record of what was sent where. The webhooks use case page has the overview, and real-time streams covers the browser side in more depth. Endpoint details are in the API docs.