Skip to content

All posts

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_ids and filter select what each destination gets. An empty listener_ids means all listeners in the organization. The optional filter is a single condition on the payload: a JSON path, an op (such as eq, neq, gt, lt, contains, in, exists) and a value. Here analytics only receives order.created events, while billing receives everything from the listener.
  • headers are static request headers, handy for a bearer token your endpoint expects. They are write-only: responses list only the header names. Headers starting with X-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-Id header; 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_at or their own sequence field.
  • 410 Gone disables 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.

Send your first event

Create an organization, add a listener and see every event stored and replayable. Choose a plan when you are ready for real traffic.