Test GitHub Webhooks on Localhost
GitHub has no official local forwarder like Stripe CLI — a tunnel is usually the fastest way to exercise Recent Deliveries and signature verification together. See the webhook hub for the CLI-vs-tunnel decision tree.
Try it in 60 seconds
GitHub cannot POST to localhost. Before debugging signature code, confirm delivery works:
npm run dev
npx localtolink --port 3000
Register https://k7m2x9p4q1.localtolink.com/api/github/webhook in your repo's webhook settings. GitHub sends a ping — your handler returns 200. If Recent Deliveries shows green, your networking is solved; any remaining issues are application logic.
See why webhooks fail on localhost for the general diagnosis flow.
Why GitHub webhooks need a public URL
GitHub sends webhook POST requests when repository events occur — pushes, pull requests, issues, releases, and dozens more. Your handler runs on localhost, which GitHub's servers cannot reach. A development tunnel provides a public HTTPS URL that forwards deliveries to your local machine.
Unlike Stripe, GitHub does not ship a CLI that forwards webhooks to localhost. A tunnel is the standard approach.
Setup
Terminal 1 — your app with a webhook handler:
npm run dev
Ensure you have a route that accepts POST requests, e.g. /api/github/webhook.
Terminal 2 — tunnel:
npx localtolink --port 3000
Copy the subdomain from the output.
Configure GitHub webhook
In your repository → Settings → Webhooks → Add webhook:
| Field | Value |
|---|---|
| Payload URL | https://k7m2x9p4q1.localtolink.com/api/github/webhook |
| Content type | application/json |
| Secret | A random string you choose (used for verification) |
| SSL verification | Enable (default) |
| Events | Choose individual events or "Send me everything" |
Replace k7m2x9p4q1 with the 10-character subdomain LocalToLink prints.
GitHub sends a ping event immediately after creation. Your handler should respond with 200 to confirm delivery.
Verify signatures
GitHub signs payloads with HMAC-SHA256. Always verify before processing:
import crypto from "crypto";
function verifySignature(payload, signature, secret) {
if (!signature) return false;
const hmac = crypto.createHmac("sha256", secret);
const digest = "sha256=" + hmac.update(payload).digest("hex");
try {
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature));
} catch {
return false;
}
}
// In your handler — use raw body, not parsed JSON
const sig = req.headers["x-hub-signature-256"];
if (!verifySignature(rawBody, sig, process.env.GITHUB_WEBHOOK_SECRET)) {
return new Response("Invalid signature", { status: 401 });
}
Set GITHUB_WEBHOOK_SECRET to the secret you configured in GitHub webhook settings.
Test delivery
After creating the webhook:
- Check Recent Deliveries in webhook settings for the ping event
- Green checkmark = your handler returned 2xx
- Red X = click for request/response details
- Use Redeliver to replay a failed delivery
Trigger real events by pushing a commit, opening a PR, or using GitHub's webhook test features.
Event types worth testing locally
| Event | When to subscribe |
|---|---|
push | CI triggers, deployment hooks |
pull_request | Review automation, status checks |
issues | Project management integrations |
release | Changelog bots, release notifications |
workflow_run | Actions orchestration (GitHub Apps) |
Start with one event type, verify delivery, then add more. Subscribing to "Send me everything" generates noise during active development.
GitHub App vs repository webhook
Repository webhooks (this guide) — configured per repo under Settings → Webhooks.
GitHub Apps — webhooks configured in the app settings with a different delivery format. The tunnel URL pattern is the same; verification uses the app's webhook secret.
Important notes
- GitHub POSTs are server-to-server; for human checks in a browser, open the link once and continue through any safety prompt
- Update the Payload URL when you start a new tunnel session (new subdomain)
- Session limit: 2 hours — restart tunnel for longer testing
- LocalToLink has no request inspector — use GitHub's Recent Deliveries for debugging
- Rate limit 10 req/s on free tier — sufficient for normal webhook volume
- GitHub retries failed deliveries — return 200 quickly and process asynchronously
Troubleshooting
| Problem | Fix |
|---|---|
| Ping delivery fails | Confirm handler route exists and returns 200 |
| Signature verification fails | Use raw body; confirm secret matches GitHub settings |
| Session ended | Restart tunnel and update Payload URL |
| Events not firing | Check you subscribed to the right event types |
| Duplicate events | GitHub retries on timeout — make handler idempotent |
FAQ
Can I test organization webhooks?
Yes. Configure at the organization level with the same tunnel URL pattern.
Does this work with GitHub Enterprise Server?
Self-hosted GHE needs to reach your tunnel URL from its network. Confirm outbound access is allowed.
Should I use a different tunnel for request inspection?
If you need visual capture and replay, use logging plus GitHub Recent Deliveries first. Add specialized inspection tooling only when delivery logs are insufficient.
See Test Webhooks Locally for the general webhook hub.
Try it now
Start a tunnel from your project directory — no account required.
$ npx --yes localtolink@latest