The loop
Create an endpoint and it prints a permanent ingest URL. Everything sent there is captured whole — headers, body, the exact bytes — before anything else happens. Stream those events to a port on your machine, and replay any one of them as often as you like while you fix the handler. Forwarding runs from your own machine, so it isn't a billable delivery.
What's different about GitHub
GitHub does not put the event name in the body. It arrives in the X-GitHub-Event header; the body carries at most an action property for the sub-activity, and push has no action at all. A handler that expects the event type in the body will not find it — action, where it exists, names the sub-activity, not the event. The second surprise lands right after: GitHub never retries. A failed delivery is recorded as failed and left there. Getting it back means clicking Redeliver, and only within the past 3 days.
Where the URL goes
Repository webhooks live under the repo's Settings, then Webhooks in the left sidebar, then Add webhook. The ingest URL goes into the Payload URL field. The Content type menu offers application/json and application/x-www-form-urlencoded — take JSON unless something downstream wants otherwise. A Secret is optional and worth setting. Finally, pick which events should trigger the hook.
There is no generic send-test button in the webhooks UI. What you get for free is the ping GitHub fires automatically the moment the webhook is created, confirming you set it up correctly. After that, testing means performing the actual action — open an issue, push a commit — or opening Recent deliveries, clicking a delivery GUID, and choosing Redeliver. The REST API mirrors all of it: POST /repos/{owner}/{repo}/hooks/{hook_id}/pings, and POST /repos/{owner}/{repo}/hooks/{hook_id}/tests, which re-sends the latest push to push-subscribed hooks only.
What GitHub sends
Names are lowercase snake_case resource nouns with the verb stripped off — the thing, not what happened to it. The specific activity usually rides in the payload's action property (opened, closed, published), while the name itself arrives in the X-GitHub-Event header. A few events, push among them, carry no action at all.
- push
- pull_request
- issues
- issue_comment
- release
- workflow_run
Worth knowing first
GitHub does not retry
A failed delivery stays failed. No backoff schedule, no second attempt — the failure is recorded and that is the end of it. Your only recourse is Redeliver — from the Recent deliveries tab or the delivery attempts REST route — and only for deliveries from the past 3 days. Outside that window the delivery can no longer be replayed at all.
Ten seconds, then the connection drops
GitHub wants a 2XX within 10 seconds. Take longer and it terminates the connection and counts the delivery as a failure. Return any 4xx or 5xx and it records a failure too. The documented advice is to acknowledge first and process the payload asynchronously.
Redelivery reuses the delivery GUID
A redelivered event carries the same X-GitHub-Delivery value as the original. Handy for tracing, awkward for deduplication: that header on its own cannot tell you whether you are looking at a resend of something already handled or a genuine first arrival. Build idempotency around what the payload means, not the GUID.
Payloads are capped at 25 MB
If an event generates a larger payload, GitHub will not deliver a payload for that webhook event.
Signature checking is handled for you once the secret is registered — you can also check a captured signature by hand in the signature verifier.