Webhook Studio
/GitHub webhooks
Webhooks / GitHub

GitHub webhooks

Capture GitHub webhook deliveries and verify X-Hub-Signature-256 before you write the handler.

Get a test URL

Get a live GitHub webhook URL
No signup, no install. The URL works the moment you click, and every request GitHub sends is stored with its full headers and raw body.
Free, anonymous, and expires after 3 days.

Point GitHub at this URL

  1. Create a test endpoint below and copy its URL.
  2. In your repository, open Settings → Webhooks → Add webhook.
  3. Paste the Webhook Studio URL as the Payload URL and set Content type to application/json.
  4. Set a secret so GitHub signs deliveries, then choose the events to send.
  5. Save. GitHub immediately sends a ping event — you should see it in the Inbox.
Nothing to install. Webhook Studio sits between GitHub and your app. Every delivery is stored and inspectable, and routing rules forward the ones you care about to your real backend — with retries, and without losing the request history when your backend is down.

What a GitHub delivery looks like

A representative body, using GitHub’s field names. Your own deliveries appear in the Inbox exactly as sent, headers and raw bytes included.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
{
  "ref": "refs/heads/main",
  "before": "9f2c1d4e8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d",
  "after": "3a7b81c2f5e6d7c8b9a0f1e2d3c4b5a6978869a0",
  "created": false,
  "deleted": false,
  "forced": false,
  "repository": {
    "id": 812345678,
    "node_id": "R_kgDOMHq1Ng",
    "name": "checkout-service",
    "full_name": "acme/checkout-service",
    "private": true,
    "default_branch": "main"
  },
  "pusher": {
    "name": "octocat",
    "email": "octocat@users.noreply.github.com"
  },
  "sender": {
    "login": "octocat",
    "id": 583231,
    "type": "User"
  },
  "commits": [
    {
      "id": "3a7b81c2f5e6d7c8b9a0f1e2d3c4b5a6978869a0",
      "message": "Verify the signature before parsing the body",
      "timestamp": "2026-07-21T09:14:02+08:00",
      "author": {
        "name": "Octocat",
        "username": "octocat"
      },
      "added": [],
      "removed": [],
      "modified": [
        "server/webhooks.js"
      ]
    }
  ],
  "head_commit": {
    "id": "3a7b81c2f5e6d7c8b9a0f1e2d3c4b5a6978869a0",
    "message": "Verify the signature before parsing the body",
    "timestamp": "2026-07-21T09:14:02+08:00"
  }
}

Delivery headers

HeaderWhat it carries
X-Hub-Signature-256HMAC-SHA256 hex digest of the raw body, keyed on the webhook secret, always prefixed with "sha256=". Present only when the webhook has a secret configured.
X-Hub-SignatureThe SHA-1 equivalent, kept for backwards compatibility. Do not verify against this one.
X-GitHub-EventThe event that triggered the delivery: push, pull_request, issues, ...
X-GitHub-DeliveryA GUID unique to this delivery. Use it to deduplicate redeliveries.
X-GitHub-Hook-IDThe identifier of the webhook that fired.
X-GitHub-Hook-Installation-Target-TypeWhat the webhook was created on — repository, organization, or app.
User-AgentAlways prefixed with GitHub-Hookshot/.

Event types

The events worth handling first. See GitHub’s own documentation for the complete list.

EventFires when
pushCommits are pushed to a repository, or a branch or tag is created or deleted.
pull_requestA pull request is opened, closed, reopened, synchronized, or its labels or reviewers change.
pull_request_reviewA review is submitted, edited, or dismissed on a pull request.
issuesAn issue is opened, edited, closed, reopened, assigned, or labelled.
issue_commentA comment is created, edited, or deleted on an issue or a pull request.
releaseA release is published, edited, or deleted.
workflow_runA GitHub Actions workflow run is requested, in progress, or completed.
check_runA check run is created, updated, or requests an action. The CI-integration workhorse.
installationA GitHub App is installed or uninstalled. App webhooks only.
pingSent once when the webhook is first created, to confirm the URL is reachable.

