Guide
Fan-In Webhooks
Fan-in is the pattern of receiving webhook traffic from several producers and sending it into one downstream service or workflow.
In FastHook, a Connection always joins one Source to one Destination. Fan-in is created by making several separate Connections and giving each of them the same destination_id.
Each producer should still have its own Source. The shared Destination receives traffic through those separate Connections, so provider auth, source evidence, rules, and recovery remain scoped.
The shared destination owns the outbound URL, HTTP method, destination auth, path forwarding behavior, and rate_limit. Treat those fields as receiver-wide capacity because every fan-in branch that points to that destination uses them.
When To Use Fan-In
Use fan-in when several producers should feed one receiver contract, but you still need to inspect and operate each producer separately.
- Several providers feed one billing or notification service.
- Multiple internal teams send related event types to one platform endpoint.
- Staging and local sources need to exercise the same receiver contract.
- Different providers need normalization before delivery.
- Provider identity must remain inspectable during incidents.
Dashboard Shape
Open Connections in the dashboard and use Structured mode. Fan-in should look like several source nodes on the left, several separate connection routes in the middle, and one destination node on the right.
Do not try to attach many sources to one connection. Instead, create one connection per source and set the same destination on each connection.
If the graph shows one source pretending to represent several providers, split it before production. Source-specific auth and request evidence are too important to merge.
Stripe source
-> stripe-to-ingest connection
-> shared ingest destination
Shopify source
-> shopify-to-ingest connection
-> shared ingest destination
Internal source
-> internal-to-ingest connection
-> shared ingest destinationBackend Model
FastHook fan-in uses the same resource model as every other route. Sources receive inbound webhooks, connections route accepted requests, and destinations deliver outbound attempts.
The fan-in part is the repeated destination_id. Each connection has exactly one source_id and one destination_id. Several connection records can point to the same destination_id.
- Source: one ingress URL and source auth boundary per producer.
- Destination: one shared receiver configuration for outbound delivery.
- Connection: one route from source_id to the shared destination_id.
- Fan-in: multiple connection records reuse the same destination_id.
- Rules: source-specific filter, transform, deduplicate, delay, and retry behavior.
- Request evidence: accepted or rejected ingress remains tied to source_id.
- Event evidence: delivered, failed, held, or ignored branches remain tied to connection_id and destination_id.
Source src_stripe -> Connection web_stripe_ingest -> Destination des_ingest
Source src_shopify -> Connection web_shopify_ingest -> Destination des_ingest
Source src_internal -> Connection web_internal_ingest -> Destination des_ingest1. Keep Sources Separate
Do not use one catch-all source for several providers just because the destination is shared. A source owns the public ingress URL, allowed_http_methods, source auth config, disabled_at, custom response, and request evidence.
Separate sources let Stripe, Shopify, GitHub, internal apps, and staging traffic use different verification secrets and still converge into one receiver later.
- Use provider-specific source auth and allowed methods.
- Keep provider dashboards pointed at source URLs dedicated to that provider.
- Disable a source when that producer should stop at ingress.
- Use source_id filters during debugging to isolate one producer.
- Keep provider secrets on the source, not in the shared destination.
curl -X POST "https://api.fasthook.io/v1/sources" \
-H "Authorization: Bearer fh_api_xxx" \
-H "x-team-id: tm_xxx" \
-H "content-type: application/json" \
-d '{
"name": "stripe-prod",
"type": "WEBHOOK",
"config": {
"auth_type": "HMAC",
"auth": {
"secret": "whsec_stripe_prod",
"signature_header": "stripe-signature"
},
"allowed_http_methods": ["POST"]
}
}'
curl -X POST "https://api.fasthook.io/v1/sources" \
-H "Authorization: Bearer fh_api_xxx" \
-H "x-team-id: tm_xxx" \
-H "content-type: application/json" \
-d '{
"name": "shopify-prod",
"type": "WEBHOOK",
"config": {
"auth_type": "HMAC",
"auth": {
"secret": "shpss_shopify_prod",
"signature_header": "x-shopify-hmac-sha256"
},
"allowed_http_methods": ["POST"]
}
}'2. Create One Shared Destination
Create the receiver once when the downstream service should handle all normalized events. The destination owns the target URL, HTTP method, path forwarding, outbound auth, disabled_at, and destination rate limit.
Because rate_limit lives on the destination config, use it as shared receiver capacity. If one producer can flood the receiver, also add source-specific filters, pause controls, or separate destinations.
- Use an HTTP destination for deployed shared receivers.
- Use FASTHOOK_SIGNATURE when the receiver verifies FastHook outbound delivery.
- Set rate_limit and rate_limit_period to match the receiver's total capacity.
- Avoid mixing unrelated receivers behind one destination URL.
- Disable the destination only when every fan-in branch should stop delivery.
curl -X POST "https://api.fasthook.io/v1/destinations" \
-H "Authorization: Bearer fh_api_xxx" \
-H "x-team-id: tm_xxx" \
-H "content-type: application/json" \
-d '{
"name": "event-ingest-api",
"type": "HTTP",
"config": {
"url": "https://api.example.com/events",
"http_method": "POST",
"path_forwarding_disabled": false,
"rate_limit": 120,
"rate_limit_period": "minute",
"auth_type": "FASTHOOK_SIGNATURE",
"auth": {}
}
}'3. Create One Connection Per Producer
Connect every source to the shared destination with its own connection. This is the actual fan-in shape in FastHook: different connection records, different source_id values, one repeated destination_id.
This preserves branch-level event evidence and lets each producer have its own filter, transformation, retry policy, deduplication key, pause state, and disabled state.
Name each connection by producer and receiver. That makes Events, Metrics, and retry operations much easier to read during an incident.
- Use source_id for the producer source.
- Use the same destination_id for the shared receiver.
- Attach provider-specific rules to the connection.
- Pause one connection when one producer needs a temporary hold.
- Disable one connection when one producer should stop creating future delivery work.
curl -X POST "https://api.fasthook.io/v1/connections" \
-H "Authorization: Bearer fh_api_xxx" \
-H "x-team-id: tm_xxx" \
-H "content-type: application/json" \
-d '{
"name": "stripe-to-ingest",
"source_id": "src_stripe",
"destination_id": "des_ingest",
"rules": [
{
"type": "filter",
"body": { "type": "invoice.paid" }
},
{
"type": "transform",
"transformation_id": "trs_stripe_to_envelope"
}
]
}'
curl -X POST "https://api.fasthook.io/v1/connections" \
-H "Authorization: Bearer fh_api_xxx" \
-H "x-team-id: tm_xxx" \
-H "content-type: application/json" \
-d '{
"name": "shopify-to-ingest",
"source_id": "src_shopify",
"destination_id": "des_ingest",
"rules": [
{
"type": "deduplicate",
"window": 60000,
"include_fields": ["body.id"]
},
{
"type": "transform",
"transformation_id": "trs_shopify_to_envelope"
}
]
}'4. Normalize Before Delivery
A shared receiver should not have to understand every provider's native webhook format. Put normalization on each connection branch so the receiver gets one stable internal envelope.
FastHook normalizes connection rule order before saving: deduplicate, transform, filter, delay, then retry. When fan-in branches transform payloads, design filters with that order in mind.
- Transform each provider payload into a stable internal event envelope.
- Include provider name, provider event id, original event type, and object id.
- Avoid deleting fields needed for audit, replay, or idempotency.
- Keep receiver idempotency based on stable provider event ids.
- Use source-specific metrics to detect whether one producer is flooding the shared receiver.
request.body = {
provider: "stripe",
event_id: request.body.id,
event_type: request.body.type,
object_id: request.body.data?.object?.id,
received_at: request.created_at,
data: request.body.data?.object ?? request.body
};
return request;5. Inspect Fan-In By Source And Branch
Fan-in incidents can be misleading because the destination is shared. Start by deciding whether the issue is provider ingress, branch processing, or receiver delivery.
Use source_id to isolate one producer, connection_id to isolate one producer-to-receiver branch, and destination_id to inspect the shared receiver across all producers.
- Requests: filter by source_id, status, verified, and time range.
- Events: filter by source_id, connection_id, destination_id, status, and time range.
- Ignored events: inspect filtered or deduplicated branches from request detail.
- Attempts: inspect destination response status, body, requested_url, trigger, and error_code.
- Metrics: compare request accepted_count per source with failed_count, queued_count, hold_count, and delivered_count per destination.
curl "https://api.fasthook.io/v1/requests?from=now-24h&to=now&source_id=src_stripe&status=accepted" \
-H "Authorization: Bearer fh_api_xxx" \
-H "x-team-id: tm_xxx"
curl "https://api.fasthook.io/v1/events?from=now-24h&to=now&connection_id=web_stripe_ingest&destination_id=des_ingest" \
-H "Authorization: Bearer fh_api_xxx" \
-H "x-team-id: tm_xxx"
curl "https://api.fasthook.io/v1/attempts?event_id=evt_xxx&order_by=created_at&dir=desc" \
-H "Authorization: Bearer fh_api_xxx" \
-H "x-team-id: tm_xxx"
curl "https://api.fasthook.io/v1/metrics/events?date_range[start]=2026-05-29T00:00:00Z&date_range[end]=2026-05-29T23:59:59Z&destination_id=des_ingest&granularity=1h" \
-H "Authorization: Bearer fh_api_xxx" \
-H "x-team-id: tm_xxx"Operational Controls
Choose the smallest control that matches the incident. Pausing a single connection holds one producer branch. Disabling a source stops one producer at ingress. Disabling the shared destination affects every producer feeding it.
- Pause one connection when one producer should temporarily stop delivery.
- Unpause the connection to drain held branch events.
- Disable one connection when one producer should stop creating future delivery work.
- Patch destination rate_limit when the shared receiver needs a global safety cap.
- Use request-level retry only when a source request should be re-evaluated across its route.
curl -X PUT "https://api.fasthook.io/v1/connections/web_shopify_ingest/pause" \
-H "Authorization: Bearer fh_api_xxx" \
-H "x-team-id: tm_xxx"
curl -X PATCH "https://api.fasthook.io/v1/destinations/des_ingest" \
-H "Authorization: Bearer fh_api_xxx" \
-H "x-team-id: tm_xxx" \
-H "content-type: application/json" \
-d '{
"config": {
"rate_limit": 60,
"rate_limit_period": "minute"
}
}'Design Patterns
- One source per provider account or environment.
- One shared destination per receiver contract.
- One connection per producer-to-receiver branch.
- One transformation per provider shape when payloads differ.
- Provider name and provider event id preserved in the outbound body.
- Destination rate limit sized for total fan-in traffic, not one producer.
Common Mistakes
- Using one source for several providers and losing source-specific auth evidence.
- Forgetting that destination auth and rate_limit are shared by every fan-in branch.
- Putting provider parsing burden inside the shared receiver instead of connection transformations.
- Debugging only by destination_id and missing which source caused the incident.
- Retrying every producer branch when one connection or one provider failed.
- Disabling the shared destination when the safer action is pausing one connection.