GitHub webhooks
Capture GitHub webhook deliveries and verify X-Hub-Signature-256 before you write the handler.
Get a test URL
Point GitHub at this URL
- Create a test endpoint below and copy its URL.
- In your repository, open Settings → Webhooks → Add webhook.
- Paste the Webhook Studio URL as the Payload URL and set Content type to application/json.
- Set a secret so GitHub signs deliveries, then choose the events to send.
- Save. GitHub immediately sends a ping event — you should see it in the Inbox.
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.
{
"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
| Header | What it carries |
|---|---|
X-Hub-Signature-256 | HMAC-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-Signature | The SHA-1 equivalent, kept for backwards compatibility. Do not verify against this one. |
X-GitHub-Event | The event that triggered the delivery: push, pull_request, issues, ... |
X-GitHub-Delivery | A GUID unique to this delivery. Use it to deduplicate redeliveries. |
X-GitHub-Hook-ID | The identifier of the webhook that fired. |
X-GitHub-Hook-Installation-Target-Type | What the webhook was created on — repository, organization, or app. |
User-Agent | Always prefixed with GitHub-Hookshot/. |
Event types
The events worth handling first. See GitHub’s own documentation for the complete list.
| Event | Fires when |
|---|---|
push | Commits are pushed to a repository, or a branch or tag is created or deleted. |
pull_request | A pull request is opened, closed, reopened, synchronized, or its labels or reviewers change. |
pull_request_review | A review is submitted, edited, or dismissed on a pull request. |
issues | An issue is opened, edited, closed, reopened, assigned, or labelled. |
issue_comment | A comment is created, edited, or deleted on an issue or a pull request. |
release | A release is published, edited, or deleted. |
workflow_run | A GitHub Actions workflow run is requested, in progress, or completed. |
check_run | A check run is created, updated, or requests an action. The CI-integration workhorse. |
installation | A GitHub App is installed or uninstalled. App webhooks only. |
ping | Sent 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.
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
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.
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.
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.
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.
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
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.
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.
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.
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.
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.