GitHub webhooks are the fastest way to bolt automation onto a repo: a push lands, a PR opens, an issue gets commented, and your service hears about it within a second. The piece that trips everyone up is the local part — you need GitHub's servers to reach your laptop, and the signature check is easy to get wrong. This post walks both.
Setup in 5 minutes
Before anything else, create the webhook in your repo. Navigate to Settings → Webhooks → Add webhook and fill it out:
- Payload URL: the public URL that forwards to your localhost (we'll produce one in the next two sections).
- Content type:
application/json— the other option,application/x-www-form-urlencoded, wraps the JSON in apayload=form field and is only worth using for legacy consumers. - Secret: a high-entropy string.
openssl rand -hex 32is fine. Save it in your env asGITHUB_WEBHOOK_SECRET; without this, anyone who reaches your URL can forge events. - SSL verification: leave “Enable SSL verification” on. Self-signed certs on your tunnel will cause GitHub to silently refuse delivery.
- Events: start with Just the push event; expand later (we list the useful ones below).
GitHub sends a ping event immediately on
webhook creation. If your handler doesn't return 200
to the ping, the webhook is marked unhealthy in the UI —
watch for that while iterating.
Option A — smee.io
smee.io is GitHub's official webhook proxy. You get a
public URL from smee; a local CLI client forwards events
from that URL to localhost:PORT over an
event-source connection. No tunnel, no inbound firewall
work.
# one-time install
npm install --global smee-client
# create a channel at https://smee.io/new, then forward events to localhost
smee --url https://smee.io/abc123XYZ \
--path /webhooks/github \
--port 3000
Paste the https://smee.io/abc123XYZ URL into
the Payload URL field in GitHub. Every
event GitHub fires lands on smee, smee pushes it down the
open event stream to your laptop, and the CLI POSTs it to
http://localhost:3000/webhooks/github.
Good: free, no account, no inbound connectivity required. Works on corporate Wi-Fi, hotel Wi-Fi, anywhere you have outbound HTTPS. The signature GitHub sends is preserved end-to-end, so your verification code runs against the real header.
Limits: single-developer flow — the smee URL is yours alone, and a teammate who wants to receive the same events needs their own channel. GitHub delivers events to one URL, so you can't easily multiplex smee + your staging environment from the same repo. Smee channels are best-effort; if the service is down, events are dropped and GitHub won't redeliver automatically.
Option B — a real tunnel
Run a tunnel so your localhost has a public HTTPS URL. Give GitHub that URL directly — no middleman, no event-source proxy.
# Any of these works:
mytunnel http 3000 # 21tunnel
ngrok http 3000 # ngrok
cloudflared tunnel --url http://localhost:3000 # Cloudflare Tunnel
# Paste the HTTPS URL into GitHub's Payload URL field:
# https://yoursubdomain.21tunnel.app/webhooks/github Good: identical code path to production — same TLS, same signature, same headers. Teammates can share the URL if you pick a reserved subdomain. Plays well with multiple SaaS webhooks (GitHub + Stripe + Linear) all posting to the same dev service.
Limits: tunnel URLs typically change on restart unless you reserve a subdomain (free on 21tunnel's Hobby tier, paid on ngrok). If the URL changes and you forget to update GitHub, events keep firing at the dead URL and you'll see delivery failures in the webhook UI.
Which to pick
- Solo dev, “I just want to see the payload” → smee. Zero setup.
- Shared dev environment, reserved URL → tunnel with a reserved subdomain.
- Debugging retries, 5xx behavior, or anything production-shaped → tunnel. Smee abstracts some of that away.
- GitHub Apps (not webhooks) → tunnel, because the App needs a single webhook URL and smee isn't a great fit for that.
I usually start on smee for the first hour of a new
integration (payload shape, what fields exist, what
X-GitHub-Event values show up for what
actions), then switch to a tunnel once I'm writing the
real handler and want retry / latency behavior to look
like production.
Verify the signature
GitHub signs every webhook with HMAC-SHA256 using the
secret you set. The signature arrives in two headers —
prefer X-Hub-Signature-256, not the legacy
SHA-1 X-Hub-Signature header:
X-Hub-Signature-256: sha256=7d38cdd689735b008b3c702edd92eea23791c5f6...
X-GitHub-Event: push
X-GitHub-Delivery: 72d3162e-cc78-11e3-81ab-4c9367dc0958 Verification recipe:
- Read the raw request body as bytes. Do not parse JSON first.
- Compute
HMAC-SHA256(secret, body_bytes)and hex-encode. - Prefix your computed digest with the literal string
sha256=. - Constant-time compare the full string against the
X-Hub-Signature-256header. - Reject anything that mismatches with a 401. Don't log which part was wrong.
Use the raw body. The single most common bug: your framework re-serializes JSON between receiving it and handing it to you, the re-serialized bytes differ from the original by whitespace, and the signature fails. Every example below pulls the raw body before any parsing.
Drop-in handlers
Node.js / Express
Express's default JSON parser eats the raw body. Use
express.raw() for the webhook route only, then
parse the JSON yourself after verification passes.
import express from "express";
import crypto from "node:crypto";
const app = express();
const secret = process.env.GITHUB_WEBHOOK_SECRET;
app.post(
"/webhooks/github",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["x-hub-signature-256"];
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(req.body).digest("hex");
if (
!sig ||
sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
) {
return res.status(401).send("bad signature");
}
const event = req.headers["x-github-event"];
const payload = JSON.parse(req.body.toString("utf8"));
switch (event) {
case "ping":
break; // nothing to do, just 200
case "push":
// payload.ref, payload.commits, payload.pusher
break;
case "pull_request":
// payload.action: opened, synchronize, closed, ...
break;
}
res.json({ received: true });
}
); Python / FastAPI
FastAPI gives you the raw body through await request.body().
Parse JSON yourself after signature verification:
import hmac, hashlib, os, json
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
secret = os.environ["GITHUB_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/github")
async def github(request: Request):
body = await request.body()
sig = request.headers.get("x-hub-signature-256", "")
expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
raise HTTPException(status_code=401, detail="bad signature")
event = request.headers.get("x-github-event")
payload = json.loads(body)
if event == "pull_request":
action = payload["action"] # opened, synchronize, closed...
# TODO: dispatch on action
elif event == "push":
pass
return {"received": True} Rust / axum
axum gives you Bytes directly, which is
already the raw body — no middleware gymnastics required.
use axum::{body::Bytes, http::HeaderMap, http::StatusCode};
use hmac::{Hmac, Mac};
use sha2::Sha256;
pub async fn github(headers: HeaderMap, body: Bytes)
-> Result<&'static str, StatusCode>
{
let secret = std::env::var("GITHUB_WEBHOOK_SECRET")
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let sig = headers.get("x-hub-signature-256")
.and_then(|v| v.to_str().ok()).unwrap_or("");
let mut mac: Hmac<Sha256> = Hmac::new_from_slice(secret.as_bytes())
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
mac.update(&body);
let expected: String = mac.finalize().into_bytes()
.iter().map(|b| format!("{:02x}", b)).collect();
let expected = format!("sha256={}", expected);
// Constant-time compare.
if expected.len() != sig.len() { return Err(StatusCode::UNAUTHORIZED); }
let mut diff = 0u8;
for (a, b) in expected.as_bytes().iter().zip(sig.as_bytes()) {
diff |= a ^ b;
}
if diff != 0 { return Err(StatusCode::UNAUTHORIZED); }
let event = headers.get("x-github-event")
.and_then(|v| v.to_str().ok()).unwrap_or("");
let payload: serde_json::Value = serde_json::from_slice(&body)
.map_err(|_| StatusCode::BAD_REQUEST)?;
// match event.as_str() { "push" => ..., "pull_request" => ..., _ => {} }
let _ = (event, payload);
Ok("ok")
} Events you actually care about
GitHub fires 60+ event types. You almost never need most of them. The ones that cover common automation flows:
push— branch updates. Userefto filter default branch vs feature branches.pull_request—actiontells you which transition fired (opened, reopened, synchronize, closed, ready_for_review).pull_request_review/pull_request_review_comment— review workflow.issue_comment— catches PR comments and issue comments; look atissue.pull_requestto disambiguate.workflow_run/check_run— CI status. Better than polling the API.release— tag published. Handy for changelog automation.ping— GitHub's health-check on webhook creation. Return 200 with no side effects.
Subscribe to the specific events you need rather than “Send me everything.” The noise level on a busy repo with “send me everything” makes real events hard to spot in your logs.
Redelivery + debugging
GitHub's delivery semantics are different from Stripe's in useful ways:
- Replay any delivery from the UI. Under Settings → Webhooks → Recent Deliveries, every event is kept for ~30 days with the full request/response. Click Redeliver to fire it again — invaluable when you're iterating on the handler.
- No automatic retries on 5xx. Unlike Stripe, GitHub does not retry failed deliveries automatically. If your handler 500s, the event is gone unless you replay it manually.
- The
X-GitHub-DeliveryUUID is your dedupe key. If you replay a delivery or the sender retries by hand, you'll see the same UUID — dedupe on it.
The simplest safe pattern for handlers that do real work:
CREATE TABLE github_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
delivery_id VARCHAR(64) UNIQUE NOT NULL, -- X-GitHub-Delivery
event_type VARCHAR(64) NOT NULL,
payload JSONB NOT NULL,
received_at TIMESTAMPTZ DEFAULT NOW()
);
-- on webhook:
INSERT INTO github_events (delivery_id, event_type, payload)
VALUES ($1, $2, $3)
ON CONFLICT (delivery_id) DO NOTHING
RETURNING id;
If the INSERT returned no row, it's a
duplicate — ack with 200 and skip. If it returned a row,
you're first; process the event async, return 200.
GitHub Apps vs webhooks
If you're building anything beyond a single-repo integration, use a GitHub App rather than a per-repo webhook. Apps give you:
- One webhook URL that receives events for every repo the App is installed on.
- Scoped access tokens (per-install) instead of a shared personal access token.
- A stable public identity — the App owner — instead of “whoever generated the PAT.”
The signature verification code is identical; the only extra steps are JWT-signed requests to trade the App's private key for installation access tokens. If you already have a tunnel pointed at your dev server, flipping from per-repo webhook to App is a 30-minute swap.
Graduating to production
The moves we made when ship-ready on 21tunnel's ops integration:
- Replace the tunnel URL with your production URL in the webhook settings.
- Rotate the secret — don't reuse your dev secret in prod. Keep both configured on the server briefly so replayed-from-dashboard deliveries still verify.
- Keep the
github_eventsdedupe table in prod — it's what makes “Redeliver” safe for engineers debugging incidents. - Alert on the Recent Deliveries failure count. GitHub won't tell you about silent 500s.
- Monitor handler p99 — GitHub's delivery timeout is 10 seconds; anything you can't finish in that budget must run async.
One-paragraph summary. Use smee for
zero-setup local dev, use a tunnel when you want a
production-shaped URL, always verify
X-Hub-Signature-256 over the raw
body with constant-time compare, always dedupe on
X-GitHub-Delivery, and remember GitHub
doesn't auto-retry — so a 500 you didn't notice
is an event you lost.
If you're reaching for a tunnel: 21tunnel has a free Hobby tier — 3 tunnels, custom domain, no rate cap — and the reserved subdomain means your GitHub webhook URL stays stable across laptop restarts. Or compare options in the roundup and vs ngrok.