Test GitHub Webhooks Locally
GitHub will not save http://localhost:3000 as a Payload URL. The API answers url is not supported because it isn't reachable over the public Internet (localhost). Use gh webhook forward for a quick look at one repo, or give GitHub a public HTTPS URL that forwards to your machine. Then read the exact X-Hub-Signature-256 and raw body it sent, and replay the delivery until your check passes.
30-second setup
From a stuck handler to a live GitHub delivery you can read and replay.
- 1Start LocalCan and point it at your local server:
localcan http 3000. It opens the tunnel and prints your.localdomains plus a public HTTPS URL. - 2Grab your persistent URL, something like
your-app-12.localcan.dev. It is saved to your project file and comes back identical after every restart, so you wire it up once. - 3In the repo go to Settings → Webhooks → Add webhook and set the Payload URL to your LocalCan URL with your path, e.g.
https://your-app-12.localcan.dev/webhook. Set Content type toapplication/json, type a high-entropy string into Secret (there is no prefix or format), then pick the events to send. For an org hook use the organization’s Settings → Webhooks. - 4GitHub sends a
pingthe moment you save. To send it again without touching the config, callPOST /repos/{owner}/{repo}/hooks/{hook_id}/pings. To re-send a real event, open the webhook → Recent deliveries → click a delivery’s GUID → Redeliver. That works for deliveries from the past 3 days, and GitHub never retries a failure by itself. - 5Open Inspect Traffic in LocalCan. The delivery lands with full headers and body. Expand it, fix your handler, and hit Replay to send the exact same request again. No new test event needed.
Why localhost can't receive GitHub webhooks
GitHub delivers from its own servers, so the Payload URL has to be an address it can reach over the internet. It refuses localhost and 127.0.0.1 when you save.
Other local addresses get past the check and fail later. A LAN IP such as 192.168.1.20, a .local name and host.docker.internal all save without complaint. Deliveries to the LAN IP and the .local name then show up in Recent deliveries as failed to connect to host. What works is a public HTTPS URL that forwards to your local handler.
Just want to see what GitHub sends first? Peek at the raw payload on webhook.cool, then point the endpoint at LocalCan to forward it into localhost.
Register the endpoint (and pass GitHub’s URL check)
GitHub saysurl is not supported because it isn't reachable over the public Internet (localhost)
GitHub checks the Payload URL’s host when the hook is created and answers 422 Validation Failed for localhost or 127.0.0.1, naming whichever you used in the parentheses. That is the REST API’s wording. GitHub’s troubleshooting page lists it as URL host localhost is not supported.
With a …localcan.dev URL the check passes and the ping reaches your handler about a second after you save.
What makes the GitHub loop fast
Leave the hook alone once it works
Each edit to a working GitHub hook is a chance to break its signing. Update it through PATCH /repos/{owner}/{repo}/hooks/{hook_id} without secret in the config and GitHub deletes the secret. The next delivery then arrives with no X-Hub-Signature-256 at all, and a correct verifier answers 401. Events sent while the hook was broken failed once and stay failed until someone redelivers them.
On a paid plan, a Public URL (also called a tunnel) saved in a LocalCan project keeps its …localcan.dev address through restarts, so the Payload URL you register never needs that edit. A quick localcan http tunnel gets a fresh address on every run, which suits a one-off ping but not a long-lived hook.
See the bytes GitHub signed, not the object you parsed
GitHub sends compact JSON in raw UTF-8, with no escaping of <, & or emoji. Because of that, Node’s JSON.stringify(JSON.parse(body)) round trip reproduces the signed bytes, so a handler that hashes the re-parsed object can pass in Node and fail when the same logic runs in Python. That held for a ping, a push and an issues title full of HTML, quotes and an emoji, while Python’s default json.dumps changed all three (it adds a space after , and : and escapes non-ASCII characters).
The inspector shows the X-Hub-Signature-256 header next to the body, indented for reading when it is JSON. The body’s Copy button and localcan traffic get <id> --format json hand you the original bytes, payload= wrapper included when the hook is set to application/x-www-form-urlencoded, so you can hash what GitHub hashed.
Replay without waiting for a real push
Replay sends a captured delivery to your handler again, byte for byte and with its original X-GitHub-Delivery, so you work through a push or pull_request branch without pushing a commit or opening a PR for each attempt. It skips the trip through GitHub, so it does not depend on the delivery still being in GitHub’s 3-day list.
Inspect & replay GitHub events
Common events
pushnot sent when more than three tags are pushed at oncepull_requestissuesissue_commentpingsent the moment you save the webhookrelease
Save the webhook and the ping (X-GitHub-Event: ping) lands in the inspector with every header and the JSON body, followed by the real events as you push commits or open issues. When the handler throws, fix the code and hit Replay to send the identical request again. Deduplicate on X-GitHub-Delivery, which a redelivery keeps.
Acknowledge within 10 seconds and do the slow work afterwards. When GitHub gives up on a slow handler, the inspector shows that request as 499.
- Timeout
- 10 seconds. A handler answering after 9 seconds passed, and one taking 11 failed at the 10-second mark with
context deadline exceeded (Client.Timeout exceeded while awaiting headers). - Retries
- None. The failure reads
giving up after 1 attempt(s). Recovery is Redeliver, by hand or through the REST API, for deliveries from the past 3 days. - Auto-disable
- No failure count is documented. The one automatic disable GitHub describes is for a hook an OAuth app created, in an organization that restricts that app.
Verify the GitHub request
- Header
- X-Hub-Signature-256
- Algorithm
- HMAC-SHA256, lowercase hex (legacy
X-Hub-Signatureis HMAC-SHA1) - What's signed
- GitHub signs the raw request body bytes with your webhook secret and sends
sha256=+ the lowercase hex digest. No timestamp or delivery id is mixed in, just the body. With Content typeapplication/x-www-form-urlencodedthe body ispayload=<urlencoded JSON>, so you must HMAC those exact bytes, not the inner JSON. Whether a re-serialized object still matches depends on your language’s serializer, so verify the bytes you received. - Secret
- A secret you set on the webhook (Settings → Webhooks → Secret, or
config.secretvia the REST API). GitHub does not issue it. There is no prefix or fixed length, just "a random string of text with high entropy". It is optional: if you never set one, deliveries are unsigned andX-Hub-Signature-256is absent entirely (not "wrong").
import express from 'express'
import crypto from 'node:crypto'
const secret = process.env.GITHUB_WEBHOOK_SECRET // the string you typed into the Secret field
const app = express()
// GitHub signs the RAW body. Capture the exact bytes BEFORE any JSON parser.
app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => {
const sig = req.headers['x-hub-signature-256'] // 'sha256=<hex>', absent if no secret set
if (!sig) {
// No secret configured on the webhook → no header. Reject cleanly, do not throw.
return res.status(401).send('signature missing')
}
// req.body is a Buffer (the raw bytes). HMAC those, not a re-stringified object.
const digest = 'sha256=' + crypto.createHmac('sha256', secret).update(req.body).digest('hex')
const a = Buffer.from(sig)
const b = Buffer.from(digest)
// timingSafeEqual throws if lengths differ. Guard, and never use plain ==.
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('signature mismatch')
}
const event = req.headers['x-github-event'] // 'ping' on first save, then 'push', etc.
if (event === 'ping') return res.status(200).send('pong')
const payload = JSON.parse(req.body.toString('utf8')) // Content type application/json
// handle push / pull_request / ...
res.status(202).send('ok')
})
app.listen(3000)LocalCan does not verify the signature for you (your crypto.timingSafeEqual check does), but it hands you both halves so a failure is obvious instead of silent. A payload= wrapper from a form-encoded hook, or a missing header from a hook with no secret, shows up in the inspector in seconds.
Then hit Replay to re-send the identical delivery and confirm the fix, instead of pushing another commit and hoping.
A replayed capture verifies for as long as LocalCan holds it. GitHub signs the body and nothing else (no timestamp, no tolerance window), and replayed ping, push and issues deliveries all passed the same check as the originals. The inspector keeps the latest exchanges in memory (250 by default) and clears them when the daemon restarts, so use Copy as cURL (bodies up to 1 MB) to keep a delivery you want to reuse next week.
LocalCan vs gh webhook forward
GitHub ships its own forwarder as a gh extension (gh extension install cli/gh-webhook, v0.2.0 released in October 2025). Use it when the whole loop is one repo or org and you want no public URL at all. Use LocalCan when one stable URL has to serve GitHub and your other providers, when you want a GUI inspector with Replay, or when you are testing the hook you will actually run, with its own secret and content type.
| LocalCan | gh webhook forward | |
|---|---|---|
| Stable public URL | Persistent …localcan.dev on paid plans | Relays over a WebSocket, no public URL |
| Forward without a URL | Public URL (also reachable by others) | gh webhook forward, no public URL needed |
| Works across providers | One URL for GitHub, Stripe, Shopify… | GitHub only |
| GitHub App / Marketplace hooks | Any Payload URL, including GitHub App hooks | Repo and org hooks only, not App/Marketplace |
| Tests your real webhook config | Your registered hook, its secret and content type | Separate cli hook, signed only with --secret |
| Inspect full payloads | GUI inspector, headers + body (exact bytes via Copy) | Prints bodies only when run without --url, and then forwards nothing |
| Replay a delivery | Replay from the inspector (250 kept in memory by default) | No local replay. The cli hook is deleted when it exits |
| Concurrent use | A hook per developer, up to 20 per event on a repo | One person per repo or org at a time |
GitHub webhook FAQ
Test webhooks for other providers
- Test Stripe Webhooks Locally →
- Test Shopify Webhooks Locally →
- Test Twilio Webhooks Locally →
- Test Slack Webhooks Locally →
- Test Clerk Webhooks Locally →
- Test Supabase Webhooks Locally →
- Test GitLab Webhooks Locally →
- Test Square Webhooks Locally →
- Test OpenAI Webhooks Locally →
- Test Discord Webhooks Locally →
- Test Resend Webhooks Locally →