Home/ Blog/ Tutorial

Test GitHub webhooks locally — the complete guide.

Two real options (smee.io forwarding vs a real tunnel), three drop-in signature-verification handlers, the events worth subscribing to, and how to graduate from “it works on my laptop” to a production endpoint without redoing your work.

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 a payload= form field and is only worth using for legacy consumers.
  • Secret: a high-entropy string. openssl rand -hex 32 is fine. Save it in your env as GITHUB_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:

  1. Read the raw request body as bytes. Do not parse JSON first.
  2. Compute HMAC-SHA256(secret, body_bytes) and hex-encode.
  3. Prefix your computed digest with the literal string sha256=.
  4. Constant-time compare the full string against the X-Hub-Signature-256 header.
  5. 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. Use ref to filter default branch vs feature branches.
  • pull_requestaction tells 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 at issue.pull_request to 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:

  1. 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.
  2. 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.
  3. The X-GitHub-Delivery UUID 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:

  1. Replace the tunnel URL with your production URL in the webhook settings.
  2. 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.
  3. Keep the github_events dedupe table in prod — it's what makes “Redeliver” safe for engineers debugging incidents.
  4. Alert on the Recent Deliveries failure count. GitHub won't tell you about silent 500s.
  5. 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.