Skip to main content

GitHub webhook example

This GitHub webhook example runs end to end: a repository webhook that sends push and pull_request events to https://example.com/github-webhook, the headers and trimmed JSON payloads GitHub delivers, and a Node.js receiver that verifies X-Hub-Signature-256, routes on X-GitHub-Event and action, and answers inside GitHub’s 10-second limit.

How GitHub’s webhooks compare with other providers, including the missing automatic retries, is covered in our GitHub webhooks review. Hardening beyond the signature check, such as IP allowlists, is in GitHub webhook security.

Receiving webhooks?
Svix Ingest is a reliable webhook receiver. It verifies signatures, absorbs delivery spikes, and gives you one endpoint for webhooks from any provider. Start receiving webhooks with Svix Ingest.

Step 1: Create the webhook​

In the repository, open Settings > Webhooks > Add webhook and fill in four fields:

  1. Payload URL: https://example.com/github-webhook. GitHub cannot reach localhost, so during development use a tunnel such as ngrok http 3000, or a Svix Play URL if you only want to look at deliveries.
  2. Content type: application/json. The other option, application/x-www-form-urlencoded, sends the JSON inside a form field called payload, and a receiver that parses the body as JSON fails on it.
  3. Secret: a long random string, such as the output of openssl rand -hex 32. GitHub has you choose the secret instead of generating one, so it is only as strong as what you paste.
  4. Which events: choose Let me select individual events and tick Pushes and Pull requests. “Send me everything” works too, but every event you do not handle is still a request your endpoint has to answer.

Click Add webhook. GitHub immediately sends a ping event, and its result shows up under Recent Deliveries a few seconds later.

The headers GitHub sends​

These are the headers on a push delivery, with the signatures shortened:

POST /github-webhook HTTP/1.1
User-Agent: GitHub-Hookshot/7e3f1a2
Content-Type: application/json
X-GitHub-Event: push
X-GitHub-Delivery: 9f3c2e10-7b1a-11ef-8d4e-2c1f7a0b5d61
X-GitHub-Hook-ID: 512345678
X-GitHub-Hook-Installation-Target-Type: repository
X-GitHub-Hook-Installation-Target-ID: 734512908
X-Hub-Signature: sha1=4c1b7f...
X-Hub-Signature-256: sha256=9a3e5d...

X-GitHub-Event names the event, and the action field in the body narrows it further. X-GitHub-Delivery is a GUID for the delivery, and it stays the same when you redeliver, which makes it the right deduplication key. X-GitHub-Hook-ID identifies the webhook configuration that fired, useful when one receiver serves several repositories. X-Hub-Signature-256 is an HMAC-SHA256 of the raw body keyed with your secret, written as sha256= plus a hex digest; X-Hub-Signature is the SHA-1 version, kept for legacy receivers, and should be ignored. Both signature headers appear only when the webhook has a secret, so a delivery without one came from a misconfigured hook or from someone other than GitHub. The GitHub-Hookshot/ user agent prefix is constant, but anyone can send it, so it proves nothing.

Example payloads​

The ping event confirms the webhook works. Your receiver should answer it with a 2xx, or the first entry in Recent Deliveries is a failure:

{
"zen": "Design for failure.",
"hook_id": 512345678,
"hook": {
"type": "Repository",
"id": 512345678,
"active": true,
"events": ["pull_request", "push"],
"config": { "content_type": "json", "url": "https://example.com/github-webhook", "insecure_ssl": "0" }
},
"repository": { "full_name": "acme/api" },
"sender": { "login": "octocat" }
}

A push to the default branch, trimmed to the fields most handlers read:

{
"ref": "refs/heads/main",
"before": "6113728f27ae82c7b1a177c8d03f9e96e0adf246",
"after": "a10867b14bb761a232cd80139fbd4c0d33264240",
"created": false,
"deleted": false,
"forced": false,
"compare": "https://github.com/acme/api/compare/6113728f27ae...a10867b14bb7",
"commits": [
{
"id": "a10867b14bb761a232cd80139fbd4c0d33264240",
"message": "Fix retry jitter",
"timestamp": "2026-10-20T14:02:11Z",
"author": { "name": "Mona Lisa", "email": "mona@example.com", "username": "octocat" },
"added": [],
"removed": [],
"modified": ["src/retry.js"]
}
],
"head_commit": { "id": "a10867b14bb761a232cd80139fbd4c0d33264240", "message": "Fix retry jitter" },
"pusher": { "name": "octocat", "email": "mona@example.com" },
"repository": { "full_name": "acme/api", "default_branch": "main" },
"sender": { "login": "octocat" }
}

Three fields trip people up. ref is the full ref, refs/heads/main for a branch and refs/tags/v3.14.1 for a tag, so compare it against refs/heads/ plus repository.default_branch rather than against main. commits holds at most 2048 entries, and anything beyond that has to come from the Commits API. And head_commit can be null: a push that deletes a branch arrives with deleted: true and no head commit, so code that reads head_commit.id without a guard crashes on the first branch deletion.

A merged pull request:

