Documentation

Capture a webhook once, forward it to many targets, inspect exactly what arrived.

Quick start

1. Mint a token
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
2. Create a webhook
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"}'
3. Register a target
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"}'
4. Send a test request
curl -X POST 'https://<slug>.webhook-fanout.com' -H 'content-type: application/json' -d '{"hello":"world"}'
0. Install
curl -fsSL https://webhook-fanout.com/install | sh          # macOS / Linux
irm https://webhook-fanout.com/install.ps1 | iex             # Windows
1. Mint a token, then store it
WHF_TOKEN=$(curl -s -X POST 'https://webhook-fanout.com/api/tokens' | jq -r .token)
whf login --token "$WHF_TOKEN"
2. Create a webhook
whf webhooks new --name stripe-test
3. Register a target
whf endpoints add <slug> https://staging.example.com/hooks
4. Send a test request
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"
[x]Shown once. The token is stored only as a SHA-256 hash. If you lose it, mint a new one — there is no recovery.
[i]
Losing a token does not delete your webhooks, but nothing can reach them again. Delete them from a new account or keep the old token safe.
[-]Account-wide. One token covers every webhook and endpoint on the account.

Receiving webhooks

Every webhook gets a unique subdomain. Any method, any path, any body.

https://<slug>.webhook-fanout.com/any/path?any=query
[+]202 immediately. the sender never waits on your endpoints — fan-out happens in the background
[+]Unauthenticated. whoever holds the URL can post to it, which is what makes it usable by a provider
[+]Path form. /_ingest/<slug> on the main domain is equivalent to the subdomain, and needs no DNS
[i]
Both forms capture identically, including any path suffix and query string. Prefer the path form where subdomain resolution is not guaranteed: locally, *.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.
[x]413. bodies above 10 MiB are rejected rather than truncated
[x]508. an endpoint may not point back at an ingest URL, and a payload that has passed through here 3 times is refused
[i]
Forwarding to an ingest URL creates a cycle: every delivery is captured as a new request and fanned out again, compounding until it fills storage. Two webhooks registered as each other's endpoint do the same thing less obviously. Self-targeting endpoints are refused outright, and a hop counter bounds any cycle that leaves through a third party and comes back.

Fan-out semantics

The forwarded request reproduces the captured one. This is the section to read before trusting the tool with signed webhooks.

[+]Raw bytes. the body is stored and replayed exactly as received — never parsed and re-serialised, so HMAC signatures still verify
[+]Method, path, query. all preserved; the captured path is appended to the endpoint's own path
[+]Headers verbatim, minus hop-by-hop ones: Host Connection Content-Length Transfer-Encoding Keep-Alive Upgrade TE Trailer Expect Proxy-*
[i]
These describe a single TCP hop rather than the message, so forwarding them to a different destination is incorrect. Everything else, including Stripe-Signature and X-Hub-Signature-256, passes through untouched.
[-]Infrastructure headers removed. Our own proxy adds 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]
A captured request should be exactly what the provider sent. Keeping the proxy's own headers would misrepresent that, and forwarding them would leak our topology to your endpoint. The caller's address is not lost — it is recorded separately on the request and shown in the dashboard.
[+]Added on top: X-Webhook-Fanout-Request-Id, X-Webhook-Fanout-Attempt, and X-Webhook-Fanout-Replay on replays

Retries & failure

Endpoint respondsResult
2xx, 3xxsuccess, recorded and done
408, 429retried
5xxretried
timeout, DNS or connection errorretried
any other 4xxpermanent failure, not retried
[-]3 retries per endpoint, each with an independent budget — one dead target never starves the others
[-]10s timeout per attempt
[-]Attempt count is recorded per attempt, so a fully failed delivery shows 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.

