Handling GitHub Webhooks with FastAPI
GitHub will POST to your endpoint on practically anything — a push, a new issue, a closed PR, a release. That makes it a clean trigger for build-your-own automation: deploy on push, post to Slack on release, label issues on open, kick off a changelog job when a tag lands. This page builds a FastAPI handler that verifies GitHub's X-Hub-Signature-256 HMAC, routes on the X-GitHub-Event header, deduplicates retries, and acknowledges fast. It is part of Processing Webhooks with Python, which covers the receive-verify-enqueue pattern this page specializes for GitHub, and it sits inside the wider work of automating side-hustle operations with APIs.
The decision framing is simple: GitHub webhooks are the right tool when you want to react to repository events the instant they happen, without polling the REST API on a cron. If you only need state on a schedule, polling is simpler and you should read when to use webhooks instead of polling before you commit. If you need to act the moment something happens — and you want to stay far under GitHub's REST rate limit while doing it — the webhook below is the pattern.
When to reach for GitHub webhooks
Reach for a webhook when the value is in immediacy. Deploy the second main moves. Announce a release the instant it is published. Triage an issue as it lands rather than an hour later when a cron wakes up. A webhook is also far kinder to your rate limit than polling: GitHub pushes one small JSON body to you when a real event occurs, instead of you burning 5,000 authenticated requests an hour asking "anything new yet?" against a repository that changes twice a day.
Skip webhooks when you need history or batch state. "All PRs merged this week" is a query against the API, not a stream of events — you cannot reconstruct it reliably from webhook deliveries because you were not listening last Tuesday and GitHub does not backfill. Skip them, too, when your endpoint cannot be publicly reachable; for local development, GitHub's own webhook forwarding or a tunnel like ngrok exposes your machine so deliveries can reach it. The three events most side-hustle automations key on are push, pull_request, and release, so the handler routes those first and acknowledges everything else.
Verify the signature first
GitHub signs the raw request body with HMAC-SHA256 using the secret you set on the webhook, and sends the result as X-Hub-Signature-256: sha256=<hex>. Verification is the single most important line of the handler: without it, anyone who learns your endpoint URL can POST a fake release published event and trigger a deploy. The check is pure standard library — GitHub ships no signing SDK, so there is nothing to import.
Two rules make or break the check. First, hash the raw bytes exactly as received; if you let FastAPI parse the JSON and then re-serialize it, the whitespace changes and the signature will never match. Read await request.body() and hash that. Second, compare with hmac.compare_digest, not == — a plain string equality leaks timing information that a determined attacker can use to forge a signature byte by byte. The sequence below is what happens on every delivery.
The full handler
The event type arrives in a separate header, X-GitHub-Event, and a unique delivery id in X-GitHub-Delivery — your dedupe key. Install with pip install fastapi "uvicorn[standard]"; everything else is standard library. Read the secret from the environment so it never lands in source control, the same discipline you would apply on any async FastAPI setup.
import os
import hmac
import hashlib
import json
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
WEBHOOK_SECRET = os.getenv("GITHUB_WEBHOOK_SECRET", "")
def verify_github(raw_body: bytes, signature_header: str) -> bool:
if not signature_header.startswith("sha256="):
return False
sent = signature_header.removeprefix("sha256=")
expected = hmac.new(
WEBHOOK_SECRET.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, sent)
@app.post("/webhooks/github")
async def github_webhook(request: Request):
raw = await request.body()
signature = request.headers.get("X-Hub-Signature-256", "")
if not verify_github(raw, signature):
raise HTTPException(status_code=401, detail="bad signature")
event_type = request.headers.get("X-GitHub-Event", "")
delivery_id = request.headers.get("X-GitHub-Delivery", "")
payload = json.loads(raw)
# Route, but do the heavy lifting in a background worker.
match event_type:
case "push":
await on_push(payload, delivery_id)
case "pull_request":
await on_pull_request(payload, delivery_id)
case "release":
await on_release(payload, delivery_id)
case "ping":
return {"pong": True}
case _:
pass # acknowledge unhandled events with 200
return {"received": True, "event": event_type}
The ping case matters more than it looks: GitHub sends a ping event the moment you create the webhook, and the delivery dashboard shows a red X against the hook if you do not return a 2xx. The wildcard case _ returns 200 for events you have not subscribed to logic for, which stops GitHub from retrying deliveries you do not care about. One guard the snippet omits for brevity but production needs: reject a request whose body is larger than a sane ceiling (GitHub caps payloads at 25 MB) before you call json.loads, so a malformed or hostile body cannot balloon your memory.
Route handlers, and acknowledge fast
Each handler should enqueue and return — the same fast-ack rule from the webhooks guide. GitHub expects a response within roughly 10 seconds; cloning a repo, running a build, or making an outbound API call inline will blow that budget and trigger a retry, which arrives as a second delivery of the same event and doubles your work.
import logging
log = logging.getLogger("gh-webhook")
async def on_push(payload: dict, delivery_id: str) -> None:
ref = payload.get("ref", "")
if ref == "refs/heads/main":
log.info("push to main %s — enqueue deploy", delivery_id)
# await queue.enqueue("deploy", payload["after"])
async def on_pull_request(payload: dict, delivery_id: str) -> None:
if payload.get("action") == "opened":
log.info("PR #%s opened", payload["pull_request"]["number"])
# await queue.enqueue("label_pr", payload["pull_request"]["url"])
async def on_release(payload: dict, delivery_id: str) -> None:
if payload.get("action") == "published":
tag = payload["release"]["tag_name"]
log.info("release %s published — enqueue announce", tag)
The action field is how GitHub distinguishes sub-events within one type — pull_request alone covers opened, closed, synchronized, reopened, and a dozen more. Branch on payload["action"], not just the event header, or your "PR opened" automation will also fire on every force-push to the branch. The queue calls are commented out because the queue is your choice: for a solo build, arq or RQ backed by Redis is enough, and you can graduate to full background jobs with Celery when concurrency demands it. The timeline below is the whole reason to enqueue rather than process inline.
Production configuration and failure modes
A few environment-driven settings turn the snippet into something you can leave running. Keep the secret and any tuning knobs in os.getenv so staging and production differ only by config, never by code.
| Env var | Default | Production note |
|---|---|---|
GITHUB_WEBHOOK_SECRET | empty | Required; 32+ random bytes. Rotate on leak. |
WEBHOOK_MAX_BYTES | 1048576 | Reject larger bodies before parsing. |
DEDUPE_TTL_SECONDS | 86400 | How long to remember delivery ids. |
Three failure modes bite builders most often. The first is verifying against a re-serialized body instead of the raw bytes, which fails every signature silently — always hash await request.body(). The second is treating a retry as a new event: GitHub redelivers on any non-2xx or timeout, so a slow handler that eventually 200s can still fire your deploy twice. Dedupe on X-GitHub-Delivery, which is stable across retries, by storing seen ids in Redis with a TTL; the full pattern lives in building an idempotent webhook receiver. The third is losing events during a deploy of your own service — if your endpoint is down when GitHub delivers, GitHub retries only a handful of times over a short window, so a long outage drops events for good. Enqueue first, process later, and keep the receiver itself trivially cheap to restart.
Cost and performance at scale
The economics here are lopsided in your favor, which is exactly why webhooks beat polling for automation. A verified-and-enqueued delivery does an HMAC over a few kilobytes and one Redis write: call it 2–5 milliseconds of CPU. A single small instance — a $7/month box, or even a free tier — handles a busy organization's entire event firehose, because the expensive work never touches the request thread. At one million deliveries a month you are looking at a couple of dollars of compute if the worker count is matched to your vCPUs, plus whatever the enqueued jobs themselves cost.
Compare that to polling: to notice a push within 30 seconds you would poll every repo every 30 seconds, roughly 86,000 requests per repo per day, which shreds GitHub's 5,000-requests-per-hour authenticated limit after a handful of repositories and still adds up to 30 seconds of latency. The webhook delivers in well under a second and costs GitHub the request, not you. The only real spend is the downstream work you trigger — and if that work eventually charges customers, you fold the billing in via integrating Stripe with Python APIs.
When to use, when to avoid
Use GitHub webhooks when immediacy is the product: deploy-on-push, release announcements, instant issue triage, ChatOps. Avoid them when you need historical or aggregate state, when your endpoint cannot be public, or when a once-a-day batch job would do the same job with less moving infrastructure. And if you find yourself wiring the same receive-route-enqueue shape for GitHub, then Stripe, then a form provider, you are reimplementing a generic event router — at that point read building Zapier alternatives with Python and build the abstraction on purpose. The sibling pattern for payment events, verifying Stripe webhook signatures, differs only in the header and the signing scheme.
Builder verdict
Verify with the standard-library HMAC shown above — six lines, nothing to import. Route on X-GitHub-Event, branch on the payload's action, dedupe on X-GitHub-Delivery, and enqueue everything heavier than a log line. The one mistake that bites people is doing real work — a clone, a build, an outbound API call — inside the handler and tripping GitHub's delivery timeout, which turns one event into a retry pile-up and, without dedupe, a double deploy. Acknowledge in milliseconds, process in a worker, and a single small box will absorb an entire organization's event traffic on a rounding-error budget. That is the whole trade: near-zero receiver cost in exchange for the discipline of never blocking the request.
FAQ
How much does it cost to run this at a million deliveries a month? A couple of dollars of compute for the receiver, because each verified-and-enqueued delivery is 2–5 milliseconds of CPU plus one Redis write. The real spend is the downstream work you trigger — the deploy, the notification, the API call — not the webhook endpoint itself, which a $7 box or a free tier absorbs easily.
What's the difference between X-Hub-Signature and X-Hub-Signature-256?
The unsuffixed header is the legacy SHA-1 signature; X-Hub-Signature-256 is the SHA-256 version. Always verify the -256 header and ignore the SHA-1 one — SHA-1 is deprecated for this purpose and offers no security benefit to check.
How do I avoid processing the same delivery twice and double-deploying?
Dedupe on X-GitHub-Delivery, which is unique per delivery and stable across GitHub's retries. Store seen ids in Redis with a TTL and skip any id you have already handled. GitHub retries on any non-2xx or timeout, so without this a slow handler can fire your automation more than once.
How should I rotate the webhook secret without missing events?
Add the new secret in the GitHub webhook settings, then update GITHUB_WEBHOOK_SECRET and redeploy; during the overlap, verify against both the old and new secret and accept a match on either. Once every delivery signs with the new secret, drop the old one. This mirrors zero-downtime key rotation for any signed integration.
Can I test the endpoint without pushing real commits?
Yes. The GitHub webhook settings page has a "Recent Deliveries" tab with a Redeliver button, and you can copy a payload and replay it locally with a curl request carrying a self-computed X-Hub-Signature-256 header. That lets you develop against real payloads without touching the repository.
Related
Same track:
- Processing Webhooks with Python — the parent guide with the general receive-verify-enqueue pattern.
- Building an Idempotent Webhook Receiver — the dedupe store that stops double deploys.
- Verifying Stripe Webhook Signatures — the same idea for payment events.
- Building Zapier Alternatives with Python — when to turn ad-hoc handlers into a real event router.
Other tracks:
- When to Use Webhooks Instead of Polling — the decision behind this whole page.
- Running Background Jobs with Celery — where the enqueued work actually runs.