Documentation
Capture a webhook once, forward it to many targets, inspect exactly what arrived.
Quick start
WHF_TOKEN=$(curl -s -X POST 'https://webhook-fanout.com/api/tokens' | jq -r .token) echo "$WHF_TOKEN" # store it now, it is never shown again
curl -s -X POST 'https://webhook-fanout.com/api/v1/webhooks' \
-H "Authorization: Bearer $WHF_TOKEN" -H 'content-type: application/json' \
-d '{"name":"stripe-test"}'curl -s -X POST 'https://webhook-fanout.com/api/v1/webhooks/$WEBHOOK_ID/endpoints' \
-H "Authorization: Bearer $WHF_TOKEN" -H 'content-type: application/json' \
-d '{"url":"https://staging.example.com/hooks"}'curl -X POST 'https://<slug>.webhook-fanout.com' -H 'content-type: application/json' -d '{"hello":"world"}'curl -fsSL https://webhook-fanout.com/install | sh # macOS / Linux irm https://webhook-fanout.com/install.ps1 | iex # Windows
WHF_TOKEN=$(curl -s -X POST 'https://webhook-fanout.com/api/tokens' | jq -r .token) whf login --token "$WHF_TOKEN"
whf webhooks new --name stripe-test
whf endpoints add <slug> https://staging.example.com/hooks
curl -X POST 'https://<slug>.webhook-fanout.com' -H 'content-type: application/json' -d '{"hello":"world"}'Authentication
One bearer token per account. It authenticates the API and, stored in an httpOnly cookie, the dashboard.
curl 'https://webhook-fanout.com/api/v1/me' -H "Authorization: Bearer $WHF_TOKEN"
[i]
Receiving webhooks
Every webhook gets a unique subdomain. Any method, any path, any body.
https://<slug>.webhook-fanout.com/any/path?any=query
/_ingest/<slug> on the main domain is equivalent to the subdomain, and needs no DNS[i]
*.localhost resolves on macOS and on Linux with systemd-resolved, but not on plain glibc/musl systems that consult only /etc/hosts — Alpine containers, many CI runners, Windows.[i]
Fan-out semantics
The forwarded request reproduces the captured one. This is the section to read before trusting the tool with signed webhooks.
Host Connection Content-Length Transfer-Encoding Keep-Alive Upgrade TE Trailer Expect Proxy-*[i]
Fly-*, X-Forwarded-*, Via, Forwarded, X-Request-Start, X-Real-IP and friends. Your provider did not send these, so they are neither captured nor forwarded.[i]
X-Webhook-Fanout-Request-Id, X-Webhook-Fanout-Attempt, and X-Webhook-Fanout-Replay on replaysRetries & failure
| Endpoint responds | Result |
|---|---|
| 2xx, 3xx | success, recorded and done |
| 408, 429 | retried |
| 5xx | retried |
| timeout, DNS or connection error | retried |
| any other 4xx | permanent failure, not retried |
attempt_count: 4 (the first try plus 3 retries)Local development
Deliver straight to a server on your own machine. No tunnel, no public port.
curl -fsSL https://webhook-fanout.com/install | sh
irm https://webhook-fanout.com/install.ps1 | iex
whf listen --webhook <slug> --forward-to http://localhost:3000
whf update
http://localhost:3000 as an endpoint would resolve to this server's port 3000, not yours[i]
whf listen adds this machine as a target while it runs and removes it when you stop, so the dashboard never shows a local target with nothing behind itwhf checks for a newer build at most every six hours and prints a one-line nudge; whf update verifies the checksum and replaces the binary in placecurl -fsSL https://webhook-fanout.com/download/SHA256SUMS
whf login # store your token whf webhooks # list whf webhooks new --name stripe-test # create whf webhooks rm <slug> # delete, with its history whf endpoints <slug> # list fan-out targets whf endpoints add <slug> <url> # register one whf endpoints rm <endpoint-id> # unregister whf requests <slug> # captured requests + delivery status whf request <request-id> # headers, body, per-endpoint results whf replay <request-id> # re-send, --endpoint for just one
API reference
Shapes are generated from the same schemas the API validates against.
Mint an account and its bearer token. The only unauthenticated route. The token is returned once and is not recoverable.
{
created_at: number
}curl -X POST 'https://webhook-fanout.com/api/tokens'
Identify the account behind the token.
{
id: string
created_at: number
}curl 'https://webhook-fanout.com/api/v1/me' -H "Authorization: Bearer $WHF_TOKEN"
whf webhooks # any command proves the token works
Create a webhook. The response carries the ingest URL to hand to your provider.
{
name?: string
}{
id: string
slug: string
name: string | null
url: string
created_at: number
endpoint_count?: number
request_count?: number
last_request_at?: number | null
disabled_at?: number | null
disabled_reason?: string | null
failing_since?: number | null
}curl -X POST 'https://webhook-fanout.com/api/v1/webhooks' \
-H "Authorization: Bearer $WHF_TOKEN" \
-H 'content-type: application/json' \
-d '{"name":"stripe-test"}'whf webhooks new --name stripe-test
List every webhook on the account, with endpoint and request counts.
{
id: string
slug: string
name: string | null
url: string
created_at: number
endpoint_count?: number
request_count?: number
last_request_at?: number | null
disabled_at?: number | null
disabled_reason?: string | null
failing_since?: number | null
}curl 'https://webhook-fanout.com/api/v1/webhooks' -H "Authorization: Bearer $WHF_TOKEN"
whf webhooks
Delete a webhook along with its endpoints, requests and delivery history.
{
error?: string
details?: {
path: string
message: string
}[]
}curl -X DELETE 'https://webhook-fanout.com/api/v1/webhooks/$WEBHOOK_ID' -H "Authorization: Bearer $WHF_TOKEN"
whf webhooks rm <slug>
Register a fan-out target. Rejected if the URL resolves to a private address.
{
url: string
}{
id: string
webhook_id: string
url: string
enabled: boolean
created_at: number
kind: "url" | "relay"
label: string | null
}curl -X POST 'https://webhook-fanout.com/api/v1/webhooks/$WEBHOOK_ID/endpoints' \
-H "Authorization: Bearer $WHF_TOKEN" \
-H 'content-type: application/json' \
-d '{"url":"https://staging.example.com/hooks"}'whf endpoints add <slug> https://staging.example.com/hooks
List the targets registered on a webhook.
{
id: string
webhook_id: string
url: string
enabled: boolean
created_at: number
kind: "url" | "relay"
label: string | null
}curl 'https://webhook-fanout.com/api/v1/webhooks/$WEBHOOK_ID/endpoints' -H "Authorization: Bearer $WHF_TOKEN"
whf endpoints <slug>
Unregister a target. Its delivery history is kept so past traffic stays readable.
{
error?: string
details?: {
path: string
message: string
}[]
}curl -X DELETE 'https://webhook-fanout.com/api/v1/endpoints/$ENDPOINT_ID' -H "Authorization: Bearer $WHF_TOKEN"
whf endpoints rm <endpoint-id>
List captured requests, newest first. Bodies are omitted here - fetch the detail route for those.
{
limit?: number
offset?: number
q?: string
method?: string
status?: "success" | "failed" | "pending"
}{
id: string
method: string
path: string
query: string
content_type: string | null
body_size: number
remote_ip: string | null
received_at: number
delivery_total: number
delivery_success: number
delivery_failed: number
delivery_pending: number
}curl 'https://webhook-fanout.com/api/v1/webhooks/$WEBHOOK_ID/requests?limit=20' -H "Authorization: Bearer $WHF_TOKEN"
whf requests <slug> --limit 20
Clear all captured traffic for a webhook.
{
error?: string
details?: {
path: string
message: string
}[]
}curl -X DELETE 'https://webhook-fanout.com/api/v1/webhooks/$WEBHOOK_ID/requests' -H "Authorization: Bearer $WHF_TOKEN"
The full captured request plus every delivery and attempt.
{
id: string
method: string
path: string
query: string
content_type: string | null
body_size: number
remote_ip: string | null
received_at: number
headers: [string, string][]
body: string
body_encoding: "utf-8" | "base64"
deliveries: {
id: string
endpoint_id: string
endpoint_url: string
replay_seq: number
status: "pending" | "success" | "failed"
attempt_count: number
response_status: number | null
response_body: string | null
error: string | null
duration_ms: number | null
created_at: number
completed_at: number | null
attempts?: {
attempt_no: number
response_status: number | null
response_body: string | null
error: string | null
duration_ms: number | null
at: number
}[]
}[]
}curl 'https://webhook-fanout.com/api/v1/requests/$REQUEST_ID' -H "Authorization: Bearer $WHF_TOKEN"
whf request <request-id>
Re-send a captured request. Omit endpointId to replay to every enabled target.
{
endpointId?: string
}{
endpointId?: string
}curl -X POST 'https://webhook-fanout.com/api/v1/requests/$REQUEST_ID/replay' \
-H "Authorization: Bearer $WHF_TOKEN" \
-H 'content-type: application/json' \
-d '{}'whf replay <request-id> # --endpoint <id> for just one
The ingest URL. Unauthenticated by design. Answers 202 immediately without waiting on any endpoint.
{
id: string
webhook: string
endpoints: number
}curl -X POST 'https://<slug>.webhook-fanout.com?source=test' \
-H 'content-type: application/json' \
-d '{"hello":"world"}'Limits & retention
[i]
[i]
169.254.169.254) are rejected in production[i]
Recipes
stripe listen --forward-to 'https://<slug>.webhook-fanout.com' stripe trigger payment_intent.succeeded
Same either way — this one is Stripe's own CLI.
for url in https://staging.example.com/gh https://qa.example.com/gh; do
curl -s -X POST 'https://webhook-fanout.com/api/v1/webhooks/$WEBHOOK_ID/endpoints' \
-H "Authorization: Bearer $WHF_TOKEN" -H 'content-type: application/json' -d "{\"url\":\"$url\"}"
donewhf endpoints add <slug> https://staging.example.com/gh whf endpoints add <slug> https://qa.example.com/gh whf endpoints <slug>
REQUEST_ID=$(curl -s 'https://webhook-fanout.com/api/v1/webhooks/$WEBHOOK_ID/requests?limit=1' \
-H "Authorization: Bearer $WHF_TOKEN" | jq -r '.requests[0].id')
curl -X POST "https://webhook-fanout.com/api/v1/requests/$REQUEST_ID/replay" \
-H "Authorization: Bearer $WHF_TOKEN" -H 'content-type: application/json' -d '{}'whf requests <slug> --limit 1 # copy the id whf replay <request-id>
# not possible with curl alone: this server cannot reach
# a machine behind NAT. Use a tunnel and register its public URL:
ngrok http 3000
curl -X POST 'https://webhook-fanout.com/api/v1/webhooks/$WEBHOOK_ID/endpoints' \
-H "Authorization: Bearer $WHF_TOKEN" -H 'content-type: application/json' -d '{"url":"https://<id>.ngrok-free.app"}'whf listen --webhook <slug> --forward-to http://localhost:3000
curl -s 'https://webhook-fanout.com/api/v1/webhooks/$WEBHOOK_ID/requests?status=failed' -H "Authorization: Bearer $WHF_TOKEN" | jq
whf requests <slug> # [x] marks failed deliveries whf request <request-id> # per-endpoint detail