install
curl -fsSL https://webhook-fanout.com/install | sh
windows (powershell)
irm https://webhook-fanout.com/install.ps1 | iex
run it
whf listen --webhook <slug> --forward-to http://localhost:3000
stay current
whf update
[+]Why a client and not just a URL? registering http://localhost:3000 as an endpoint would resolve to this server's port 3000, not yours
[i]
There is no route from this server into a machine behind NAT. That is topology, not a setting — no configuration change can make it work. The client solves it by connecting outbound and performing the request on your machine, which also means no port of yours is ever reachable from outside.
[+]Same behaviour as a URL endpoint. relay deliveries appear in the dashboard with the same status, response body and attempt count, retry on the same rules, and can be replayed
[+]Nothing to register. 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 it
[+]Updates itself. whf 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 place
[-]Nothing listening? a delivery waits 5 minutes for a client, then fails with "no relay client connected"
[-]Any tunnel also works. if you would rather not install anything, expose your port with ngrok or cloudflared and register the public URL as an ordinary endpoint
verify the download yourself
curl -fsSL https://webhook-fanout.com/download/SHA256SUMS
Everything else from the terminal
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.

POST/api/tokens

Mint an account and its bearer token. The only unauthenticated route. The token is returned once and is not recoverable.

201 response
{
  created_at: number
}
curl -X POST 'https://webhook-fanout.com/api/tokens'
GET/api/v1/me

Identify the account behind the token.

200 response
{
  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
POST/api/v1/webhooks

Create a webhook. The response carries the ingest URL to hand to your provider.

request body
{
  name?: string
}
201 response
{
  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
GET/api/v1/webhooks

List every webhook on the account, with endpoint and request counts.

200 response
{
  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/api/v1/webhooks/:id

Delete a webhook along with its endpoints, requests and delivery history.

200 response
{
  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>
POST/api/v1/webhooks/:id/endpoints

Register a fan-out target. Rejected if the URL resolves to a private address.

request body
{
  url: string
}
201 response
{
  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
GET/api/v1/webhooks/:id/endpoints

List the targets registered on a webhook.

200 response
{
  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>
DELETE/api/v1/endpoints/:id

Unregister a target. Its delivery history is kept so past traffic stays readable.

200 response
{
  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>
GET/api/v1/webhooks/:id/requests

List captured requests, newest first. Bodies are omitted here - fetch the detail route for those.

query string
{
  limit?: number
  offset?: number
  q?: string
  method?: string
  status?: "success" | "failed" | "pending"
}
200 response
{
  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
DELETE/api/v1/webhooks/:id/requests

Clear all captured traffic for a webhook.

200 response
{
  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"
GET/api/v1/requests/:id

The full captured request plus every delivery and attempt.

200 response
{
  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>
POST/api/v1/requests/:id/replay

Re-send a captured request. Omit endpointId to replay to every enabled target.

request body
{
  endpointId?: string
}
202 response
{
  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
ANY<slug>.webhook-fanout.com/*

The ingest URL. Unauthenticated by design. Answers 202 immediately without waiting on any endpoint.

202 response
{
  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

[-]Request body 10 MiB, larger is rejected with 413
[-]Retention 3 days, swept daily
[i]
Captured requests, their bodies, deliveries and attempts are all removed once they pass 3 days. Webhook health is tracked separately on the webhook itself, so the inactivity check below still works after the evidence is pruned.
[x]Inactive webhooks are disabled. if every delivery has failed for more than 3 days, the webhook stops capturing and stops retrying
[i]
A target that has gone away — a torn-down staging box, an expired tunnel — otherwise burns retries and storage indefinitely. The clock starts at the first delivery that exhausts its retries and resets on any success, so a webhook with no endpoints or no traffic is never touched. Re-enable it from the dashboard once the endpoint answers again.
[-]Response capture first 64 KiB of each endpoint response
[-]Token minting 5 per hour per IP address
[x]Private targets endpoints resolving to loopback, RFC1918, link-local or cloud metadata (169.254.169.254) are rejected in production
[i]
The server fetches whatever URL you register, so an unchecked target would let anyone reach internal services through it. Checked at registration and again at delivery, since DNS can be repointed afterwards.
[-]Large bodies are slower storage sustains roughly 1.5 MB/s, so a 10 MiB capture takes a few seconds to acknowledge; typical webhooks answer in well under a second

Recipes

Point the Stripe CLI at a webhook
stripe listen --forward-to 'https://<slug>.webhook-fanout.com'
stripe trigger payment_intent.succeeded

Same either way — this one is Stripe's own CLI.

Fan a GitHub webhook to two environments
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\"}"
done
whf endpoints add <slug> https://staging.example.com/gh
whf endpoints add <slug> https://qa.example.com/gh
whf endpoints <slug>
Replay after fixing a handler
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>
Debug against your own machine
# 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
Find what failed
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