GitHub Guide

How to Test GitHub Webhooks Locally

Learn how to test GitHub webhooks locally with HookNexus: capture ping, push, pull request, and release events, inspect the real payload, and route the same delivery into localhost.

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

  1. Open your repository on GitHub and navigate to Settings > Webhooks > Add webhook.
  2. Paste the HookNexus endpoint URL into the Payload URL field.
  3. Set Content type to application/json.
  4. 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.
  5. 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 leave ping enabled (it is always sent on creation).
  6. 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:

  1. Status code 200 — the endpoint accepted the delivery.
  2. X-GitHub-Event: ping header — confirms the event type.
  3. X-Hub-Signature-256 header — 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:

HeaderPurpose
X-GitHub-EventThe event type: push, pull_request, release, etc.
X-Hub-Signature-256HMAC-SHA256 signature of the body, using your webhook secret
X-GitHub-DeliveryA unique UUID for this delivery, useful for deduplication
X-GitHub-Hook-IDThe 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 with id, message, author, and modified files.
  • 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:

  1. express.raw() is essential. The signature is computed over the raw request body bytes. If you use express.json() first, the parsed-then-re-stringified body will not match the signature.
  2. 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:

EventTriggerKey payload fields
pingWebhook created or re-enabledzen, hook_id, hook
pushCode pushed to any branchref, commits, pusher, compare
pull_requestPR opened, closed, merged, synchronizedaction, pull_request, number
releaseRelease published, edited, deletedaction, release, release.tag_name
issuesIssue opened, labeled, closedaction, issue, label
workflow_runGitHub Actions workflow completesaction, 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:

  1. Wrong secret — the value in your application does not match the value entered in the GitHub webhook settings. Copy-paste it again.
  2. Body was parsed before verification — if middleware like express.json() runs before your handler, the body bytes change. Use express.raw({ type: "application/json" }) so you receive the untouched buffer.
  3. 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:

  1. Check the event type. Your switch statement may not have a case for the event GitHub sent. Add a default case that logs unhandled events.
  2. Check the action field. For pull_request events, GitHub sends deliveries for opened, closed, synchronize, reopened, labeled, and many more actions. If your code only handles opened, all other actions are silently ignored.
  3. 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/json content type
  • Set a webhook secret and saved it in your local .env file
  • Confirmed the ping event arrived with a 200 status
  • Triggered a real event (push, PR, or release) and inspected the payload
  • Verified the X-Hub-Signature-256 header 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: