Get started
BETA
Browse docs
Guides

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.local as GITHUB_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-hooks so you don't have to update GitHub settings every session. Reserved subdomains are a paid-plan feature; see Reserve a subdomain.
  • GitHub sends a ping event when you first create a webhook. Return 200 (or handle it explicitly) so the dashboard shows the hook as healthy.
  • Keep GITHUB_WEBHOOK_SECRET in .env.local so signature verification stays consistent across restarts.

On this page