Debug a GitHub webhook that never reaches localhost
You're building a webhook handler. It runs on localhost:3000. GitHub needs a public HTTPS address to send to, so the delivery never arrives, and you're writing your handler against the JSON sample in the docs instead of
You're building a webhook handler. It runs on localhost:3000. GitHub needs a public HTTPS address to send to, so the delivery never arrives, and you're writing your handler against the JSON sample in the docs instead of what GitHub really sends.
Here's a way to get the real delivery onto your machine, headers and body intact, without a tunnel and without installing anything.
1. Get a public address that records everything
Open curler.dev and click Create a bin. The top bar shows a capture URL like:
https://api.curler.dev/h/fZU_aI73r6LZkEr4eThskAPiWwXaXa
Anything sent to that address, or to any path below it, is recorded: method, path, headers and the raw body.
2. Point GitHub at it
In your repository, open Settings → Webhooks → Add webhook:
-
Payload URL: the capture URL, plus a path if your handler uses one, for example
https://api.curler.dev/h/<your-bin>/webhooks/github -
Content type:
application/json - Secret: the same secret your handler checks
Save, then push a commit. The delivery shows up in the bin within a couple of seconds. Click it to see every header GitHub sent (X-GitHub-Event, X-GitHub-Delivery, X-Hub-Signature-256) and the body as a JSON tree.
3. Replay it against localhost
Under the request, set replay target to your app's address, for example http://localhost:3000, and copy the Bash command. It comes as one line; here it is shortened and wrapped for reading:
printf '%s' '{"ref":"refs/heads/main","repository":{"full_name":"octo/demo"},...}' \
| curl --globoff -X 'POST' 'http://localhost:3000/webhooks/github' \
-H 'User-Agent: GitHub-Hookshot/…' \
-H 'Content-Type: application/json' \
-H 'X-Github-Delivery: 72d3162e-cc78-11e3-81ab-4c9367dc0958' \
-H 'X-Github-Event: push' \
-H 'X-Hub-Signature-256: sha256=…' \
--data-binary @-
Paste it in a terminal and your handler receives the same request GitHub sent. Run it again as often as you like while you fix your code: set a breakpoint, change a line, replay.
There's a PowerShell version next to it for Windows.
Why the body has to be byte for byte
GitHub signs the raw body with your secret, and your handler checks X-Hub-Signature-256 against the bytes it receives. One changed space, a re-ordered key or a different line ending, and the check fails.
Curler stores the body exactly as it arrived, and the copied command sends it back byte for byte. A binary body goes through base64 so the shell can't alter it. So the signature check in your handler passes on a replay, exactly as it would on a live delivery.
When it's not GitHub
The same steps work for any sender that needs a public URL: Stripe, Shopify, Slack, or the callback of an SSO provider such as Okta or Auth0. For a login callback, Curler shows you the exact query string or form post the provider sent, which is usually where the reason for a failed login hides.
Good to know
- Curler is free: a bin lives 24 hours and keeps its newest 100 requests.
- The bin's page updates on its own while it's open, so you can leave it next to your editor.
- Curler's server sleeps when nobody uses it, and the first call after a quiet spell can take about 10 seconds. GitHub waits 10 seconds for an answer, so open your bin's page before you trigger the first delivery. If one is missed, Redeliver on GitHub's webhook page sends it again.
- Captured content is never rendered as HTML, so a hostile payload can't run in your browser.
I'm the developer of Curler. If something about your webhook didn't show up the way you expected, tell me: [email protected].
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.