GitHub webhooks: testing & debugging guide
To test GitHub webhooks, set the payload URL to an endpoint you can watch (a free webhooks.sh bin, no signup), fire the ping event, then verify X-Hub-Signature-256: sha256= plus a hex HMAC-SHA256 of the raw body, keyed by your webhook secret.
GitHub webhooks drive everything from CI triggers to deploy bots, and they fail quietly: a typo'd secret or a slow handler just means events never turn into actions. This guide covers the full loop — creating the webhook, watching what actually arrives, verifying the signature byte-for-byte, and debugging with the delivery log.
Get a URL you can watch
Before pointing GitHub at your app, point it at something that shows you the raw request:
- An inspection bin. Create a free bin at view.webhooks.page (no signup) and copy the ingest URL. Every delivery lands there live — headers, body, timing — exactly as GitHub sent it.
- The GitHub CLI.
gh webhook forward --repo owner/name --events push,issues --url http://localhost:3000/webhooks/githubforwards repo or org events straight to your local handler. Great for driving your code; the bin is still ground truth for what's on the wire.
Use both: the bin shows you the unmodified request, the CLI drives your handler. When the two disagree, trust the wire.
Create the webhook
On a repository: Settings → Webhooks → Add webhook. For an organization, the same path under the org's settings. You set four things that matter:
- Payload URL — your public HTTPS endpoint (paste the bin URL to start).
- Content type —
application/jsonputs the JSON payload in the body;application/x-www-form-urlencodedstuffs the JSON into apayloadform field instead. Pick JSON unless a legacy integration demands otherwise. - Secret — any random string. GitHub uses it to sign every delivery. Save it; you verify signatures with it.
- Events — just the push event, everything, or a hand-picked list. Subscribe narrowly; noisy webhooks hide the events you care about.
The moment you save, GitHub sends a ping event to your URL. If that first delivery shows up green in your bin, the plumbing works — everything after that is your handler's problem.
What GitHub sends
Every delivery is a POST with three headers you need to know:
| Header | Meaning |
|---|---|
X-GitHub-Event | The event name: push, issues, pull_request, ping, … |
X-GitHub-Delivery | A GUID identifying this delivery. Redeliveries reuse it — your idempotency key. |
X-Hub-Signature-256 | sha256= + hex HMAC-SHA256 of the raw body, keyed by your webhook secret. |
A push event looks like this on the wire (headers and body truncated):
POST /ingest/your-bin-id HTTP/1.1
Host: view.webhooks.page
Content-Type: application/json
User-Agent: GitHub-Hookshot/a1b2c3d
X-GitHub-Event: push
X-GitHub-Delivery: 1c7ec200-3f4a-11ef-9c2e-0a8f2f4b1234
X-GitHub-Hook-ID: 512345678
X-Hub-Signature-256: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
{"ref":"refs/heads/main","before":"6113728…","after":"827efc6…",
"repository":{"full_name":"owner/name"},"pusher":{"name":"monalisa"},
"commits":[{"id":"827efc6…","message":"fix the thing"}]}
If you chose the form content type, the same JSON arrives URL-encoded inside a single payload field — parse the form first, then the JSON inside it.
Verify the signature
The signature is an HMAC-SHA256 of the raw, unparsed request body, keyed with your webhook secret, hex-encoded, prefixed with sha256=. Compare with a constant-time function:
import crypto from "node:crypto";
import express from "express";
const app = express();
// Raw body, not express.json() — parsing destroys the bytes you must verify.
app.post("/webhooks/github", express.raw({ type: "*/*" }), (req, res) => {
const header = req.get("X-Hub-Signature-256") ?? "";
const [algo, theirHex] = header.split("=");
const expected = crypto
.createHmac("sha256", process.env.GITHUB_WEBHOOK_SECRET!)
.update(req.body)
.digest();
const theirs = Buffer.from(theirHex ?? "", "hex");
const valid =
algo === "sha256" &&
theirs.length === expected.length &&
crypto.timingSafeEqual(theirs, expected);
if (!valid) return res.sendStatus(401);
const event = req.get("X-GitHub-Event");
const deliveryId = req.get("X-GitHub-Delivery");
// …dedupe on deliveryId, then handle the event…
res.sendStatus(200);
});
If you set no secret on the webhook, GitHub sends no signature header at all — so absence of X-Hub-Signature-256 means misconfiguration, not a forgery. To check your mental model before wiring code, paste a captured header and body into our HMAC signature verifier — it runs entirely in the browser.
Deliveries, redelivery, and timeouts
The webhook's settings page has a Recent Deliveries tab: every delivery from the past 3 days with the full request, your server's response, and a Redeliver button. This is your primary debugging tool — a delivery that never appears there means GitHub never attempted it (wrong repo, unsubscribed event, or still queued; give it a minute).
Three behaviors to design around:
- 10-second timeout. If your server takes longer than ~10 seconds to respond, GitHub records the delivery as failed. Acknowledge fast, do the slow work asynchronously.
- No automatic retries. GitHub never re-sends a failed delivery on its own. Recovery is manual (the Redeliver button) or scripted via the REST API's delivery endpoints.
- Redeliveries reuse the GUID. Clicking Redeliver re-sends the same payload with the same
X-GitHub-Delivery. Handy for testing — but your handler must treat repeat GUIDs as duplicates, or every test run double-applies side effects.
Common pitfalls
- Verifying the parsed body.
JSON.parsethenJSON.stringifychanges key order and whitespace, and the HMAC never matches. Always hash the raw bytes — the exact same trap as Stripe's signature scheme. - Middleware eating the body. A global
express.json()mounted before your route consumes the stream. Mountexpress.raw()on the webhook route first. - Secret mismatch. Repo/org webhook secrets, GitHub App webhook secrets, and per-environment copies all differ. If verification fails on every request while the header format looks right, suspect the secret before the code.
- Slow handlers. Anything near the 10-second window flakes under load. Return 200 first, process later.
- Redelivery storms. Mashing Redeliver while debugging fires a burst of identical payloads at your handler. Idempotency on
X-GitHub-Deliveryabsorbs it; an idempotent handler is a debuggable handler.
The fast feedback loop
- Create a bin at view.webhooks.page and paste it as the payload URL.
- Watch the
pingarrive, then trigger real events — push a commit, open an issue. - Copy a real signature + body into the verifier and confirm your verification logic offline.
- Swap in
gh webhook forwardto drive your local handler, and keep the Recent Deliveries tab open for redeliveries while you iterate.
GitHub's side of the contract is simple and static: one signature scheme, a delivery log, a redeliver button. Everything else is your handler — so test the handler.
Last updated
Try it in 10 seconds.
Create a free bin at view.webhooks.page — no signup, no install. Point your sender at the URL and watch requests land live. Bins expire after 48 hours.