Test GitHub Webhooks Locally
Your webhook shows a red ✗ in Recent deliveries and your handler logs a signature mismatch — usually a parsed body, the wrong Content type, or no secret set at all. Get a stable URL GitHub can reach, see the exact X-Hub-Signature-256 and raw body it sent, and Redeliver until it verifies.
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 (this is the key GitHub will sign with — there is no fixed format or prefix), then pick the events to send. For an org hook use Org Settings → Webhooks. - 4GitHub sends a
pingthe moment you save. To re-fire later, open the webhook → Recent deliveries → click a delivery’s GUID → Redeliver. GitHub does not auto-retry failures and only keeps the last 3 days of deliveries. For a live local loop,gh webhook forward --repo=OWNER/REPO --events=push --url=http://localhost:3000/webhookrelays real events to your machine. - 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 webhooks from its servers to a public HTTPS Payload URL. localhost:3000 only exists on your machine, so GitHub has nothing to connect to — the very first ping fails and the webhook shows a red ✗ under Recent deliveries.
You need a public URL that forwards to your local handler — and one that does not change on restart. GitHub never auto-retries a failed delivery, so a URL that rotates overnight means a dead Payload URL and a manual re-edit (plus the risk of a mismatched secret) every single morning.
What makes the GitHub loop fast
Set the Payload URL once
Your …localcan.dev URL survives restarts and sleeps, so the Payload URL you save in Settings → Webhooks keeps working tomorrow. That matters more here than for most providers: GitHub does not automatically redeliver failed deliveries, so a URL that rotated overnight silently drops every push and pull_request until you notice the red ✗ and re-edit it — and re-pasting the URL is exactly when a stray newline sneaks into the Secret field.
See the raw X-Hub-Signature-256
The inspector shows the exact X-Hub-Signature-256 header (and the legacy X-Hub-Signature) plus the untouched body GitHub sent. That is precisely what you need to debug a mismatch: confirm the sha256= digest against what your code computes, and check the body is bare JSON — not payload=<urlencoded> from a x-www-form-urlencoded webhook — instead of guessing why the hash did not match. If the header is missing entirely, you forgot the Secret.
Redeliver without waiting for a real push
Hit Replay to send the identical captured delivery again — same bytes, same signature — without pushing a commit or opening a PR to trigger a fresh event, and without GitHub’s 3-day deliveries window expiring underneath you. Iterate on your handler against the real payload until the signature verifies and your ping/push branches both return 2xx.
Inspect & replay GitHub events
Common events
pushpull_requestissuesissue_commentpingsent the moment you save the webhookrelease
These are the events most apps wire up first. Save the webhook and GitHub immediately sends a ping (X-GitHub-Event: ping) — watch it land in Inspect Traffic with full headers and JSON body, then push a commit or open a PR for the real push / pull_request. When the handler throws, fix the code and hit Replay — LocalCan re-sends the exact same delivery, so you are testing against the real payload, not a hand-rolled fixture.
Two GitHub-specific caveats. First, that opening ping is not one of your business events: if your handler only branches on push/pull_request and 500s on anything else, the ping shows red — switch on X-GitHub-Event and return 2xx for it. Second, GitHub keeps only the last 3 days of deliveries and never auto-retries, so capture and Replay locally rather than relying on its Redeliver button after the fact.
Verify the GitHub signature
- 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. Critical: with Content typeapplication/x-www-form-urlencodedthe body ispayload=<urlencoded JSON>, so you must HMAC those exact bytes, not the inner JSON. Parse or re-serialize the body before hashing and the signature can never match. - 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'))
// handle push / pull_request / ...
res.status(202).send('ok')
})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. The inspector shows the real X-Hub-Signature-256 GitHub sent and the untouched raw body, so you can tell a parsed body from a x-www-form-urlencoded payload= wrapper from a missing-secret (no header at all) in seconds.
Then hit Replay to re-send the identical delivery and confirm the fix, instead of pushing another commit and hoping.
LocalCan vs gh webhook forward
GitHub ships a genuinely good local-forward tool in the gh CLI (gh webhook forward, installed via gh extension install cli/gh-webhook). The honest split: use it when your whole loop is one GitHub repo or org and you want zero public URL; reach for LocalCan when one stable URL has to serve GitHub and everything else, when you want a GUI inspector and replay the whole team can use, or when you are testing your real production webhook config and its signing secret.
| LocalCan | gh webhook forward | |
|---|---|---|
| Stable public URL | Persistent …localcan.dev, survives restarts | 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 repo/org webhooks only |
| GitHub App / Marketplace hooks | Any Payload URL, including GitHub App hooks | Repo & org webhooks only — not App/Marketplace |
| Tests your real webhook config | GitHub posts to your registered hook + its secret | Temp CLI hook — not your config or secret |
| Inspect full payloads | GUI inspector, headers + raw body, on by default | Terminal output of forwarded events |
| Replay a delivery | One-click Replay of the exact captured request | Re-run / Redeliver via REST or the deliveries UI |
| Concurrent use | Any number of tunnels | One forwarder per repo/org at a time |
GitHub webhook FAQ
Stop guessing why the webhook 401'd. Get a stable GitHub endpoint, read every delivery, and replay it until your handler is right.
Free trial. No credit card required.