Route one webhook to every service that needs it
- 01How sources, destinations and connections fit together
- 02Filtering which events reach which destination
- 03What fan-out does to retries, throughput and your bill
- 04Recovering one destination without disturbing the others
- 05Reading the queue while it drains
Three objects, and only three
A source is an inbound endpoint. It has a maskId and an ingestUrl, and that URL is the only credential your provider needs. A destination is somewhere you want events sent, with its own headers and its own rate limit. A connection joins one source to one destination and decides which events make the trip.
Fan-out is what you get when a source has more than one connection. Nothing about the source changes and the provider never learns about it.
Wire up a second destination
Assuming a source already exists, adding another consumer is one destination and one connection. Neither touches the provider.
- 1
Create the destination
POST /v1/destinationswith the URL you want called. Custom headers are supported, and the ones you mark sensitive are encrypted at rest and never returned by the API. - 2
Connect it to the source
A connection names the pair and optionally narrows what flows through it. Leave
filterRules.eventTypesout to forward everything.curlcurl -s "$API/v1/connections" \ -H "authorization: Bearer $SDHK_KEY" \ -H 'content-type: application/json' \ -d '{ "name": "Stripe → billing service", "sourceId": "src_...", "destinationId": "dest_...", "filterRules": { "eventTypes": ["invoice.payment_succeeded"] } }'
- 3
Send a test event
Post to the source's ingest URL. A source with signature verification configured needs a valid signature, and without one the request is answered 401 and stored with status
rejected. Once an event lands you should see two deliveries against it, one per connection, each with its own status.
Filtering without a rules language
A connection matches on event type and nothing else. The type comes from the source's eventTypeField, a dot-path into the request. Known providers set a sensible default, so a GitHub source reads headers.x-github-event and a Stripe source reads the type out of the body without you configuring anything.
That single knob covers more than it looks like it does, because you choose the path. Point a source at body.action and its connections filter on the action instead of the header.
# A GitHub source filters on the event header by default.# Point eventTypeField at a body path to filter on something else:curl -s -X PATCH "$API/v1/sources/src_..." \ -H "authorization: Bearer $SDHK_KEY" \ -H 'content-type: application/json' \ -d '{ "data": { "eventTypeField": "body.action" } }' # Connections on that source now match on the action, not the header:# "filterRules": { "eventTypes": ["review_requested"] }
filtered. You have not lost it. Add the connection you were missing and replay.What fan-out costs
The billable unit is the delivery, which means one event sent to one connected destination. Three connections on an event is three deliveries. The pricing follows the work rather than the seat count, so adding a fourth consumer costs the same whether one person or forty look at the dashboard.
When one destination goes down
Deliveries fail independently. Each one retries on a widening backoff curve that starts at 5 seconds and opens out to 10 hours between attempts: three attempts under a one-hour ceiling on Hobby, eight under a 36-hour ceiling on paid plans, which the curve reaches after roughly 28 hours. After that it waits for you rather than disappearing. The other connections on the same event are unaffected and will already have succeeded.
Not every non-2xx answer counts as a failure, which matters when you are reading an attempt log at 3am.
Retry-After up to an hour and costs no attempt. Capped at five parks per delivery, and refused sooner than that if the wait would run past your plan's retry ceiling, either way it falls through to the failure path.Two recovery paths exist and they are not interchangeable. POST /v1/deliveries/{id}/retry re-sends one delivery to one destination and creates nothing new, so it is free. POST /v1/events/{id}/replay runs the event through its connections again, which produces new deliveries and is billed. Reach for retry when a single consumer failed, and for replay when you added a connection after the fact.
rejected event failed signature verification, so replaying it would push unverified content to your destinations under that source's name. It returns 400. A request dropped as a duplicate is not stored at all, so there is nothing there to replay either.Pacing, which is not failure
Two rate limits sit on the outbound side: your plan's deliveries per second, and whatever limit you set on an individual destination. Going over either one queues the delivery instead of failing it. Its status reads throttled with a throttledReason of rate_limit, no retry attempt is spent, and it sends when capacity frees up. The same status carrying destination_busy is the other kind of pause: the destination asked for it with a 429, 502 or 504.
This matters most while a destination is recovering. A service that just came back does not get the whole backlog at once, and you are not paged for a queue that is draining correctly. A delivery that was ever held keeps a throttledAt timestamp even after it succeeds, so the pause is visible afterwards.
# Everything that was excluded by a filtercurl -s "$API/v1/events?status=filtered" -H "authorization: Bearer $SDHK_KEY" # What is waiting in the outbound queue right nowcurl -s "$API/v1/deliveries/stats" -H "authorization: Bearer $SDHK_KEY"→ { "pending": 4, "throttled": 412 }
Practices worth adopting
- Give each consumer its own connection rather than sharing one and branching inside your handler. Separate connections give you separate retry state and separate receipts.
- Set a rate limit on any destination you know to be fragile. Queuing is cheaper for both sides than a burst of failures followed by backoff.
- Filter at the connection, not in your handler. An event your service was going to discard still costs a delivery if you let it through.
- Check
?status=filteredwhen a consumer reports missing events. The usual cause is aneventTypeFieldpointing somewhere other than where you thought. - Make handlers idempotent. Replay exists, people use it during incidents, and your endpoint should treat a second copy as a no-op.
Questions
Fan one source out to everything that needs it
Create a source, add as many connections as you have consumers, and watch each one settle.