Skip to content

GitHub Webhooks: Event-Driven Integration

Lesson 6 of 10Intermediate → Advanced13 min readGitHub Engineering · GitHub APIVerified: GitHub.com, August 2026

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.

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:

LevelScopeBest for
RepositoryOne repositoryA single project’s integration
OrganisationEvery repository in the organisationOrg-wide auditing or routing
GitHub AppEvery installation of the AppAnything 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.

POST /your-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: GitHub-Hookshot/abc1234
X-GitHub-Event: pull_request
X-GitHub-Delivery: 7a1b2c3d-4e5f-6789-abcd-ef0123456789
X-GitHub-Hook-ID: 12345678
X-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 actionopened, synchronize, closed — plus the pull request and repository objects. The same event type covers several distinct things, so handlers dispatch on event and action.

import hashlib
import hmac
import 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 "", 202

request.get_data() returns the raw bytes. Using request.get_json() for verification would fail, because re-encoding produces different bytes than GitHub signed.

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.

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.

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.

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.

EventFires on
pushCommits pushed to any ref
pull_requestOpened, closed, reopened, edited, synchronized, ready for review
pull_request_reviewReview submitted, edited, dismissed
issuesOpened, closed, labeled, assigned
issue_commentComments on Issues and pull requests
check_suite / check_runCI status changes
releasePublished, edited, deleted
workflow_runA 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.

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.

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.

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:

replay.py
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 are better in most respects and they are not free.

WebhooksPolling
LatencySecondsHalf the interval, on average
Rate limit costNoneFrequency multiplied by objects
InfrastructureA public HTTPS endpointA scheduled job
Missed eventsPossible; needs reconciliationSelf-correcting
Local developmentTunnel or fixturesTrivial
OrderingNot guaranteedWhatever 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.

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.

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.

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.

Terminal window
# Hourly: catch anything the webhook handler missed
gh 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...
done

Webhooks for latency, reconciliation for completeness. Neither alone is sufficient for anything that must not miss events.

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.

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.

  1. Start a tunnel and run the Flask handler above locally.
  2. Register a repository webhook for issues with a strong secret.
  3. Open an Issue and confirm the delivery arrives and verifies.
  4. Change the secret on your side only, and confirm you now return 401.
  5. Restore it, then use the deliveries view to redeliver the earlier event.
  6. 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.

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.

  1. Register a GitHub App subscribing to the same events.
  2. Point it at the same endpoint, with the same secret, so the handler needs no change.
  3. Install it on a few repositories and confirm deliveries arrive.
  4. Remove the individual webhooks from those repositories.
  5. 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.

  • Webhooks invert polling: GitHub tells you, at no rate-limit cost and within seconds.
  • X-GitHub-Event, X-GitHub-Delivery and X-Hub-Signature-256 are 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.

Before a webhook receiver handles production traffic:

  1. Signature verified against the raw body, in constant time, on every request.
  2. A strong secret, from a secret store, not a literal in the code.
  3. Rejects with 401 on verification failure — never a 2xx.
  4. Deduplicates on X-GitHub-Delivery, with a bounded store.
  5. Responds fast, enqueueing rather than processing inline.
  6. Dispatches on event and action, not on event alone.
  7. Authorises separately — a valid signature proves GitHub sent it, not that the actor may cause what it requests.
  8. Handles unknown events without erroring.
  9. Bounded body size before hashing.
  10. 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.

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:

Terminal window
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.

Professional ToolkitThe fine-grained token permissions matrix, App-vs-PAT guide and gh api recipes are in the Professional Toolkit.