{
"action": "closed",
"number": 42,
"pull_request": {
"number": 42,
"title": "Add webhook retries",
"state": "closed",
"merged": true,
"merged_at": "2026-10-20T15:30:00Z",
"merge_commit_sha": "e5bd3914e2e596debea16f433f57875b5b90bcd6",
"head": { "ref": "retries", "sha": "a10867b14bb761a232cd80139fbd4c0d33264240" },
"base": { "ref": "main" },
"user": { "login": "octocat" },
"html_url": "https://github.com/acme/api/pull/42"
},
"repository": { "full_name": "acme/api" },
"sender": { "login": "octocat" }
}

There is no merged action. A merge arrives as closed with merged: true, and a pull request closed without merging is closed with merged: false. The other actions most handlers care about are opened, reopened, and synchronize, which fires when new commits land on the head branch. GitHub defines more than 20 actions in total, so ignore the ones you do not handle rather than erroring on them.

Step 2: Write the receiver​

const express = require('express');
const crypto = require('crypto');

const app = express();
const secret = process.env.GITHUB_WEBHOOK_SECRET;
const seen = new Set();

function verify(req) {
const header = req.get('X-Hub-Signature-256') || '';
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(req.body).digest('hex');
const a = Buffer.from(header);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const handlers = {
ping: (p) => console.log(`webhook ${p.hook_id} is live: ${p.zen}`),
push: (p) => {
if (p.deleted) return console.log(`${p.ref} was deleted`);
if (p.ref !== `refs/heads/${p.repository.default_branch}`) return;
console.log(`${p.commits.length} commit(s) on ${p.ref}, head ${p.head_commit.id}`);
},
pull_request: (p) => {
if (p.action === 'closed' && p.pull_request.merged) {
console.log(`PR #${p.number} merged into ${p.pull_request.base.ref}`);
} else if (p.action === 'opened' || p.action === 'synchronize') {
console.log(`PR #${p.number} needs a build at ${p.pull_request.head.sha}`);
}
},
};

app.post('/github-webhook', express.raw({ type: 'application/json', limit: '25mb' }), (req, res) => {
if (!verify(req)) return res.status(401).send('bad signature');

const delivery = req.get('X-GitHub-Delivery');
if (seen.has(delivery)) return res.status(200).send('duplicate');
seen.add(delivery);

const event = req.get('X-GitHub-Event');
const payload = JSON.parse(req.body.toString('utf8'));
res.status(202).send('accepted');

setImmediate(() => {
try {
handlers[event]?.(payload);
} catch (err) {
console.error(`failed to handle ${event} ${delivery}`, err);
}
});
});

app.listen(3000, () => console.log('listening on http://localhost:3000/github-webhook'));

Install Express with npm install express, then run GITHUB_WEBHOOK_SECRET=... node server.js with the same secret you pasted into GitHub.

The route reads the body with express.raw, because the signature covers the exact bytes GitHub sent and re-serializing parsed JSON produces different ones. The 25mb limit matches GitHub’s payload cap; Express defaults to 100 KB, which rejects a large push with a 413 that looks like a GitHub problem. The length check comes before crypto.timingSafeEqual because that function throws on buffers of different lengths. The handler responds 202 before doing any work, since GitHub marks anything slower than 10 seconds as failed. The seen set catches a redelivery of something you already processed, the usual case being a delivery that timed out on GitHub’s side after your server had finished it. In production, replace both the set and setImmediate with a database table and a job queue so neither is lost on restart.

Step 3: Test the signature with known values​

GitHub publishes a known answer for checking an implementation: with the secret It's a Secret to Everybody and the body Hello, World!, the header must be sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17. printf 'Hello, World!' | openssl dgst -sha256 -hmac "It's a Secret to Everybody" reproduces it from a shell. To drive your local receiver without waiting for GitHub, sign a body the same way and send it with curl:

BODY='{"zen":"Keep it logically awesome.","hook_id":1}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$GITHUB_WEBHOOK_SECRET" | sed 's/^.* //')
curl -i localhost:3000/github-webhook \
-H 'Content-Type: application/json' \
-H 'X-GitHub-Event: ping' \
-H "X-GitHub-Delivery: $(uuidgen)" \
-H "X-Hub-Signature-256: sha256=$SIG" \
-d "$BODY"

That returns 202 and logs the zen line. Change one character of the body after computing SIG and it returns 401. To keep these cases as a reusable suite with assertions, the same signing logic runs in a Postman pre-request script, covered in how to test a webhook with Postman.

Step 4: Redeliver what failed​

GitHub does not retry failed deliveries on its own. The webhook’s Recent Deliveries tab lists every delivery from the past three days with its full request and response, and the Redeliver button sends one again with the same X-GitHub-Delivery. After a receiver outage, clicking through that list does not scale, so script it with the REST API:

HOOK=repos/acme/api/hooks/512345678
gh api "$HOOK/deliveries" --paginate --jq '.[] | select(.status != "OK") | .id' |
while read -r id; do gh api -X POST "$HOOK/deliveries/$id/attempts"; done

A failed original stays in the list after a successful redelivery, so running the script twice sends it again, which is one more reason the receiver deduplicates on X-GitHub-Delivery. Anything older than three days cannot be redelivered, so recovery past that point means reading current state back through the API. See what happens when a webhook fails for how other providers handle the same situation. If you would rather have retries without running the script, Svix Ingest gives GitHub a URL that verifies and stores every delivery, then forwards it to your service with automatic retries.