A webhook POSTs a JSON payload to your URL when a chosen event happens, instead of making you poll. Each delivery carries X-GitHub-Event (the event name), X-GitHub-Delivery (a unique ID), and, when the hook has a secret, X-Hub-Signature-256: an HMAC-SHA256 of the raw body keyed with that secret. Your server must recompute it and reject anything that does not match, because the URL alone is no secret. BookNest's receiver, added in pull request #31, does exactly that:
// Receives GitHub webhook deliveries and accepts only those signed with WEBHOOK_SECRET.
const http = require('node:http');
const crypto = require('node:crypto');
const secret = process.env.WEBHOOK_SECRET;
const port = Number(process.env.PORT || 31080);
function signedBy(body, header = '') {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(header);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
http.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const body = Buffer.concat(chunks);
const event = req.headers['x-github-event'];
if (!signedBy(body, req.headers['x-hub-signature-256'])) {
console.log(`${event}: rejected, bad signature`);
return res.writeHead(401).end();
}
const p = JSON.parse(body);
const what = p.issue ? `#${p.issue.number} "${p.issue.title}"` : p.zen;
console.log(`${event}${p.action ? ' ' + p.action : ''}: ${what} (signature ok)`);
res.writeHead(204).end();
});
}).listen(port, () => console.log(`listening on http://localhost:${port}`));timingSafeEqual compares in constant time, so the signature cannot be guessed byte by byte. GitHub 29 cannot reach localhost, so smee.io (https://smee.io) relays: its client (smee-client, ISC) streams deliveries from a public channel URL and re-posts them locally. Anyone with the URL can read them, so it is for development only. The hook subscribes to issues; an issue was opened and closed, and a forged request sent to the receiver:
export WEBHOOK_SECRET=$(openssl rand -hex 20)
SMEE=$(curl -Ls -o /dev/null -w '%{url_effective}' https://smee.io/new)
node scripts/webhook-receiver.js > receiver.log &
npx -y smee-client@5.0.0 --url "$SMEE" --target http://127.0.0.1:31080/ > smee.log & sleep 5
HOOK=$(gh api repos/{owner}/{repo}/hooks -f name=web -f "config[url]=$SMEE" \
-f 'config[content_type]=json' -f "config[secret]=$WEBHOOK_SECRET" \
-f 'events[]=issues' --jq .id)
N=$(gh issue create --title "Try the webhook relay" --body "Section 3.16.5" \
| grep -o '[0-9]*$')
gh issue close $N --comment "Delivered."; sleep 10
curl -s -o /dev/null -w '%{http_code}\n' -H 'X-GitHub-Event: issues' \
-H 'X-Hub-Signature-256: sha256=00' -d '{"action":"opened"}' http://127.0.0.1:31080/
cat receiver.log
gh api repos/{owner}/{repo}/hooks/$HOOK/deliveries --jq '.[] | "\(.event) \(.status_code)"'
gh api -X DELETE repos/{owner}/{repo}/hooks/$HOOK
gh api repos/{owner}/{repo}/hooks --jq length
kill $(jobs -p)✓ Closed issue binarybehemoth/booknest#32 (Try the webhook relay) 401 listening on http://localhost:31080 ping: Responsive is better than fast. (signature ok) issues opened: #32 "Try the webhook relay" (signature ok) issues closed: #32 "Try the webhook relay" (signature ok) issues: rejected, bad signature issues 200 issues 200 ping 200 0
Creating the hook sent a ping; all three real deliveries verified and the forged one got 401. The deliveries API (and Settings > Webhooks) shows each request and answer and can redeliver after downtime. Answer within 10 seconds, queue slow work, and deduplicate on X-GitHub-Delivery; in production, use HTTPS, not a relay.