To test GitHub webhooks locally, point your repository webhook at a public inspection endpoint such as HookNexus, capture the initial ping event to confirm connectivity, then forward every subsequent delivery straight into your localhost server. This two-stage approach — capture first, forward second — lets you isolate network and configuration issues from application bugs before a single line of handler code runs.
Most developers hit the same wall: GitHub webhooks demand a publicly reachable URL, yet your Express or Fastify server is running on localhost:3000. You could punch a hole through your firewall or spin up a cloud VM, but neither option gives you request inspection, payload replay, or signature verification out of the box. A dedicated webhook debugging tool fills that gap and turns a frustrating setup ritual into a five-minute workflow.
This guide walks through the full process — from creating a public endpoint to writing a production-grade Express handler that verifies the X-Hub-Signature-256 header and routes events by type.
Why GitHub webhook local testing needs a dedicated workflow
GitHub webhooks behave differently from a simple REST API call. Three properties make them harder to debug on localhost without extra tooling.
Public URL requirement. When you save a webhook in the GitHub UI, GitHub immediately sends a ping event to the URL you entered. If that URL is http://localhost:3000/webhooks, the delivery fails instantly because GitHub’s servers cannot reach your machine. You need a publicly routable endpoint that can accept the request and optionally tunnel it back to your local process.
The ping event comes first. Before any push or pull_request delivery, GitHub sends a ping event to confirm the endpoint is alive. If you skip this step and jump straight to triggering real events, you may not realize the endpoint itself is misconfigured — and you will waste time debugging your handler when the problem is upstream.
Payload shapes differ by event type. A push payload contains a commits array and a ref string. A pull_request payload wraps the entire PR object under an action field. A release payload includes asset download URLs. If your handler assumes one shape, it will silently break on another. Inspecting the raw payload for each event type before writing handler logic prevents entire classes of bugs.
What you need before starting
Before you touch the GitHub webhook settings page, confirm you have:
- Repository or organization admin access — only admins can create or edit webhooks.
- A webhook debugging endpoint — sign in to HookNexus and create one in the dashboard. You will get a public URL like
https://hook.hooknexus.com/h/abc123. - The HookNexus CLI (optional but recommended for localhost forwarding) — install it following the CLI installation guide.
- Node.js 18+ if you plan to follow the Express handler examples below.
Step-by-step: testing GitHub webhooks locally
Step 1 — Create a public webhook endpoint
Log in to HookNexus, click Create Endpoint, and copy the generated URL. This URL is where GitHub will send every delivery. All incoming requests are captured in real time so you can inspect headers, body, and status without writing any code.
Step 2 — Configure the webhook in GitHub
- Open your repository on GitHub and navigate to Settings > Webhooks > Add webhook.
- Paste the HookNexus endpoint URL into the Payload URL field.
- Set Content type to
application/json. - Enter a Secret — a random string you will also use in your application to verify signatures. Keep this value safe; you will need it in Step 7.
- Under Which events would you like to trigger this webhook?, choose Let me select individual events and check the ones you need:
push,pull_request,release, and leavepingenabled (it is always sent on creation). - Click Add webhook.
GitHub immediately sends a ping event. Switch back to the HookNexus dashboard to confirm it arrived.
Step 3 — Verify with the ping event
The ping event payload looks like this:
{
"zen": "Speak like a human.",
"hook_id": 401234567,
"hook": {
"type": "Repository",
"id": 401234567,
"name": "web",
"active": true,
"events": ["push", "pull_request", "release"],
"config": {
"content_type": "json",
"insecure_ssl": "0",
"url": "https://hook.hooknexus.com/h/abc123"
}
},
"repository": {
"id": 123456789,
"full_name": "your-org/your-repo"
}
}
Check for three things in the HookNexus request detail view:
- Status code 200 — the endpoint accepted the delivery.
X-GitHub-Event: pingheader — confirms the event type.X-Hub-Signature-256header — proves GitHub signed the payload with your secret.
If the ping does not appear, the URL is wrong or your endpoint is not active. Fix this before moving on — there is no point triggering real events against a broken connection. See GitHub webhook not delivering for a full troubleshooting guide.
Step 4 — Trigger a real event
With the ping confirmed, trigger a real event. The easiest option:
- Push event — commit and push a small change to any branch.
- Pull request event — open, edit, or close a pull request.
- Release event — draft or publish a release.
Each action causes GitHub to send a POST request to your endpoint within seconds. Refresh the HookNexus dashboard to see the new delivery.
Step 5 — Inspect headers and payload
Every GitHub webhook delivery includes these critical headers:
| Header | Purpose |
|---|---|
X-GitHub-Event | The event type: push, pull_request, release, etc. |
X-Hub-Signature-256 | HMAC-SHA256 signature of the body, using your webhook secret |
X-GitHub-Delivery | A unique UUID for this delivery, useful for deduplication |
X-GitHub-Hook-ID | The webhook configuration ID |
In HookNexus, click on the delivery to see the full JSON body. For a push event, the important fields are:
ref— the branch that was pushed to (e.g.,refs/heads/main).commits— an array of commit objects withid,message,author, andmodifiedfiles.pusher— the user who pushed.repository— full repository metadata.
For pull_request events, look at action (opened, closed, synchronize, reopened) and the nested pull_request object.
Understanding these shapes now saves debugging time later when your handler processes them.
Step 6 — Forward to localhost
Once you have confirmed the payload looks correct, forward live deliveries to your local development server using the HookNexus CLI:
# Install the CLI globally
npm install -g hooknexus
# Log in
hooknexus login
# Forward traffic from your endpoint to localhost:3000
hooknexus forward <endpoint-id> --to http://localhost:3000/webhooks/github
Every new delivery that hits the HookNexus endpoint is now replayed to localhost:3000/webhooks/github in real time. Your local server receives the exact same headers and body that GitHub sent — including the signature header, so your verification logic works identically to production. See the forwarding guide for advanced options.
Step 7 — Handle the webhook in your application
Below is a production-grade Express handler that verifies the GitHub signature and routes events. This is the code running on your localhost that receives the forwarded requests.
import express from "express";
import crypto from "node:crypto";
const app = express();
app.post(
"/webhooks/github",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["x-hub-signature-256"];
const event = req.headers["x-github-event"];
const deliveryId = req.headers["x-github-delivery"];
if (!signature || !event) {
return res.status(400).json({ error: "Missing required headers" });
}
// Verify HMAC-SHA256 signature
const secret = process.env.GITHUB_WEBHOOK_SECRET;
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(req.body).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
console.error(`Signature mismatch for delivery ${deliveryId}`);
return res.status(401).json({ error: "Invalid signature" });
}
const payload = JSON.parse(req.body.toString());
// Route by event type
switch (event) {
case "ping":
console.log(`Ping received: ${payload.zen}`);
break;
case "push":
console.log(
`Push to ${payload.ref}: ${payload.commits.length} commit(s)`
);
handlePush(payload);
break;
case "pull_request":
console.log(
`PR #${payload.pull_request.number} ${payload.action}`
);
handlePullRequest(payload);
break;
case "release":
console.log(
`Release ${payload.release.tag_name} ${payload.action}`
);
handleRelease(payload);
break;
default:
console.log(`Unhandled event: ${event}`);
}
res.status(200).json({ received: true });
}
);
function handlePush(payload) {
const branch = payload.ref.replace("refs/heads/", "");
for (const commit of payload.commits) {
console.log(` [${branch}] ${commit.id.slice(0, 7)}: ${commit.message}`);
}
}
function handlePullRequest(payload) {
const pr = payload.pull_request;
console.log(` Title: ${pr.title}`);
console.log(` Base: ${pr.base.ref} <- Head: ${pr.head.ref}`);
}
function handleRelease(payload) {
console.log(` Tag: ${payload.release.tag_name}`);
console.log(` Assets: ${payload.release.assets.length}`);
}
app.listen(3000, () => console.log("Listening on port 3000"));
Two things to note about this handler:
express.raw()is essential. The signature is computed over the raw request body bytes. If you useexpress.json()first, the parsed-then-re-stringified body will not match the signature.crypto.timingSafeEqual()prevents timing attacks on the signature comparison. Never use===for HMAC comparison.
For a deeper look at signature verification patterns and edge cases, see Validate GitHub webhook signatures.
Understanding GitHub webhook payload structure
Event types and their payloads
GitHub supports over 30 webhook event types. The most commonly debugged ones are:
| Event | Trigger | Key payload fields |
|---|---|---|
ping | Webhook created or re-enabled | zen, hook_id, hook |
push | Code pushed to any branch | ref, commits, pusher, compare |
pull_request | PR opened, closed, merged, synchronized | action, pull_request, number |
release | Release published, edited, deleted | action, release, release.tag_name |
issues | Issue opened, labeled, closed | action, issue, label |
workflow_run | GitHub Actions workflow completes | action, workflow_run, conclusion |
Each event type has a completely different top-level structure. Your handler must check X-GitHub-Event before accessing any payload fields.
The X-GitHub-Event header
This header is the single most important piece of metadata in a GitHub webhook delivery. It tells your application which schema to expect. A common mistake is to parse the body first and try to infer the event from the payload fields — this is fragile because different events can share field names (e.g., both pull_request and issues have an action field).
Always dispatch on the header value, then validate the payload shape for that specific event.
Repository vs organization webhooks
GitHub lets you create webhooks at two levels:
- Repository webhooks fire only for events in that specific repository. You configure them under Repo Settings > Webhooks.
- Organization webhooks fire for events across every repository in the organization. You configure them under Org Settings > Webhooks.
Organization webhooks include an additional organization object in the payload and are useful for audit logging, centralized CI triggers, or compliance automation. During local development, repository-level webhooks are simpler to manage because they generate less noise.
Common problems and fixes
Ping succeeds but real events don’t arrive
This usually means the webhook is configured to listen only for the ping event. Go back to Settings > Webhooks, edit the webhook, and verify the correct events are checked. GitHub also provides a Recent Deliveries tab on the webhook settings page — check there to see if deliveries were attempted and what response code your endpoint returned.
Another cause: branch protection or required status checks can delay or prevent pushes, which means the push event never fires. Confirm the triggering action actually completed on GitHub.
Signature verification fails
The most common causes of X-Hub-Signature-256 mismatches:
- Wrong secret — the value in your application does not match the value entered in the GitHub webhook settings. Copy-paste it again.
- Body was parsed before verification — if middleware like
express.json()runs before your handler, the body bytes change. Useexpress.raw({ type: "application/json" })so you receive the untouched buffer. - Encoding mismatch — ensure both sides use UTF-8. If your framework decodes the body as Latin-1, the HMAC will not match.
Here is a standalone signature verification function you can use for debugging:
import crypto from "node:crypto";
function verifyGitHubSignature(secret, payload, signatureHeader) {
if (!signatureHeader) return false;
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(payload).digest("hex");
try {
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected)
);
} catch {
return false;
}
}
// Usage in a test or debugging script
const rawBody = Buffer.from('{"zen":"Speak like a human."}');
const secret = "whsec_test123";
const sig =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
console.log(verifyGitHubSignature(secret, rawBody, sig)); // true
console.log(verifyGitHubSignature("wrong", rawBody, sig)); // false
For a full walkthrough of signature verification, read Validate GitHub webhook signatures.
Event arrives but handler doesn’t process it
If the delivery shows up in HookNexus and your local server receives it but nothing happens:
- Check the event type. Your
switchstatement may not have acasefor the event GitHub sent. Add adefaultcase that logs unhandled events. - Check the action field. For
pull_requestevents, GitHub sends deliveries foropened,closed,synchronize,reopened,labeled, and many more actions. If your code only handlesopened, all other actions are silently ignored. - Check your response code. If your handler throws an error and returns a 500, GitHub marks the delivery as failed and may retry it up to three times over several hours. Look at your server logs for stack traces.
Quick checklist
Use this checklist every time you set up a new GitHub webhook for local testing:
- Created a HookNexus endpoint and copied the public URL
- Added the webhook in GitHub repo/org settings with
application/jsoncontent type - Set a webhook secret and saved it in your local
.envfile - Confirmed the
pingevent arrived with a 200 status - Triggered a real event (push, PR, or release) and inspected the payload
- Verified the
X-Hub-Signature-256header matches your secret - Installed the CLI and started forwarding to localhost
- Tested your handler processes the event correctly and returns 200
Frequently asked questions
Do I need to restart the CLI when I change my local server code?
No. The CLI maintains a persistent connection to your HookNexus endpoint and forwards each delivery independently. When your local server restarts (e.g., via nodemon or ts-node-dev), the next incoming request simply waits for the server to be available again. You only need to restart the CLI if you change the endpoint ID or the target localhost port.
Can I test organization-level webhooks the same way?
Yes. The process is identical — paste your HookNexus endpoint URL into the organization webhook settings instead of the repository settings. The only difference is that organization webhooks fire for every repository in the org, so you will see more traffic. You can filter by repository name in the HookNexus dashboard or in your handler code by checking payload.repository.full_name.
How do I replay a specific delivery without triggering the event again on GitHub?
Open the delivery in the HookNexus dashboard, click Replay, and the exact same request — headers, body, and signature — is sent to your endpoint again. This is especially useful when debugging intermittent failures or when the triggering action (merging a PR, publishing a release) is difficult to repeat. Replay preserves the original X-GitHub-Delivery UUID so your deduplication logic is also tested.
What is the difference between X-Hub-Signature and X-Hub-Signature-256?
X-Hub-Signature uses HMAC-SHA1, while X-Hub-Signature-256 uses HMAC-SHA256. GitHub sends both headers, but SHA-1 is considered weak. Always verify against X-Hub-Signature-256. If you only see the SHA-1 header, your GitHub webhook may have been created before the SHA-256 header was introduced — delete and recreate it.
Next steps
You now have a working local testing workflow for GitHub webhooks. To go deeper:
- Validate GitHub webhook signatures — production hardening for signature verification, including edge cases around encoding and proxy headers.
- GitHub webhook not delivering — systematic troubleshooting when deliveries fail silently.
- Forward webhooks to localhost — advanced CLI options: filtering by event type, rewriting headers, and connecting multiple local services.
- GitHub integration docs — full reference for GitHub-specific features in HookNexus.
- HookNexus CLI installation — getting started with the command-line tool.