Debug a GitHub Webhook That Isn't Reaching Your Server
Start in GitHub: open the repository's Settings → Webhooks, edit the webhook and read Recent Deliveries. If a new event is missing, check the event selection and Active checkbox; a connection error or timeout means GitHub could not connect or get a response in 10 seconds; a 2xx means the delivery succeeded; a 4xx or 5xx means your app or a proxy in front of it returned an error, most often because of a wrong path, an X-Hub-Signature-256 mismatch or an unreachable upstream.
Testing on localhost? GitHub can't reach it. Run hooklistener tunnel --port 3000, set the printed HTTPS URL plus your route as the Payload URL, and watch each delivery arrive in the tunnel log with its method, path, status and duration.
What Recent Deliveries Is Telling You
| You see | It means | Check |
|---|---|---|
| No delivery for the event | No recent delivery recorded | Selected events, Active checkbox, right repo/org/App |
| Connection error, no status | GitHub could not reach the URL | Public URL, DNS, TLS certificate, tunnel running |
| Timed out | No response within 10 seconds | Verify signature, enqueue work, return 2xx |
| 404 / 405 | Route mismatch | Payload URL path, POST handler exists |
| 401 / 403 | Request rejected before your logic ran | Signature check, auth or CSRF middleware |
| 500 | Usually an unhandled error in your app | App logs, Response tab body, payload shape |
| 502 / 503 / 504 | Often a proxy that could not reach your app | Proxy upstream config, app running, proxy timeout |
Six Checks, in Order
- 01
Open the delivery log in GitHub
Go to the repository's Settings → Webhooks, edit the webhook and open Recent Deliveries. Each delivery shows the request GitHub sent and the response it got back. If a newly triggered event is not listed, check the selected events, the Active checkbox and that you are looking at the right webhook (repository, organization or GitHub App).
- 02
Read what the failed delivery says
A connection error or timeout with no HTTP status means GitHub could not reach the Payload URL or your server took longer than 10 seconds to answer. A 2xx status means the delivery succeeded. A 4xx or 5xx status means something answered with an error: your app, or a reverse proxy, load balancer or tunnel in front of it.
- 03
Fix 4xx and 5xx responses
404 usually means the Payload URL path does not match your route, 405 means the route does not accept POST, and 401 or 403 usually come from signature checks, auth or CSRF middleware. A 500 usually comes from your app, but 502, 503 and 504 are often returned by a proxy or load balancer that could not reach your app or got no answer from it, so check the proxy's upstream and that the app is running before reading handler logs. The Response tab in Recent Deliveries shows the body and headers that came back.
- 04
Check the X-Hub-Signature-256 signature
Compute an HMAC-SHA256 of the raw request body with the webhook secret, prefix it with sha256= and compare it to the header in constant time. Most mismatches come from hashing re-serialized JSON instead of the raw bytes, or from a secret that differs from the one saved in GitHub.
- 05
Check the tunnel when testing on localhost
GitHub cannot reach localhost. Run hooklistener tunnel --port 3000, use the printed HTTPS URL plus your route as the Payload URL, and watch the tunnel log. If GitHub reports a failure but the tunnel log is empty, check the URL and tunnel connection; if the tunnel shows the request but your app logs nothing, check the app is listening on the configured host and port (for an app in Docker, publish the container port and tunnel to that host port).
- 06
Redeliver and confirm a 2xx
GitHub does not retry failed deliveries on its own. While you iterate, you can replay the captured request from Hooklistener and check each attempt's status in Hooklistener and your app logs; these replays do not go through GitHub and do not change Recent Deliveries. Once the handler works, click Redeliver on the failed delivery in GitHub and confirm the new attempt shows a 2xx in Recent Deliveries.
No Delivery at All
Check a newly triggered event: GitHub only lists deliveries from the past three days. Confirm that the webhook is Active and subscribes to that event; selecting only push will not send pull_request or issues events. Repository, organization and GitHub App webhooks have separate logs (App deliveries are in its settings, under Advanced). GitHub also skips payloads larger than 25 MB.
Creating a webhook sends a ping event. If it failed, inspect its response before triggering another event. If it is missing, confirm you opened the correct webhook.
Connection Errors and Timeouts
A delivery with no HTTP status means GitHub never got a response. Check that the Payload URL is a public hostname (not localhost or a private IP), that DNS resolves, and that the TLS certificate is valid. Leave SSL verification on and fix the certificate instead.
GitHub waits 10 seconds for a response. If your handler calls other APIs, runs a build or writes a lot to the database before answering, the delivery fails even though your code eventually succeeds. Verify the signature, enqueue the work, and return 202 straight away.
Trace 4xx and 5xx to the Component That Returned Them
Open the failed delivery's Response tab for its status, headers and body. A proxy error page can explain why your handler logged nothing. Compare the Request tab's X-GitHub-Delivery with your app logs and use the status table above. For 401/403, check session auth and CSRF middleware as well as the signature; exempt only the webhook route from browser-session checks and keep signature verification.
A 413 means a server or proxy body-size limit rejected the payload. A 500 can come from reading a pull_request field on a ping event: route on X-GitHub-Event before accessing event-specific fields. For 502/503/504, verify the app is running and the proxy's upstream host and port match it. A 2xx only confirms HTTP acceptance; check worker logs separately if the expected action never happens.
X-Hub-Signature-256 Mismatches
GitHub signs the raw body with your webhook secret. Verify against the exact bytes, before any JSON parsing:
import crypto from "node:crypto";
import express from "express";
const app = express();
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (!secret) throw new Error("Set GITHUB_WEBHOOK_SECRET before starting");
// Raw body for this route only, so the bytes match what GitHub signed.
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
if (!Buffer.isBuffer(req.body)) return res.sendStatus(415);
const expected =
"sha256=" +
crypto
.createHmac("sha256", secret)
.update(req.body)
.digest("hex");
const received = req.get("X-Hub-Signature-256") ?? "";
const receivedBytes = Buffer.from(received, "utf8");
const expectedBytes = Buffer.from(expected, "utf8");
const valid =
receivedBytes.length === expectedBytes.length &&
crypto.timingSafeEqual(receivedBytes, expectedBytes);
if (!valid) return res.status(401).send("bad signature");
const event = req.get("X-GitHub-Event");
const payload = JSON.parse(req.body.toString("utf8"));
// enqueue work for event/payload here
res.status(202).send("ok");
});
app.listen(3000);- A global
express.json()before this route replaces the raw bytes, and the hash will never match. - The secret in your environment must match the one saved on the webhook exactly, with no trailing newline from a
.envfile. - With Content type set to
application/x-www-form-urlencoded, the JSON arrives in apayloadform field. Switch toapplication/jsonunless your handler expects forms.
More on HMAC checks in webhook signing and HMAC verification.
Tunnel Checks for Localhost
Start your app, then open a tunnel to its port:
npm install -g hooklistener
hooklistener login
hooklistener tunnel --port 3000
# Forwarding: https://abc123.hook.events → http://localhost:3000Set the Payload URL to the tunnel URL plus your route, for example https://abc123.hook.events/webhook, then click Redeliver on the ping. Compare the GitHub delivery with the tunnel log:
- GitHub failed, tunnel log empty: the Payload URL does not match the current tunnel URL, or the tunnel is not running. Compare it with the URL the CLI printed this run.
- Tunnel shows the request, app logs nothing: the app is not listening on that port. Set
--portto match. If the CLI runs on your machine and the app runs in a Docker container, publish the container port (for exampledocker run -p 3000:3000 …) and tunnel to that host port. Use--host host.docker.internalonly when the CLI itself runs inside a container and your app runs on the host. - Tunnel shows a 4xx/5xx: if your app logged the request, go back to the response and signature sections above. If it logged nothing, the error came from something between the tunnel and your handler, such as a local proxy or a wrong port, so check that first.
- Tunnel keeps disconnecting: VPN changes, Wi-Fi roaming or sleep/wake interrupt the connection. Restart the tunnel and redeliver.
Options are in the tunnel command reference. If you need a support bundle, run hooklistener diagnostics.
Isolate GitHub From Your Server
Still not sure which side is failing? Point the webhook at a Hooklistener Debug Endpoint instead of your server. Every delivery is stored with its method, path, headers and raw body. If the request shows up there, GitHub is sending correctly and the problem is between the URL and your handler.
Then forward that endpoint to your machine while you fix the handler:
hooklistener listen --endpoint ep_abc123 --port 3000 --path-prefix /webhookBecause the request is captured, you can resend it from the dashboard or over MCP as many times as you need, without pushing another commit. An unedited GitHub replay can still pass signature verification if it preserves the raw body and signature header and your secret has not changed. Track X-GitHub-Delivery to avoid duplicate side effects; retry failed work without repeating work that already completed. See replay a failed webhook without re-triggering the provider and the listen command reference.
Frequently Asked Questions
Why is my GitHub webhook not being received?
Open Settings → Webhooks → Recent Deliveries for the webhook. If a newly triggered event is missing, check the selected events, Active checkbox and repository. Deliveries older than three days are no longer listed. If there is a failed delivery, the Response tab tells you whether GitHub could not connect, timed out or got a 4xx/5xx error response from your app or a proxy in front of it.
Can GitHub send webhooks to localhost?
No. GitHub delivers from its own servers, so the Payload URL must be publicly reachable. Run hooklistener tunnel --port 3000 and use the public HTTPS URL it prints, including your route (for example /webhook). Requests to that URL are forwarded to your local server and your server's response is returned to GitHub.
What does a GitHub webhook timeout mean?
GitHub expects a response within 10 seconds. If your handler takes longer, GitHub closes the connection and marks the delivery as failed even if your code finishes later. Verify the signature, durably enqueue slow work, then return a 2xx before processing the job.
Why does X-Hub-Signature-256 verification fail?
The signature is an HMAC-SHA256 of the exact request body bytes. If a JSON body parser runs before your webhook route and you hash the re-serialized object, the hash changes. Read the raw body, use the same secret that is saved in GitHub, and compare against the full header value including the sha256= prefix.
Does GitHub retry failed webhook deliveries?
Not automatically. A failed delivery stays failed until you click Redeliver in Recent Deliveries or resend it with the REST API. If the delivery was captured by Hooklistener, you can also replay it to localhost or staging while you fix the handler; those replays are recorded in Hooklistener, not in GitHub's delivery log, so redeliver from GitHub once to confirm the fix there.
My handler gets the request but the body is empty or not JSON. Why?
Check the webhook's Content type. With application/x-www-form-urlencoded, GitHub sends the JSON as a form field named payload. Set Content type to application/json if your handler parses the body as JSON.
GitHub references: troubleshooting deliveries, validating signatures and redelivering webhooks.
Setting up from scratch? Follow the GitHub webhooks on localhost guide, or see what the GitHub webhook debugger captures for each delivery.