NewLocalCan 3.0 Beta with CLI, Multi-region and more ⟶

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.

Start Free Trial →DownloadFree trial. No credit card required.

30-second setup

From a stuck handler to a live GitHub delivery you can read and replay.

  1. 1Start LocalCan and point it at your local server: localcan http 3000. It opens the tunnel and prints your .local domains plus a public HTTPS URL.
  2. 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.
  3. 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 to application/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.
  4. 4GitHub sends a ping the 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/webhook relays real events to your machine.
  5. 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

  • push
  • pull_request
  • issues
  • issue_comment
  • pingsent the moment you save the webhook
  • release

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-Signature is 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 type application/x-www-form-urlencoded the body is payload=<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.secret via 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 and X-Hub-Signature-256 is absent entirely (not "wrong").
Node.js
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.

LocalCangh webhook forward
Stable public URLPersistent …localcan.dev, survives restartsRelays over a WebSocket; no public URL
Forward without a URLPublic URL (also reachable by others)gh webhook forward — no public URL needed
Works across providersOne URL for GitHub, Stripe, Shopify…GitHub repo/org webhooks only
GitHub App / Marketplace hooksAny Payload URL, including GitHub App hooksRepo & org webhooks only — not App/Marketplace
Tests your real webhook configGitHub posts to your registered hook + its secretTemp CLI hook — not your config or secret
Inspect full payloadsGUI inspector, headers + raw body, on by defaultTerminal output of forwarded events
Replay a deliveryOne-click Replay of the exact captured requestRe-run / Redeliver via REST or the deliveries UI
Concurrent useAny number of tunnelsOne 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.