Watch webhooks arrive, and see why they went where they did
`hookman tail` streams every delivery as it lands, and each delivery can now explain which rule caught it and why the earlier ones didn't.
Debugging a webhook has always gone the same way. Trigger the event, refresh the logs, click into the row, squint at the payload, and then wonder why it went to staging when you expected the preview you’d pointed it at.
Two things shorten that loop: a live tail, and a “why did it go there?” explanation on every delivery.
hookman tail
$ hookman tail --org acme --project paymentsTailing acme/payments — Ctrl+C to stop 13:15:14 POST 200 118ms default → main 019e6429 13:15:14 POST 200 96ms stripe-test-events → staging cbb16676 13:15:15 POST 500 212ms preview-header → pr-42 d5d8a79d 13:15:16 POST 200 104ms default → main e800a7a2One line per delivery as it arrives: the time, method, status, latency, the rule that matched (or default when the active deployment took it), the deployment it went to, and the delivery ID. That ID is the same one your handler receives in the x-hookman-delivery-id header, and a replay or retry keeps the original’s, so you can follow one webhook through its attempts.
It starts from now. It shows what arrives while it runs, not history (that’s hookman logs), so it pairs naturally with hookman listen: run both and watch each webhook reach your machine. You can narrow it down with --status 4xx,5xx, --branch pr-42 or --rule stripe-test-events, and --json prints one JSON object per line for piping to jq.
The project’s Logs tab in the dashboard works the same way. It’s live by default, new webhooks appear at the top within a couple of seconds, and you can pause it to read without the rows moving.
The output in this post is from the real CLI and API code running locally against sample webhooks, not production traffic.
Why did it go there?
Look at the third line. It’s a Stripe test event, so you’d expect stripe-test-events to send it to staging. Instead it went to pr-42, and came back a 500.
Open it in the dashboard and the Routing decision section shows why. This is what comes back for that delivery:
"trace": [ { "ruleId": "r1", "ruleName": "preview-header", "matched": true }]The webhook also carried an x-preview: pr-42 header, preview-header is checked first, and the first match wins, so stripe-test-events was never tried. Compare the second line, which went to staging as intended:
"trace": [ { "ruleId": "r1", "ruleName": "preview-header", "matched": false, "failedCondition": "header[x-preview] eq \"pr-42\"" }, { "ruleId": "r2", "ruleName": "stripe-test-events", "matched": true }]The dashboard lays that out as a checklist:
✗ preview-header header[x-preview] eq "pr-42"✓ stripe-test-eventsAnd the first line, which matched nothing, lists every rule with the condition that failed before falling through to the active deployment.
How it works, and what it can’t tell you
The live stream is a long-poll. The CLI asks for everything after a cursor and the API holds the request open until something newer arrives. The cursor follows the order deliveries were recorded, not their timestamps. Logs reach the database through a queue, so one can land late with an earlier time, and a timestamp cursor would skip it. Because of that queue, expect a few seconds between a webhook arriving and showing up.
The routing decision is recomputed, not recorded. Writing the evaluation trace on every webhook would add work to the proxy’s hot path, and we’d rather keep that fast. Instead, when you open a delivery we take the routing config that was live at that moment, run it against the payload we stored, and show the result. That has limits, and the panel tells you when they apply:
- It needs payload storage turned on for the project, and the payload still within your retention window.
- The query string is only recorded for webhooks received from now on, so for older ones, rules that look at query parameters can’t be re-checked.
Authorization,CookieandX-Api-Keyheaders are never stored, so rules on them can’t be re-checked.- If the recomputed answer doesn’t match where the webhook actually went, it says so rather than presenting a guess as fact.
Replays, mirrored fan-out copies and webhooks rejected for quota aren’t the result of routing, so they have no decision to show.
Try it
npm i -g @hookman/clihookman tail --project paymentsThen trigger a webhook and watch it land. There’s more in the docs.