Verify the X-Hub-Signature-256 header

GitHub signs each delivery with HMAC-SHA256 over the raw request body, keyed on your webhook secret. Verify before you parse, and before you act.

Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';

const app = express();

// Raw body again: the digest is over the exact bytes GitHub sent.
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-hub-signature-256'];
  const expected =
    'sha256=' +
    crypto
      .createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET)
      .update(req.body)
      .digest('hex');

  // timingSafeEqual, never ===. A plain comparison returns early on the first
  // wrong byte, which leaks the correct signature one byte at a time. It also
  // throws on a length mismatch, so check length first.
  const valid =
    typeof signature === 'string' &&
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

  if (!valid) return res.status(401).send('Invalid signature');

  res.status(202).send('accepted');

  const event = req.headers['x-github-event'];
  const delivery = req.headers['x-github-delivery']; // dedupe key
  const payload = JSON.parse(req.body.toString('utf8'));

  if (event === 'push') {
    // handle(payload) — idempotently, keyed on `delivery`
  }
});

Retries and delivery guarantees

GitHub does not retry failed deliveries automatically the way payment providers do. Failures sit in the repository's Recent Deliveries tab, where each one can be redelivered by hand or through the REST API. That makes the delivery log your safety net: if your handler was down for an hour, nothing re-arrives on its own. Payloads are capped at 25 MB and larger ones are not delivered at all.

Common pitfalls

Verifying against X-Hub-Signature instead of X-Hub-Signature-256

Both headers arrive on every signed delivery. The unsuffixed one is SHA-1 and exists only for backwards compatibility. Reach for the -256 header; a handler that picks the first matching header name often gets the wrong one.

Comparing signatures with ===

GitHub's own docs call this out: a plain equality check returns as soon as it hits a differing byte, which lets an attacker recover a valid signature through timing. Use crypto.timingSafeEqual, and compare lengths first because it throws on mismatched buffers.

Choosing the form-encoded content type

The Add webhook screen defaults to application/x-www-form-urlencoded, which wraps the JSON in a payload= parameter. Everything downstream then has to unwrap it, and the signature is over the encoded form. Set application/json unless you have a specific reason not to.

Assuming a failed delivery will come back

There is no automatic retry. If your endpoint 500s, the event is simply lost until someone clicks Redeliver. Capture deliveries somewhere durable before your handler can drop them.

Treating X-GitHub-Event as the whole story

One event name covers many actions — pull_request fires for opened, closed, synchronize, labeled, and more. Branch on the payload's "action" field as well, or a "PR opened" integration will also run on every label change.

FAQ

How do I test a GitHub webhook without a public server?

Create a test endpoint on this page and paste its URL into Settings → Webhooks as the Payload URL. GitHub sends a ping immediately, and every subsequent delivery is stored here with full headers and raw body — no tunnel, no deploy, no account.

Why is my X-Hub-Signature-256 check failing?

The two usual causes are a parsed body and the wrong content type. The digest covers the exact bytes GitHub sent, so any JSON middleware ahead of the route breaks it; and if the webhook is set to form-encoded, the signed bytes are the URL-encoded form, not the JSON you expect.

Does GitHub retry failed webhook deliveries?

No. Failed deliveries are listed under Recent Deliveries in the webhook settings and can be redelivered manually or via the REST API, but nothing is retried automatically. This is the main practical difference from Stripe.

What is the difference between X-Hub-Signature and X-Hub-Signature-256?

X-Hub-Signature is HMAC-SHA1 and is sent only for backwards compatibility. X-Hub-Signature-256 is HMAC-SHA256 and is the one to verify. Both are hex digests of the raw body keyed on your webhook secret.

Can I replay a GitHub webhook into my local machine?

Yes. Add a routing rule on the endpoint that forwards matching deliveries to your local tunnel, or replay any stored delivery on demand from the Replay page — useful precisely because GitHub will not retry for you.