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/github forwards 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/json puts the JSON payload in the body; application/x-www-form-urlencoded stuffs the JSON into a payload form 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:

HeaderMeaning
X-GitHub-EventThe event name: push, issues, pull_request, ping, …
X-GitHub-DeliveryA GUID identifying this delivery. Redeliveries reuse it — your idempotency key.
X-Hub-Signature-256sha256= + 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.parse then JSON.stringify changes 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. Mount express.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-Delivery absorbs it; an idempotent handler is a debuggable handler.

The fast feedback loop

  1. Create a bin at view.webhooks.page and paste it as the payload URL.
  2. Watch the ping arrive, then trigger real events — push a commit, open an issue.
  3. Copy a real signature + body into the verifier and confirm your verification logic offline.
  4. Swap in gh webhook forward to 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.