Adding a test URL to a repository
Create a URL above, then go to Settings → Webhooks → Add webhook on any repository you own. Paste it into Payload URL, leave the content type as application/json, type any string into Secret, and choose the events. GitHub sends a ping immediately, so you will see a request appear before you do anything else.
For an organisation-wide hook the path is Organisation settings → Webhooks; for a GitHub App it is Developer settings → GitHub Apps → your app → Webhook URL. The payloads differ in detail but the mechanics on this page are identical.
The ping event
The first delivery is not the event you subscribed to:
{"zen":"Non-blocking is better than blocking.","hook_id":123456789,"hook":{...}}
X-GitHub-Event: ping. It is GitHub’s way of proving the URL resolves. Handle it by returning 200 and nothing else — but do handle it, because a handler that reads payload.repository.full_name unconditionally will 500 on the very first request and the webhook will look broken before it has delivered anything real.
The body shape tells you almost nothing about which event you have. push has ref and commits; pull_request has action and pull_request; issue_comment has action, issue, and comment. The reliable discriminator is the header:
const event = request.headers.get('x-github-event');
const body = await request.json();
if (event === 'push' && body.ref === 'refs/heads/main') { ... }
if (event === 'pull_request' && body.action === 'synchronize') { ... }
Look at the two samples above with that in mind. The pull_request sample carries action: "opened", and the same event name will arrive again as synchronize on every subsequent push to that branch — which is why CI integrations that only listen for opened never re-run.
Branch deletions look like pushes
A deleted branch arrives as push with after set to forty zeros and an empty commits array. There is a deleted: true flag as well, but the zeros are the part that breaks naive code: anything that does git checkout ${after} will try to check out a commit that does not exist.
Verifying X-Hub-Signature-256
The signed payload is the raw body and nothing else — no timestamp, no delimiter:
X-Hub-Signature-256: sha256=8f3b2c1d...
Strip the sha256= prefix, compute an HMAC-SHA256 of the body bytes with your secret, hex-encode, and compare in constant time. In the Signature tab above, choose GitHub, paste the secret from the webhook form, and it will do the same thing over the bytes that actually arrived.
Because there is no timestamp in the signature, GitHub webhooks are replayable in principle: a captured delivery stays valid forever. If that matters for your endpoint, deduplicate on X-GitHub-Delivery and reject ids you have seen.
When deliveries fail
GitHub does not retry, which makes a failed delivery a lost event rather than a delayed one. The Recent Deliveries tab shows the status your server returned; if it shows a timeout, your handler took more than ten seconds.
The pattern that survives this: verify the signature, write the raw event to a queue or a table, return 200, and process asynchronously. A push to a monorepo can carry hundreds of commits, and the difference between parsing that inline and parsing it in a worker is the difference between an integration that works and one that silently drops every large push.