Test GitHub Webhooks: Quick Local Setup Methods

Published:

Think you can test GitHub webhooks on localhost without a tunnel? Think again.
GitHub delivers webhooks only to public HTTPS endpoints, so your dev server needs a way to accept external POSTs.
This post shows quick local setup methods: ngrok, localtonet, Hookdeck, a tiny local receiver, and the fast checks to debug timeouts and signature mismatches.
Follow the steps here and you’ll have a working webhook in under five minutes, plus the gotchas to avoid when GitHub marks deliveries as failed.

Core Methods to Test GitHub Webhooks Effectively

Q382UbxDRRqSGa4ojvovTw

GitHub won’t deliver webhook payloads to localhost because your development machine sits behind a private network (NAT). You need a public HTTPS URL that accepts incoming POST requests and returns a 200 status code within roughly 10 seconds. The standard workflow? Expose your local server through a tunnel or relay, add the public endpoint in Repository → Settings → Webhooks → Add webhook, and configure event types like push or pull_request. GitHub fires a ping event immediately when you save a new webhook configuration, and it expects a 200 response to confirm the endpoint is live. If your handler takes longer than about 10 seconds or crashes, GitHub marks the delivery as failed even if your code eventually recovers.

After a webhook fires, inspect delivery history under Settings → Webhooks → Recent Deliveries to see status codes, timestamps, request headers (including X-GitHub-Delivery for idempotency), and the full JSON body GitHub sent. Click any delivery to view headers like X-GitHub-Event, which tells you whether it was a push, pull_request, or release event. Use the Redeliver button to replay the exact payload for debugging. When signature verification or handler logic breaks, redelivery lets you iterate locally without triggering a new commit or pull request. Delivery IDs in the X-GitHub-Delivery header act as unique fingerprints. If you see the same ID twice, GitHub retried the request because it didn’t get a timely 2xx response the first time.

Essential debugging checks when testing GitHub webhooks:

  • Timeout violations – Your handler logs might confirm logic completed, but if GitHub shows “failed,” check response latency. Over about 10 seconds causes GitHub to cut the connection.
  • Raw body inspection – Open Recent Deliveries and copy the JSON to verify payload structure matches your parser expectations. Check nested fields, null values, array counts.
  • Signature mismatch – Compare the X-Hub-Signature-256 header value against your HMAC SHA-256 calculation using the raw request bytes and your configured secret. Body-parsing middleware that transforms JSON before verification will break signatures.
  • Duplicated events – Store X-GitHub-Delivery values in a database or cache and skip processing if the ID already exists. Duplicate IDs indicate retries.
  • Incorrect content type – GitHub sends application/json by default. Handlers expecting form-encoded data or requiring multipart will reject payloads.
  • Handler crashes – If your server returns no response or exits on error, the tunnel or relay may still log the incoming payload. Inspect that raw JSON to find unexpected structures causing exceptions.

Local Tunneling Options for Testing GitHub Webhooks on localhost

CYdY-f56SYqrX1l6tDByyg

