A webhook lets GitHub push an event to your system instead of requiring your system to repeatedly ask whether something changed.
That inversion is the point. Polling costs rate limit, adds latency proportional to the polling interval, and scales badly. A webhook arrives within seconds of the event, costs nothing against your limit, and scales with actual activity rather than with the number of things you are watching.
The mechanism
Section titled “The mechanism”You register a URL. When a subscribed event occurs, GitHub sends an HTTP POST to it with a JSON
body describing what happened.
Three places they can be configured:
| Level | Scope | Best for |
|---|---|---|
| Repository | One repository | A single project’s integration |
| Organisation | Every repository in the organisation | Org-wide auditing or routing |
| GitHub App | Every installation of the App | Anything installable or multi-tenant |
App webhooks are usually the right choice for anything beyond a single repository — one configuration covers every installation, and new repositories are included automatically as the App is installed on them.
The request
Section titled “The request”POST /your-endpoint HTTP/1.1Content-Type: application/jsonUser-Agent: GitHub-Hookshot/abc1234X-GitHub-Event: pull_requestX-GitHub-Delivery: 7a1b2c3d-4e5f-6789-abcd-ef0123456789X-GitHub-Hook-ID: 12345678X-Hub-Signature-256: sha256=...Three headers matter.
X-GitHub-Event — the event type. Your handler dispatches on this.
X-GitHub-Delivery — a unique ID per delivery. This is your idempotency key.
X-Hub-Signature-256 — an HMAC of the body using your shared secret. This is how you know the
request came from GitHub.
The body is JSON. For pull_request it carries an action — opened, synchronize, closed —
plus the pull request and repository objects. The same event type covers several distinct things, so
handlers dispatch on event and action.
Verifying the signature
Section titled “Verifying the signature”import hashlibimport hmacimport os
WEBHOOK_SECRET = os.environ["GITHUB_WEBHOOK_SECRET"].encode()
def verify_signature(raw_body: bytes, signature_header: str | None) -> bool: """Return True if raw_body was signed with the shared secret.""" if not signature_header or not signature_header.startswith("sha256="): return False
expected = "sha256=" + hmac.new( WEBHOOK_SECRET, raw_body, hashlib.sha256 ).hexdigest()
# Constant-time comparison: never use == return hmac.compare_digest(expected, signature_header)What it doesVerifies that a webhook payload was signed with your shared secret, in constant time.
Why we run ithmac.compare_digest avoids leaking information through timing. A naive == comparison returns faster on an early mismatch, which is exploitable.
Expected resultTrue for a genuine delivery, False otherwise.
Wired into a minimal Flask handler:
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/webhook")def webhook(): if not verify_signature(request.get_data(), request.headers.get("X-Hub-Signature-256")): abort(401)
event = request.headers.get("X-GitHub-Event") delivery = request.headers.get("X-GitHub-Delivery") payload = request.get_json()
if already_processed(delivery): return "", 200
enqueue(event, payload, delivery) # do the work elsewhere return "", 202request.get_data() returns the raw bytes. Using request.get_json() for verification would fail,
because re-encoding produces different bytes than GitHub signed.
Respond fast, work later
Section titled “Respond fast, work later”GitHub expects a timely response and will treat a slow endpoint as failed.
Acknowledge immediately, process asynchronously. Validate the signature, enqueue the work, return
202. Do not build a Docker image inside the request handler.
Return the right status. 2xx is success. Anything else marks the delivery failed and makes it a
candidate for redelivery.
Never return 2xx for an invalid signature. Return 401 — a rejected forgery should not look
like a successful delivery.
Idempotency
Section titled “Idempotency”Webhooks are at-least-once. The same delivery can arrive more than once: GitHub retries failures, and someone may manually redeliver from the interface.
Your handler must therefore be safe to run twice.
Deduplicate on X-GitHub-Delivery. Record processed IDs and short-circuit repeats — the pattern
in the handler above.
Make the action naturally idempotent. “Ensure this label is present” survives repetition; “post a comment” does not, and produces duplicates that look like a bug to everyone watching.
Be careful with synchronize. Every push to a pull request branch fires one. A handler that
starts an expensive job per event will start many during active work — debounce, or cancel
in-progress work when a newer event arrives.
Retries and inspection
Section titled “Retries and inspection”Failed deliveries are retried, and both repository and App webhook interfaces show recent deliveries with their request, response and status.
That view is the primary debugging tool. It answers, without instrumenting your own service, whether GitHub sent the event, what it sent, and what your endpoint returned. Deliveries can also be redelivered manually, which is how you replay an event after fixing a handler bug — far better than trying to reproduce the original action.
Choosing events
Section titled “Choosing events”Subscribe narrowly. Every event is a request to your endpoint, and a handler that ignores most of what it receives is wasting both sides’ resources.
| Event | Fires on |
|---|---|
push | Commits pushed to any ref |
pull_request | Opened, closed, reopened, edited, synchronized, ready for review |
pull_request_review | Review submitted, edited, dismissed |
issues | Opened, closed, labeled, assigned |
issue_comment | Comments on Issues and pull requests |
check_suite / check_run | CI status changes |
release | Published, edited, deleted |
workflow_run | A workflow run completes |
issue_comment covering both Issues and pull requests follows from them sharing a number space —
handlers must check for a pull_request key to tell them apart.
Local development
Section titled “Local development”Your laptop has no public URL, so you need a tunnel — ngrok, cloudflared, or similar — forwarding
a public address to your local port. Register that URL temporarily.
Alternatively, capture a real payload from the deliveries view and replay it against your handler as a fixture. That is faster for iterating on logic, and it makes handler behaviour testable without a network at all. Sign your fixtures with the same secret so the verification path is exercised too.
Dispatching on event and action
Section titled “Dispatching on event and action”The Flask example earlier verifies and enqueues. Real handlers also need to route, because one event type covers many distinct things.
HANDLERS: dict[tuple[str, str | None], object] = {}
def on(event: str, action: str | None = None): """Register a handler for an event, optionally narrowed to one action.""" def decorator(fn): HANDLERS[(event, action)] = fn return fn return decorator
@on("pull_request", "opened")def pr_opened(payload: dict) -> None: pr = payload["pull_request"] log.info("PR #%s opened by %s", pr["number"], pr["user"]["login"])
@on("pull_request", "synchronize")def pr_updated(payload: dict) -> None: log.info("PR #%s updated", payload["pull_request"]["number"])
def dispatch(event: str, payload: dict): action = payload.get("action") return HANDLERS.get((event, action)) or HANDLERS.get((event, None))pull_request alone covers opened, closed, reopened, edited, synchronized, labelled, assigned and
ready-for-review. A single handler for the event type will do the wrong thing for most of them, and
synchronize in particular fires on every push to the branch — a handler that starts expensive
work there will start a great deal of it during active development.
Returning 204 for events you are subscribed to but do not handle is worth doing: it distinguishes
“received and ignored” from “received and processed” in the deliveries view, which makes debugging
much easier.
Testing without a tunnel
Section titled “Testing without a tunnel”Tunnels are fine for exploration and poor for iteration. Capture one real payload from the deliveries view, then replay it — signed, so the verification path is genuinely exercised:
import hashlib, hmac, json, os, requests
secret = os.environ["GITHUB_WEBHOOK_SECRET"].encode()body = open("fixtures/pull_request.opened.json", "rb").read()signature = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
response = requests.post( "http://localhost:5000/webhook", data=body, headers={ "Content-Type": "application/json", "X-GitHub-Event": "pull_request", "X-GitHub-Delivery": "test-delivery-0001", "X-Hub-Signature-256": signature, }, timeout=5,)print(response.status_code, response.text)Signing the fixture matters — an unsigned replay skips exactly the code you least want to discover is broken. Committing a fixture per handled event gives you a regression suite that runs offline in milliseconds, and it costs nothing to maintain.
Webhooks versus polling
Section titled “Webhooks versus polling”Webhooks are better in most respects and they are not free.
| Webhooks | Polling | |
|---|---|---|
| Latency | Seconds | Half the interval, on average |
| Rate limit cost | None | Frequency multiplied by objects |
| Infrastructure | A public HTTPS endpoint | A scheduled job |
| Missed events | Possible; needs reconciliation | Self-correcting |
| Local development | Tunnel or fixtures | Trivial |
| Ordering | Not guaranteed | Whatever you query |
Many production systems want both: webhooks for latency, plus a low-frequency reconciliation sweep that catches anything missed while your endpoint was down. Treating webhooks as the only source means an outage of your own service silently loses events.
If you cannot host a public endpoint, poll — but poll with ETag conditional requests, which makes
unchanged polls free. See gh api.
Events worth subscribing to
Section titled “Events worth subscribing to”Subscribe narrowly. Every event is traffic to your endpoint and a handler you must at least ignore correctly.
pull_request — the workhorse. Dispatch on action; synchronize is the high-volume one.
issue_comment — fires for comments on Issues and pull requests, since they share a number
space. Check for a pull_request key to distinguish.
check_suite and check_run — CI outcomes. Far better than polling run status.
push — every ref update, including tags and deletions. High volume on an active repository.
workflow_run — a run completing, with its conclusion.
release — publication, for notifying downstream systems.
Avoid subscribing to everything “in case”. The cost is not just bandwidth: an endpoint receiving events it does not understand is one where a genuine handler failure is harder to spot.
Common mistakes
Section titled “Common mistakes”No signature verification. Your endpoint becomes a public API for your system.
Verifying against parsed JSON. Re-encoding changes the bytes; verify the raw body.
Using == for comparison. Timing-attackable; use compare_digest.
Slow handlers. Acknowledge fast, process asynchronously.
Assuming exactly-once delivery. It is at-least-once; deduplicate on the delivery ID.
Subscribing to everything. Wasted traffic and noise.
Trusting the payload for authorisation. Verify the actor’s permissions separately.
Returning 200 for a rejected forgery. Hides attacks and makes debugging misleading.
Delivery guarantees, precisely
Section titled “Delivery guarantees, precisely”Understanding what GitHub promises — and does not — determines what your handler must cope with.
At-least-once, not exactly-once. The same delivery can arrive twice. Deduplicate on
X-GitHub-Delivery.
No ordering guarantee. Two events can arrive out of order, particularly under load. A handler that
assumes opened arrives before synchronize will eventually be wrong.
Retries on failure. A non-2xx response or a timeout is retried, with backoff, for a limited period.
Deliveries can be lost. If your endpoint is down for long enough, events are dropped. There is no replay-everything-since mechanism.
That last point is the one that shapes architecture. A system relying solely on webhooks will silently miss events during its own outages, and the missing state is invisible — nothing indicates that something did not arrive.
The standard mitigation is reconciliation: a low-frequency sweep that queries current state and corrects anything the event stream missed.
# Hourly: catch anything the webhook handler missedgh api "repos/OWNER/REPO/pulls?state=open&per_page=100" --paginate \ --jq '.[] | select(.labels | map(.name) | index("triaged") | not) | .number' \| while read -r n; do echo "PR #$n was never triaged — handling now" # ...same logic the webhook handler would have run... doneWebhooks for latency, reconciliation for completeness. Neither alone is sufficient for anything that must not miss events.
Ordering and idempotency together
Section titled “Ordering and idempotency together”Because ordering is not guaranteed, handlers should be written to converge on the correct state rather than to apply a sequence of transitions.
# Fragile — assumes this event is the latest@on("pull_request", "labeled")def labeled(payload): add_to_board(payload["pull_request"]["number"], payload["label"]["name"])
# Robust — reads current state and converges@on("pull_request")def pull_request_changed(payload): number = payload["pull_request"]["number"] current = fetch_pull_request(number) # authoritative sync_board(number, [l["name"] for l in current["labels"]])The second treats the event as a signal that something changed, not as a description of the change. It is immune to reordering, duplication and missed events, at the cost of one extra API call.
That trade is nearly always worth taking. Handlers that trust the payload’s description of a transition are the ones that produce state nobody can explain three months later.
Securing the endpoint beyond signatures
Section titled “Securing the endpoint beyond signatures”Signature verification proves the payload came from GitHub. A few further measures are worth having.
Reject oversized bodies before hashing them. A handler computing HMAC over an unbounded body is a denial-of-service target.
Rate-limit by source. Even verified traffic can arrive faster than you can process.
Do not echo payload content into logs unfiltered. Issue and comment bodies are user-controlled text; a log viewer that renders them is an injection surface.
Authorise separately from authentication. A verified issue_comment event proves GitHub sent it,
not that the commenter is permitted to cause what the comment requests:
@on("issue_comment", "created")def command(payload): body = payload["comment"]["body"].strip() if not body.startswith("/deploy"): return
actor = payload["comment"]["user"]["login"] repo = payload["repository"]["full_name"]
if permission_level(repo, actor) not in {"admin", "write"}: log.warning("ignoring /deploy from %s (insufficient permission)", actor) return
start_deploy(repo, payload["issue"]["number"])Without the permission check, anyone who can comment on a public repository can trigger a deployment. This is not hypothetical — comment-triggered commands are a common pattern and a common way to build a remote execution path by accident.
Exercise
Section titled “Exercise”- Start a tunnel and run the Flask handler above locally.
- Register a repository webhook for
issueswith a strong secret. - Open an Issue and confirm the delivery arrives and verifies.
- Change the secret on your side only, and confirm you now return 401.
- Restore it, then use the deliveries view to redeliver the earlier event.
- Confirm your deduplication short-circuits the redelivery.
Steps 4 and 6 are the two that matter: one proves verification works, the other proves the handler survives the duplicate delivery that will eventually happen.
Scaling a webhook receiver
Section titled “Scaling a webhook receiver”A handler that works for one repository may not for five hundred.
Queue immediately. The HTTP handler should verify, deduplicate and enqueue — nothing else. All work happens in a consumer that can be scaled independently.
Deduplicate with a bounded store. Delivery IDs need only be remembered long enough to cover retries. A cache with an expiry of a day or so is sufficient; an unbounded table grows forever.
Handle bursts. Merging a branch with two hundred commits produces a burst of events. A queue absorbs it; a synchronous handler times out and GitHub retries, making it worse.
Fail closed on verification. If the secret cannot be read — a secret store outage — reject rather than accepting unverified payloads. An endpoint that degrades to trusting everything is worse than one that is down.
Monitor the deliveries view. It records what GitHub sent and what you returned, which is the one piece of observability you get for free.
Migrating from repository webhooks to an App
Section titled “Migrating from repository webhooks to an App”Repositories accumulate individually configured webhooks, which become unmanageable across an organisation.
- Register a GitHub App subscribing to the same events.
- Point it at the same endpoint, with the same secret, so the handler needs no change.
- Install it on a few repositories and confirm deliveries arrive.
- Remove the individual webhooks from those repositories.
- Extend the installation, removing individual hooks as you go.
During step 3 both fire, so your handler receives each event twice — which is exactly the duplicate case the delivery-ID deduplication already covers. If it does not, this migration will demonstrate that.
The gain is that new repositories are covered automatically, one configuration replaces N, and the secret lives in one place instead of being copied per repository.
What you learned
Section titled “What you learned”- Webhooks invert polling: GitHub tells you, at no rate-limit cost and within seconds.
X-GitHub-Event,X-GitHub-DeliveryandX-Hub-Signature-256are the three headers that matter.- Verify the HMAC against the raw body, in constant time, always.
- Acknowledge quickly and process asynchronously; a slow endpoint is a failed one.
- Delivery is at-least-once — deduplicate on the delivery ID.
- A valid signature authenticates GitHub, not the actor; authorise separately.
- Redelivery is the practical way to replay an event after fixing a bug.
A deployment checklist
Section titled “A deployment checklist”Before a webhook receiver handles production traffic:
- Signature verified against the raw body, in constant time, on every request.
- A strong secret, from a secret store, not a literal in the code.
- Rejects with 401 on verification failure — never a 2xx.
- Deduplicates on
X-GitHub-Delivery, with a bounded store. - Responds fast, enqueueing rather than processing inline.
- Dispatches on event and action, not on event alone.
- Authorises separately — a valid signature proves GitHub sent it, not that the actor may cause what it requests.
- Handles unknown events without erroring.
- Bounded body size before hashing.
- Reconciliation sweep for anything that must not miss events.
Item 7 is the one with the worst failure mode. A comment-triggered command without a permission check is a remote execution path available to anyone who can comment, and on a public repository that is everyone.
Item 10 is the one most often skipped: webhooks are at-least-once when delivered, and deliveries are dropped if your endpoint is down long enough. Without reconciliation, an outage of your own service silently loses events with nothing indicating it happened.
The ping event
Section titled “The ping event”When a webhook is created, GitHub immediately sends a ping event. It is easy to overlook and it is
the fastest way to confirm a new receiver works end to end.
{ "zen": "Non-blocking is better than blocking.", "hook_id": 12345678, "hook": { "type": "Repository", "events": ["pull_request", "push"], "config": { "url": "https://example.com/webhook", "content_type": "json" } }}Handling it explicitly is worth the three lines, because a receiver that returns 404 for ping looks
broken in the deliveries view even though everything else works:
@on("ping")def ping(payload: dict) -> None: log.info("ping from hook %s, events: %s", payload["hook_id"], ",".join(payload["hook"]["events"]))The hook.events list in the payload is genuinely useful — it tells you what this webhook is actually
subscribed to, which is frequently not what whoever configured it intended.
You can also re-trigger it at any time without waiting for a real event:
gh api --method POST "repos/OWNER/REPO/hooks/HOOK_ID/pings"That, plus the deliveries view, gives you a complete test loop for a receiver: ping, check the delivery was verified and returned 2xx, then subscribe to real events with some confidence.