Test GitHub webhooks locally
Receive GitHub webhook events on localhost during development — no deploy, no VPN, no mock payloads.
You want to receive real GitHub webhook events (push, pull request, issue, release) on your laptop while you're building the handler. JustTunnel gives GitHub a public URL that forwards directly to your dev server.
GitHub delivers webhooks via HTTP POST to a URL you configure in repo settings. localhost:3000 isn't reachable from GitHub's infrastructure. Deploying on every iteration is slow; mock event systems never quite match the real payloads. A tunnel is the simplest path.
Steps
1. Install the CLI
curl -fsSL https://justtunnel.dev/install | sh
2. Start your dev server
npm run dev
# → http://localhost:3000
3. Open a tunnel
justtunnel 3000
You'll see something like:
Tunnel established
https://abc123.justtunnel.dev → http://localhost:3000
Ready to receive traffic.
4. Configure the webhook in GitHub
In your repo, go to Settings → Webhooks → Add webhook and fill in:
- Payload URL:
https://abc123.justtunnel.dev/api/webhooks/github - Content type:
application/json - Secret: a random string. Save it in
.env.localasGITHUB_WEBHOOK_SECRET. - Events: "Send me everything" or a curated list (e.g.
push,pull_request).
5. Trigger an event
Push a commit or open a PR. Your local server receives the event:
POST /api/webhooks/github 200
The Recent Deliveries tab on the webhook settings page shows the full payload and lets you redeliver any past event with one click.
Example handler
A Next.js route that verifies the signature and routes by event type:
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
const secret = process.env.GITHUB_WEBHOOK_SECRET!;
function verifySignature(payload: string, signature: string): boolean {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(payload).digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected),
);
}
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get("x-hub-signature-256");
if (!signature || !verifySignature(body, signature)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const event = req.headers.get("x-github-event");
const payload = JSON.parse(body);
switch (event) {
case "push":
console.log("Push to", payload.ref, "by", payload.pusher.name);
// Trigger a build, notify Slack, update a dashboard.
break;
case "pull_request":
console.log("PR", payload.action, ":", payload.pull_request.title);
// Run checks, post a preview link, label the PR.
break;
default:
console.log("Unhandled event:", event);
}
return NextResponse.json({ received: true });
}
Three things matter: read the raw body as text before verifying, use crypto.timingSafeEqual to avoid timing attacks, and switch on the x-github-event header.
Tips
- Reserve the URL with
justtunnel 3000 --subdomain github-hooksso you don't have to update GitHub settings every session. Reserved subdomains are a paid-plan feature; see Reserve a subdomain. - GitHub sends a
pingevent when you first create a webhook. Return 200 (or handle it explicitly) so the dashboard shows the hook as healthy. - Keep
GITHUB_WEBHOOK_SECRETin.env.localso signature verification stays consistent across restarts.
Related
- Test Stripe webhooks locally
- Reserve a subdomain
justtunnel— base command reference