Localhost is unreachable from the public internet because typical development machines use private IP addresses behind NAT routers. A tunneling tool creates a temporary public HTTPS URL (something like https://abc123.ngrok-free.app or https://mydev.localto.net) that forwards incoming webhook POST requests to http://localhost:3000 or another local port. Setup usually takes under two minutes. Download a binary, authenticate with an authtoken, create an HTTP tunnel pointed at your local port, and copy the public URL into GitHub’s webhook configuration. When GitHub fires an event, the tunnel process receives the payload at the public endpoint, streams it over an encrypted connection to your laptop, and forwards it to your local HTTP server.

ngrok’s free tier generates ephemeral URLs that change each time you restart the tunnel (format https://a4b2c9d1.ngrok-free.app), which means you must update the webhook URL in GitHub settings after every restart. The free tier may also inject an interstitial warning page for HTML traffic unless you upgrade to a paid plan starting around $8/month. Localtonet lets you choose a stable subdomain (https://mydev.localto.net) so the webhook URL never changes across tunnel restarts. Hookdeck CLI creates a relay endpoint and provides a web dashboard that captures full request/response data. Headers, raw JSON body, IP geolocation, response time. It lets you replay failed deliveries with one click. If you need frequent restarts or plan to keep the tunnel running on a Raspberry Pi, a stable subdomain saves repeated GitHub configuration updates.

Tool URL Stability Setup Time Debugging Features Notes
ngrok (free) Ephemeral; new URL on restart ~2 min Request inspection in web UI; paid tier removes interstitial Best for quick one-off tests; requires webhook URL update after restarts
Localtonet Stable subdomain; same URL across restarts ~2 min Dashboard shows request logs and IP info Ideal for persistent testing; example workflow: localtonet authtoken YOUR_TOKEN
Hookdeck CLI Stable relay endpoint; guest account auto-created ~3 min Web dashboard with full payload capture, retry, filtering by source/destination Strong for debugging failed events; replay without re-triggering GitHub action
localtunnel Randomized subdomain by default ~1 min Minimal; no built-in dashboard Quick setup via npx; less suitable for repeated testing sessions
Custom reverse proxy (home server) Fully stable; your own domain Varies Depends on your logging setup Always-on option; run tunnel on Raspberry Pi or VPS for 24/7 availability

Creating a GitHub Webhook for Testing and Validation

vA03dYp8R-mQ9u8JMI1yPQ

GitHub’s webhook creation UI sits under your repository Settings → Webhooks section (or Organization Settings → Webhooks for org-level hooks). Click Add webhook, paste your public tunnel or relay URL into the Payload URL field, and select application/json as the Content type. If you want signature verification, enter a random string in the Secret field and store the same value as an environment variable (for example, GITHUBWEBHOOKSECRET) in your local application. GitHub enforces a 10 second timeout on all webhook deliveries. If your handler responds after roughly 10 seconds, GitHub may cut the connection even if you eventually return 200, so process heavy work asynchronously and send res.status(200) immediately.

When you save the webhook, GitHub fires a ping event to confirm the endpoint is reachable. A successful ping shows a green checkmark next to the webhook in the UI and logs a delivery under Recent Deliveries with event type ping and a 2xx status code. If the ping fails (red X), check that your tunnel process is running, the public URL resolves, and your local server is bound to the correct port (for example, http://localhost:3000). After ping succeeds, select the events you want to test. Push delivers on every commit push, pull_request fires when PRs are opened or synchronized, and release triggers when you publish a new GitHub release. Choosing “Send me everything” forwards all event types and is useful for capturing sample payloads during exploratory testing.

Step by step webhook creation:

  1. Navigate to your repository, click Settings, then Webhooks in the left sidebar, and click Add webhook (you may need to confirm your GitHub password).
  2. Paste your tunnel’s public HTTPS URL into Payload URL, for example, https://mydev.localto.net/webhook/github, and make sure it ends with your handler’s route path.
  3. Set Content type to application/json and optionally enter a webhook secret in the Secret field. Copy that secret into your local .env file as GITHUBWEBHOOKSECRET.
  4. Under “Which events would you like to trigger this webhook?” choose “Let me select individual events” and check push, pull_request, or any events you need to test.
  5. Click Add webhook. GitHub immediately sends a ping payload and displays the delivery status at the top of Recent Deliveries. A 200 response confirms your endpoint is live.

Building a Minimal Local Webhook Receiver to Test GitHub Events

iR5ULgCLSrmRMYeFbhsYiw

A minimal webhook receiver binds to a local port (commonly 1337, 3000, or 5000) and exposes a POST route that logs incoming JSON payloads. Most developers start by logging the raw request body to the terminal, then save interesting payloads as fixture files in a /fixtures folder for use in automated tests. For example, a new pull request event generates a large JSON object with pullrequest.title, pullrequest.head.sha, and dozens of nested fields. Saving that JSON lets you write unit tests that parse realistic structures without hitting the GitHub API. If you’re validating signatures, you must capture the raw request bytes before any body-parsing middleware transforms the payload, because GitHub’s X-Hub-Signature-256 HMAC is computed over the exact bytes GitHub sent.

Node.js Example

An Express server on port 1337 might define a route /github-webhooks-endpoint that logs req.headers and req.body. Use express.raw({type: ‘application/json’}) for the GitHub route instead of express.json() globally, so the raw buffer remains available for signature verification. After verifying the X-Hub-Signature-256 header using crypto.createHmac(‘sha256’, process.env.GITHUBWEBHOOKSECRET).update(rawBody).digest(‘hex’), parse the JSON manually with JSON.parse(rawBody). Log the event type from req.headers[‘x-github-event’] and save the parsed object to /fixtures/${eventType}-${Date.now()}.json for later replay. Respond with res.status(200).send(‘OK’) immediately to stay under GitHub’s 10 second timeout. Process asynchronously and send 200 quickly, even if you queue the payload for background processing.

Python Flask Example

A Flask app listens on port 5000 with a route @app.route(‘/webhook/github’, methods=[‘POST’]) that reads request.data for the raw bytes and request.getjson() for the parsed dictionary. Validate the signature by computing hmac.new(SECRET.encode(), request.data, hashlib.sha256).hexdigest() and comparing it to the sha256= portion of request.headers.get(‘X-Hub-Signature-256’). If the signature matches, extract eventtype = request.headers.get(‘X-GitHub-Event’) and write the JSON to fixtures/{eventtype}{timestamp}.json. Return (”, 200) to acknowledge receipt. Flask’s return (”, 204) also works but 200 is more explicit. Save multiple fixture files for the same event type (for example, pull_request when opened vs. synchronized) to cover different payload shapes in your test suite.

Using Sample Payloads for Local Testing

After capturing a pullrequest.json fixture, replay it locally without triggering a new PR in GitHub by running curl -X POST http://localhost:1337/github-webhooks-endpoint -H “Content-Type: application/json” -H “X-GitHub-Event: pullrequest” -d @fixtures/pullrequest.json. This lets you iterate on parsing logic, test edge cases (null assignee, empty labels array, renamed files), and validate error handling when required fields are missing. Store fixtures in version control so teammates can run the same tests. If your handler crashes on a specific payload, inspect the saved JSON to find unexpected structures. Nested objects that are sometimes null, arrays that can be empty, or string fields that occasionally contain URLs instead of plain text. Keep a set of representative fixtures for common events (push, pullrequest, issue_comment, release) and update them when GitHub adds new fields to the webhook schema.

Debugging and Replaying GitHub Webhook Deliveries

RFhNfBI1QbiflozDhyoW8g

GitHub displays the last 30 days of deliveries under Settings → Webhooks → Recent Deliveries, showing timestamp, event type, delivery ID (X-GitHub-Delivery), and HTTP status code. Click any delivery to inspect the full request (headers, JSON body) and the response your server returned (status code, response time, response body if any). If a delivery shows 500 or “failed,” the response tab reveals whether your handler crashed, timed out, or returned an error message. Comparing the raw JSON in Recent Deliveries against your local logs helps identify payload differences. GitHub sometimes adds or renames fields in newer webhook versions, breaking older parsers that expect fixed structures.

The Redeliver button replays the exact same payload GitHub sent originally, using the same delivery ID and event type but with a new timestamp. Click Redeliver to test fixes without creating a new commit or opening a new pull request. If your signature verification failed because you used the wrong secret, update GITHUBWEBHOOKSECRET locally, click Redeliver, and confirm the new delivery logs a 200 status. Tunneling and relay dashboards (ngrok’s web UI, Hookdeck’s Events tab) provide similar replay functionality. Filter by source = GitHub, select a failed event, and click Retry to forward the payload to your local endpoint again. Relay dashboards also show response latency in milliseconds, making it easy to spot handlers that take 9+ seconds and risk hitting GitHub’s timeout.

If you need to simulate events outside GitHub’s delivery history, copy the raw JSON from a previous delivery and POST it using curl. Include the X-GitHub-Event header to match the event type (for example, -H “X-GitHub-Event: push”) and optionally add X-GitHub-Delivery with a unique UUID to test idempotency logic. This workflow is faster than triggering real GitHub actions and lets you inject edge-case payloads. Empty commits, PRs with 50+ changed files, release events with prerelease flags that are hard to reproduce manually.

Common debugging workflow steps:

  • Check delivery IDs – Compare X-GitHub-Delivery values in your logs against Recent Deliveries. Duplicate IDs indicate GitHub retried because your first response was slow or missing.
  • Inspect headers – Verify X-GitHub-Event matches your handler’s expected event type, and confirm X-Hub-Signature-256 is present if you configured a secret.
  • Validate signature correctness – Recompute HMAC SHA-256 using the raw request bytes and your local secret. Mismatches mean the secret in GitHub differs from your environment variable or you’re hashing the parsed JSON instead of raw bytes.
  • Diagnose latency/timeouts – If status shows “failed” but your logs show success, check response time in Recent Deliveries or your tunnel dashboard. Times near or over 10 seconds trigger GitHub’s timeout.
  • Replay events using GitHub or curl – Use Redeliver in Recent Deliveries for exact retries, or craft curl commands with saved fixture JSON to test specific payload structures without waiting for new GitHub activity.

Security, Signature Verification, and Webhook Reliability When Testing

vaQwZY5rQUyWI4O5EIWcaA

GitHub signs every webhook payload with HMAC SHA-256 and includes the result in the X-Hub-Signature-256 header (format sha256=). Verification requires computing the HMAC over the raw request bytes using the secret you configured in GitHub’s webhook settings, then comparing your computed digest to the value GitHub sent. If you parse the JSON body before verification or use a framework that automatically deserializes request data, you’ll hash transformed bytes instead of the original payload and signatures will never match. Use express.raw for GitHub routes rather than express.json globally. Store the webhook secret in environment variables or a secrets manager, never commit it to version control, and rotate it if you suspect exposure (especially if you ran a public tunnel for extended periods).

GitHub retries deliveries when it doesn’t receive a 2xx response or when the connection times out. Duplicate deliveries occur if your handler takes too long to respond or crashes before sending a status code. Use the X-GitHub-Delivery header as an idempotency key by storing seen delivery IDs in a database or cache (Redis SET with expiration works well). Before processing a payload, check if the delivery ID already exists. If so, return 200 immediately without reprocessing. This pattern prevents duplicate pull request comments, double issue assignments, or repeated CI pipeline triggers when GitHub retries the same event. Respond with res.status(200) as soon as you’ve queued the payload for background processing. If heavy work (API calls, database writes, file uploads) happens on the request thread, you risk timeouts and unnecessary retries.

Best practices for secure and reliable webhook testing:

  • Always verify signatures – Compute HMAC SHA-256 on raw request bytes using your configured secret and reject payloads with mismatched or missing X-Hub-Signature-256 headers.
  • Deduplicate using delivery IDs – Store X-GitHub-Delivery values and skip processing if the same ID arrives twice. GitHub retries on slow or failed responses.
  • Respond quickly, process asynchronously – Return 200 within a few hundred milliseconds and move heavy logic to a background job queue (Bull, Celery, or a simple setTimeout) to avoid GitHub’s 10 second timeout.
  • Secure secret storage – Keep webhook secrets in environment variables or a vault. Rotate them if your tunnel was publicly accessible for days or if logs show unexpected delivery attempts from unknown IPs.

Final Words

In the action, you learned core methods: create a public HTTPS endpoint, use tunneling to receive localhost deliveries, build a minimal receiver, and inspect GitHub’s delivery history.

We covered exact setup steps, the ping/timeout behavior, essential debugging checks (timeouts, raw body, signature mismatches, duplicates, content-type, handler crashes), and how to replay deliveries for fast iteration.

Use those steps to test github webhooks quickly—set a relay or ngrok, confirm pings, verify signatures, and replay failures. You’ve got a repeatable workflow to catch issues before they reach production.

FAQ

Q: Why does GitHub require a public HTTPS URL to deliver webhooks?

GitHub requires a public HTTPS URL because it can’t reach localhost behind NAT; webhooks must target a reachable HTTPS endpoint, so use a tunnel or public server for deliveries and debugging.

Q: What happens when I add a webhook to a repository?

When you add a webhook, GitHub sends an immediate ping event and expects a 2xx response within about 10 seconds; failures show up in the Recent Deliveries panel for inspection.

Q: How can I test GitHub webhooks on my localhost?

You test GitHub webhooks on localhost by running a tunneling tool that exposes a public HTTPS URL mapping to your local port, or by deploying a short-lived public endpoint for GitHub to call.

Q: How do ngrok, Localtonet, and Hookdeck compare for webhook testing?

Ngrok gives fast setup but new free URLs each restart; Localtonet offers stable subdomains with an authtoken; Hookdeck adds a relay, dashboard, and replay features—choose by URL stability, debugging needs, and budget.

Q: How do I create and configure a webhook in GitHub?

To create a webhook, go to Repository → Settings → Webhooks → Add webhook, set a public HTTPS Payload URL, content-type application/json, optional secret, choose events, then save and watch for the ping.

Q: How should I verify GitHub webhook signatures?

Verify signatures by computing HMAC SHA-256 over the raw request bytes using your configured secret, then compare that value to the X-Hub-Signature-256 header and reject mismatches before processing.

Q: How do I debug failed webhook deliveries and redeliver events?

To debug failures, open Webhooks → Recent Deliveries to view headers, raw JSON, X-GitHub-Delivery ID, status and latency; use Redeliver to replay or copy the raw body and reproduce with curl.

Q: What quick checks should I run when testing webhook handlers?

Quick checks when testing webhooks: timeouts/latency, raw body capture, signature mismatch, duplicated events, incorrect content-type, and handler crashes to catch the usual failure modes.

Q: How do I make webhook processing idempotent and reliable?

Make processing idempotent by recording X-GitHub-Delivery IDs and skipping duplicates; respond quickly with 2xx, and offload heavy work to a background queue to avoid retries.

Q: Can I replay GitHub events locally without GitHub’s UI?

You can replay events by using GitHub’s Redeliver or by saving a JSON fixture and replaying with curl, setting X-GitHub-Event and X-Hub-Signature-256 (or disabling signature check for local tests).

curtisharmon
Curtis has spent over two decades guiding hunters and anglers through the backcountry of Montana and Wyoming. His expertise in elk hunting and fly fishing has made him a sought-after voice in the outdoor community. Curtis combines traditional woodsmanship with modern techniques to help readers succeed in the field.

Related articles

Recent articles