GitHub webhooks
A webhook endpoint is three things that get confused with each other: a verifier,
a router, and a set of handlers. Most broken integrations are broken because those
three were written as one function — the verifier trusts a parsed body, the router
is an if chain nobody can enumerate, and a handler's slow work happens inside the
ten seconds GitHub allows before it calls the delivery failed.
This skill separates them, and carries a reference for what each event type
actually contains so a handler is written against real fields rather than
remembered ones.
It composes with two companions in the same pack: release-ledger orchestrates
a since-you-were-away ledger and consumes captured merge events, and
describe-changes turns one of those changes into prose. Install either with
npx skills add crissmoldovan/agent-skills.
When to Use
- An application needs to react to activity on a GitHub repository or
organization, and no endpoint exists yet.
- An endpoint exists and you are adding an event type, or auditing whether its
verification is real.
- Deliveries are arriving and being lost, timing out, or processed twice.
- You need to know which event carries a field before designing a feature around
it — that is what the event reference is for.
Do not use it for outbound calls to the GitHub API; that is the REST or GraphQL
API and needs a credential this endpoint deliberately does not hold. Do not use it
for GitHub Actions workflow triggers — on: pull_request in a workflow file is a
different mechanism with a different payload shape and no endpoint of yours.
Do not use it to poll: if you find yourself scheduling a fetch of recent merges,
you are building a poller, and a poller has different idempotency and rate-limit
concerns than anything here.
Prerequisites
- A publicly reachable HTTPS URL that GitHub can POST to, or a tunnel to a
local port for development.
Complete when: an unauthenticated POST from outside your network reaches
your handler.
- A shared verification value, stored as a secret. Generate a long random
value; place it with your platform's secret mechanism, never in the repository.
Complete when: the running process reads it from the environment and the
value has never appeared in a file, a log line, or a chat message.
- Access to the raw request body. HMAC is computed over the exact bytes
GitHub sent. A framework that hands you a parsed object has already destroyed
the thing you need to verify.
Complete when: you can log the byte length of the body and it matches the
Content-Length header.
- Somewhere to put work that is not the request. A queue, a job runner, or at
minimum a durable table the response does not wait on.
Complete when: you can enqueue and return in the same request, and see the
work complete afterwards.
- Admin rights on the repository, organization, or App whose settings hold
the hook.
Complete when: you can open the webhook settings page and see the Recent
Deliveries tab.
Procedure
- Audit what already exists. Search for a route matching
webhook, hooks,
or github and read it end to end before adding anything. Answer four
questions: does it verify, does it verify against the raw body, which events
does it already subscribe to, and does it filter events before doing work? The
fourth matters most when you are adding a consumer: a handler that returns
early for events it does not care about will discard yours too if you nest
inside it.
- Design the event map before writing code. A table of event, actions you
care about, the predicate that selects them, and the handler. Keep it in the
repository — it is the document that answers "why are we subscribed to that?"
in a year. The event reference is the raw material;
the payload cookbook has the predicates for
the common cases already written.
- Implement the endpoint in four layers, in this order.
- Read the raw body as bytes.
- Verify
X-Hub-Signature-256 with HMAC-SHA256 and a constant-time compare.
Reject with 401 on mismatch, and reject when the header is absent — an
endpoint that skips verification when the header is missing is unverified.
- Route on
X-GitHub-Event and, where the event has one, payload.action.
- Hand off to a handler that enqueues and returns. Respond 2xx within a
second or two; GitHub's delivery timeout is ten seconds and a timed-out
delivery is retried, which is how one merge becomes three ledger rows.
- Handle the
ping event explicitly. GitHub sends it when the hook is
created. Returning 200 with no special case is fine, but returning 400 for an
unknown event means the hook shows a red delivery from the moment it exists.
- Register the hook. Create it against the repository or organization, select
only the events in the map, set the content type to
application/json, and set
the verification value. Selecting "send me everything" is how an endpoint ends
up receiving thousands of deliveries it discards.
- Verify with a real delivery. Cause the event, then open Recent Deliveries
and read the request and response. A 200 you produced by hand-posting a
fixture proves your parser works and proves nothing about the hook.
- Write the event map into the host repository's docs with the endpoint URL
path, the events subscribed, and how to rotate the verification value.
Managing evolution
- Adding an event is three steps in one change: extend the map, add the
handler, then update the hook's subscription list. Doing the third first means
live deliveries hitting a router that does not know them.
- Idempotency comes from a natural key, not from the delivery id. GitHub's
X-GitHub-Delivery is unique per attempt group and a manual redelivery
reuses it — but a second, genuinely separate delivery of the same underlying
fact (a synchronize after a synchronize) has a new one. Deduplicate on what
the fact is: repository plus pull request number, or the head SHA plus check
name. Keep the delivery id in the log line, not in the unique constraint.
- Replay is the debugging tool. Recent Deliveries has a Redeliver button, and
the API exposes the same thing, so a fixed handler can be tested against the
exact bytes that broke it.
- Local development uses forwarding, not a fixture.
gh webhook forward
(from the cli/gh-webhook extension) relays live deliveries to a local port
with real signatures, which exercises the verifier that a saved JSON file never
will.
Usage Examples
Set up GitHub webhook handling in this app. Audit anything that exists first,
then implement the endpoint: raw body, HMAC-SHA256 verification with a
constant-time compare, routing on the event header plus action, and a handler
that enqueues. Only subscribe to pull_request and push for now.
We already receive pull_request events but the handler returns early unless the
title matches our ticket convention. I need every merged PR. Show me how many
deliveries that filter discards, then add a second consumer beside it rather
than inside it.
Add check_run to our event map. Route only completed check runs whose conclusion
is failure or timed_out, on the default branch, and tell me which payload fields
the predicate reads.
Pitfalls
- Verifying a re-serialized body.
JSON.stringify(await req.json()) is not the
bytes GitHub signed. Key order, whitespace, and unicode escaping all differ, and
the signature will never match — or worse, will match in development and fail
in production behind a proxy that reformats.
- Comparing signatures with
===. Use a constant-time comparison. Compare
fixed-length buffers, and check the length before comparing, because most
constant-time helpers throw on a length mismatch rather than returning false.
- Trusting
X-Hub-Signature. The unsuffixed header is the legacy SHA-1
signature. Verify the -256 one; accepting either lets a caller choose the
weaker algorithm.
- Doing the work inline. Anything that calls a model, another API, or a slow
query belongs behind a queue. A ten-second timeout means a slow handler turns
into retries, and retries turn into duplicates.
- Returning a non-2xx for events you do not handle. GitHub retries failures.
An unrouted event should return 200 and be counted, not rejected.
- Assuming every event has an
action. push, status, create, delete,
fork, gollum, and public have none. A router that reads payload.action
unconditionally routes them all to the same undefined branch.
- Assuming a merge.
pull_request with action: "closed" fires for closed
and for merged. payload.pull_request.merged is the field that distinguishes
them, and it is a boolean on the pull request, not on the event.
- Assuming the push commit list is complete. Large pushes truncate the
commits array; the payload is a notification, not an archive. Read the range
with the API if you need every commit.
- A payload that is a delete.
push with after equal to all zeros is a
branch deletion arriving as a push. Handlers that diff before..after produce
nonsense on it.
- Subscribing to everything "to be safe". Every unwanted event is delivery
volume, log noise, and a payload you did not review for the data it carries.
- Placing the endpoint behind the app's authentication. It has no session and
no bearer token; its authentication is the signature. Exempt the route
deliberately and narrowly, and make sure the exemption does not extend to the
routes you add next.
Verification
Deeper reading
- Event types: every event this skill routes, its
actions, the payload fields that carry the meaning, and one line on when you
would subscribe to it.
- Payload cookbook: worked predicates —
merged-PR-to-default-branch, tag push, check regression, review approval,
successful deployment — each written as a condition plus the exact fields it
reads.
1---2name: github-webhooks3description: Adopt and manage GitHub webhook handling in an app: endpoint setup, signature verification, event routing, and a working reference for every event type you route.4license: MIT5---67# GitHub webhooks89A webhook endpoint is three things that get confused with each other: a verifier,10a router, and a set of handlers. Most broken integrations are broken because those11three were written as one function — the verifier trusts a parsed body, the router12is an `if` chain nobody can enumerate, and a handler's slow work happens inside the13ten seconds GitHub allows before it calls the delivery failed.1415This skill separates them, and carries a reference for what each event type16actually contains so a handler is written against real fields rather than17remembered ones.1819It composes with two companions in the same pack: **`release-ledger`** orchestrates20a since-you-were-away ledger and consumes captured merge events, and21**`describe-changes`** turns one of those changes into prose. Install either with22`npx skills add crissmoldovan/agent-skills`.2324## When to Use2526- An application needs to react to activity on a GitHub repository or27 organization, and no endpoint exists yet.28- An endpoint exists and you are adding an event type, or auditing whether its29 verification is real.30- Deliveries are arriving and being lost, timing out, or processed twice.31- You need to know which event carries a field before designing a feature around32 it — that is what the event reference is for.3334Do not use it for outbound calls to the GitHub API; that is the REST or GraphQL35API and needs a credential this endpoint deliberately does not hold. Do not use it36for GitHub Actions workflow triggers — `on: pull_request` in a workflow file is a37different mechanism with a different payload shape and no endpoint of yours.38Do not use it to poll: if you find yourself scheduling a fetch of recent merges,39you are building a poller, and a poller has different idempotency and rate-limit40concerns than anything here.4142## Prerequisites43441. **A publicly reachable HTTPS URL** that GitHub can POST to, or a tunnel to a45 local port for development.46 **Complete when:** an unauthenticated POST from outside your network reaches47 your handler.482. **A shared verification value, stored as a secret.** Generate a long random49 value; place it with your platform's secret mechanism, never in the repository.50 **Complete when:** the running process reads it from the environment and the51 value has never appeared in a file, a log line, or a chat message.523. **Access to the raw request body.** HMAC is computed over the exact bytes53 GitHub sent. A framework that hands you a parsed object has already destroyed54 the thing you need to verify.55 **Complete when:** you can log the byte length of the body and it matches the56 `Content-Length` header.574. **Somewhere to put work that is not the request.** A queue, a job runner, or at58 minimum a durable table the response does not wait on.59 **Complete when:** you can enqueue and return in the same request, and see the60 work complete afterwards.615. **Admin rights on the repository, organization, or App** whose settings hold62 the hook.63 **Complete when:** you can open the webhook settings page and see the Recent64 Deliveries tab.6566## Procedure67681. **Audit what already exists.** Search for a route matching `webhook`, `hooks`,69 or `github` and read it end to end before adding anything. Answer four70 questions: does it verify, does it verify against the raw body, which events71 does it already subscribe to, and does it filter events before doing work? The72 fourth matters most when you are adding a consumer: a handler that returns73 early for events it does not care about will discard yours too if you nest74 inside it.752. **Design the event map before writing code.** A table of event, actions you76 care about, the predicate that selects them, and the handler. Keep it in the77 repository — it is the document that answers "why are we subscribed to that?"78 in a year. [The event reference](references/event-types.md) is the raw material;79 [the payload cookbook](references/payload-cookbook.md) has the predicates for80 the common cases already written.813. **Implement the endpoint in four layers, in this order.**82 - Read the raw body as bytes.83 - Verify `X-Hub-Signature-256` with HMAC-SHA256 and a constant-time compare.84 Reject with 401 on mismatch, and reject when the header is absent — an85 endpoint that skips verification when the header is missing is unverified.86 - Route on `X-GitHub-Event` and, where the event has one, `payload.action`.87 - Hand off to a handler that enqueues and returns. Respond 2xx within a88 second or two; GitHub's delivery timeout is ten seconds and a timed-out89 delivery is retried, which is how one merge becomes three ledger rows.904. **Handle the `ping` event explicitly.** GitHub sends it when the hook is91 created. Returning 200 with no special case is fine, but returning 400 for an92 unknown event means the hook shows a red delivery from the moment it exists.935. **Register the hook.** Create it against the repository or organization, select94 only the events in the map, set the content type to `application/json`, and set95 the verification value. Selecting "send me everything" is how an endpoint ends96 up receiving thousands of deliveries it discards.976. **Verify with a real delivery.** Cause the event, then open Recent Deliveries98 and read the request and response. A 200 you produced by hand-posting a99 fixture proves your parser works and proves nothing about the hook.1007. **Write the event map into the host repository's docs** with the endpoint URL101 path, the events subscribed, and how to rotate the verification value.102103### Managing evolution104105- **Adding an event** is three steps in one change: extend the map, add the106 handler, then update the hook's subscription list. Doing the third first means107 live deliveries hitting a router that does not know them.108- **Idempotency comes from a natural key, not from the delivery id.** GitHub's109 `X-GitHub-Delivery` is unique per *attempt group* and a manual redelivery110 reuses it — but a second, genuinely separate delivery of the same underlying111 fact (a `synchronize` after a `synchronize`) has a new one. Deduplicate on what112 the fact is: repository plus pull request number, or the head SHA plus check113 name. Keep the delivery id in the log line, not in the unique constraint.114- **Replay is the debugging tool.** Recent Deliveries has a Redeliver button, and115 the API exposes the same thing, so a fixed handler can be tested against the116 exact bytes that broke it.117- **Local development uses forwarding, not a fixture.** `gh webhook forward`118 (from the `cli/gh-webhook` extension) relays live deliveries to a local port119 with real signatures, which exercises the verifier that a saved JSON file never120 will.121122## Usage Examples123124```text125Set up GitHub webhook handling in this app. Audit anything that exists first,126then implement the endpoint: raw body, HMAC-SHA256 verification with a127constant-time compare, routing on the event header plus action, and a handler128that enqueues. Only subscribe to pull_request and push for now.129```130131```text132We already receive pull_request events but the handler returns early unless the133title matches our ticket convention. I need every merged PR. Show me how many134deliveries that filter discards, then add a second consumer beside it rather135than inside it.136```137138```text139Add check_run to our event map. Route only completed check runs whose conclusion140is failure or timed_out, on the default branch, and tell me which payload fields141the predicate reads.142```143144## Pitfalls145146- **Verifying a re-serialized body.** `JSON.stringify(await req.json())` is not the147 bytes GitHub signed. Key order, whitespace, and unicode escaping all differ, and148 the signature will never match — or worse, will match in development and fail149 in production behind a proxy that reformats.150- **Comparing signatures with `===`.** Use a constant-time comparison. Compare151 fixed-length buffers, and check the length before comparing, because most152 constant-time helpers throw on a length mismatch rather than returning false.153- **Trusting `X-Hub-Signature`.** The unsuffixed header is the legacy SHA-1154 signature. Verify the `-256` one; accepting either lets a caller choose the155 weaker algorithm.156- **Doing the work inline.** Anything that calls a model, another API, or a slow157 query belongs behind a queue. A ten-second timeout means a slow handler turns158 into retries, and retries turn into duplicates.159- **Returning a non-2xx for events you do not handle.** GitHub retries failures.160 An unrouted event should return 200 and be counted, not rejected.161- **Assuming every event has an `action`.** `push`, `status`, `create`, `delete`,162 `fork`, `gollum`, and `public` have none. A router that reads `payload.action`163 unconditionally routes them all to the same undefined branch.164- **Assuming a merge.** `pull_request` with `action: "closed"` fires for closed165 and for merged. `payload.pull_request.merged` is the field that distinguishes166 them, and it is a boolean on the pull request, not on the event.167- **Assuming the push commit list is complete.** Large pushes truncate the168 `commits` array; the payload is a notification, not an archive. Read the range169 with the API if you need every commit.170- **A payload that is a delete.** `push` with `after` equal to all zeros is a171 branch deletion arriving as a push. Handlers that diff `before..after` produce172 nonsense on it.173- **Subscribing to everything "to be safe".** Every unwanted event is delivery174 volume, log noise, and a payload you did not review for the data it carries.175- **Placing the endpoint behind the app's authentication.** It has no session and176 no bearer token; its authentication *is* the signature. Exempt the route177 deliberately and narrowly, and make sure the exemption does not extend to the178 routes you add next.179180## Verification181182- [ ] The endpoint reads the raw request body and verifies HMAC-SHA256 over those exact bytes.183- [ ] Comparison is constant-time, and a missing signature header is rejected rather than skipped.184- [ ] A deliberately corrupted signature was sent and produced 401.185- [ ] The router handles events without an `action` field.186- [ ] `ping` returns 200.187- [ ] Unrouted events return 200 and are counted.188- [ ] Handlers enqueue; nothing slow runs inside the request.189- [ ] Median response time to GitHub is well under the ten-second delivery timeout.190- [ ] Deduplication uses a natural key, and a redelivery was replayed to prove it.191- [ ] The hook subscribes only to the events in the map.192- [ ] A real delivery was inspected in Recent Deliveries, request and response.193- [ ] The verification value lives in a secret store and appears in no file, log, or message.194- [ ] The event map is documented in the host repository, with the rotation procedure.195196## Deeper reading197198- [Event types](references/event-types.md): every event this skill routes, its199 actions, the payload fields that carry the meaning, and one line on when you200 would subscribe to it.201- [Payload cookbook](references/payload-cookbook.md): worked predicates —202 merged-PR-to-default-branch, tag push, check regression, review approval,203 successful deployment — each written as a condition plus the exact fields it204 reads.