NewLocalCan 3.0 with CLI, Multi-region and more ⟶

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.

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 (there is no prefix or format), then pick the events to send. For an org hook use the organization’s Settings → Webhooks.
  4. 4GitHub sends a ping the moment you save. To send it again without touching the config, call POST /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.
  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 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 once
  • pull_request
  • issues
  • issue_comment
  • pingsent the moment you save the webhook
  • release

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-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. 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. 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.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')) // 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.

LocalCangh webhook forward
Stable public URLPersistent …localcan.dev on paid plansRelays 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 only
GitHub App / Marketplace hooksAny Payload URL, including GitHub App hooksRepo and org hooks only, not App/Marketplace
Tests your real webhook configYour registered hook, its secret and content typeSeparate cli hook, signed only with --secret
Inspect full payloadsGUI inspector, headers + body (exact bytes via Copy)Prints bodies only when run without --url, and then forwards nothing
Replay a deliveryReplay from the inspector (250 kept in memory by default)No local replay. The cli hook is deleted when it exits
Concurrent useA hook per developer, up to 20 per event on a repoOne person per repo or 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.