Skip to content
Back to blog
Local DevelopmentGitHubCLI tunnelSignature verification

Test GitHub Webhooks Locally: Smee, gh CLI, webhooks.cc

Three ways to receive GitHub webhooks on localhost — Smee, the GitHub CLI, and webhooks.cc. Tunnel real events, send signed test webhooks, and verify x-hub-signature-256 in your handler.

May 24, 20267 min read

There are three common ways to receive a GitHub webhook on your laptop today: Smee.io, the GitHub CLI's gh webhook forward, and webhooks.cc. They look similar at a glance — a public URL receives the request and forwards it to your local port. The differences only show up after the first iteration.

This post walks through each, then shows how to test a GitHub webhook handler end-to-end without ever clicking "Redeliver" in the GitHub UI.

The three options at a glance

Smee.iogh webhook forwardwebhooks.cc
Public URLYesNo (uses long poll)Yes
Inspect captured payloadsIn-browser only, per sessionIn terminal onlyPersistent dashboard + API
Replay a captured eventNoLimitedYes — exact bytes, signature preserved
Send a signed test event without GitHubNoNoYes — three templates, HMAC-SHA256 signed
Verify x-hub-signature-256 automaticallyNoNoYes — green/red badge on every request
Share with teammatesURL is public, no authNoEndpoint + history + replays
AuthNoneGitHub tokenAPI key / OAuth

Smee is the quickest path if you just need a URL. gh webhook forward is the right tool when the goal is "watch what GitHub actually sent." webhooks.cc adds the parts you reach for once you start writing tests: persistent captures, signed templates, signature checking, and replay.

Option 1: Smee.io

Three steps: visit smee.io, click Start a new channel, run npx smee-client --url <smee-url> --target http://localhost:3000/api/webhooks/github. Paste the smee URL into your GitHub webhook settings.

Smee is free and runs without an account. Trade-offs:

  • The channel URL is public. Anyone with the link can POST to it while it exists.
  • Captures live in the browser tab. Close it and the history is gone.
  • No signature verification, no provider awareness, no replay across sessions.
  • The smee-client process is a polling loop — if it crashes, deliveries queue at the smee channel but won't reach you until you restart.

Fine for a quick demo. Painful for sustained handler development.

Option 2: gh webhook forward

The GitHub CLI has a built-in forwarder. From a repo you have admin on:

gh webhook forward --events=push,pull_request \
  --repo=owner/repo \
  --url=http://localhost:3000/api/webhooks/github

This avoids the public-URL question entirely. gh opens a long-poll connection to GitHub and streams events directly to your machine. The signature header is included unchanged.

Two limits:

  • It only works for repos you can administer. You can't use it for org-level webhooks unless you own the org, and you can't tunnel a webhook from a service that isn't GitHub.
  • There is no inspection layer. The request hits your handler. If your handler 500s on the malformed body, you re-fetch from the Recent Deliveries UI and click "Redeliver" — manually, one event at a time.

Use it for fast local iteration against a repo you control. Reach for something else the moment you want to test the same payload multiple times, or share a failure with a teammate, or assert on the response in CI.

Option 3: webhooks.cc

You get a persistent endpoint URL, a tunnel command, signed test events for push / pull_request.opened / ping, automatic signature verification, and exact-bytes replay.

1

Install the CLI and authenticate

brew install webhooks-cc/tap/whk     # or: npm i -g @webhooks-cc/cli
whk auth login

The login flow opens a browser, prints a code, and stores a token at ~/.config/whk/token.json.

2

Create the endpoint with GitHub signing

whk create github-dev \
  --signing-provider github \
  --signing-secret "$GITHUB_WEBHOOK_SECRET"

The endpoint URL is printed (https://go.webhooks.cc/w/<slug>). Paste that into the GitHub webhook settings as the Payload URL and put the same secret in Secret. Every inbound request now gets its x-hub-signature-256 verified on the receiver — the dashboard shows a green badge on matches and the computed-vs-received diff on mismatches.

3

Tunnel to your local server

whk tunnel 3000/api/webhooks/github --endpoint <slug>

Every captured request now forwards to http://localhost:3000/api/webhooks/github, with the original signature header preserved.

That's the production loop. The next two sections cover the parts the GitHub UI can't help with.

Send signed test events without GitHub

Triggering a real push to test a handler change is slow. The webhooks.cc SDK ships signed payload generators for the three most common GitHub events — they produce the exact bytes GitHub would send, signed with your secret, with no network round-trip to GitHub:

import { WebhooksCC } from "@webhooks-cc/sdk";
 
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
 
// Send a signed push event straight to your local handler
await client.sendTo("http://localhost:3000/api/webhooks/github", {
  provider: "github",
  template: "push",
  secret: process.env.GITHUB_WEBHOOK_SECRET!,
});

provider: "github" selects the payload shape and signing scheme. The body matches the real GitHub push schema (ref, before/after SHAs, commits, repository, sender), the x-hub-signature-256 header is computed exactly the way GitHub computes it, and x-github-event: push is set. Your handler can't tell the difference.

Three templates are available today:

template: "push"               // refs/heads/main, single commit
template: "pull_request.opened" // sets x-github-event: pull_request
template: "ping"               // hook config + zen

To preview what would be sent without firing it, call client.buildRequest() with the same options — it returns { url, method, headers, body }.

Verify the signature in your handler

GitHub signs the raw body with HMAC-SHA256 and prefixes the result with sha256=. The SDK exposes that as a single function:

import { verifyGitHubSignature } from "@webhooks-cc/sdk";
 
export async function POST(req: Request) {
  const body = await req.text();          // raw body — must be the unparsed string
  const signature = req.headers.get("x-hub-signature-256");
  const valid = await verifyGitHubSignature(
    body,
    signature,
    process.env.GITHUB_WEBHOOK_SECRET!,
  );
  if (!valid) return new Response("invalid signature", { status: 401 });
 
  const event = req.headers.get("x-github-event");
  const payload = JSON.parse(body);
  // ... your logic
  return new Response("ok");
}

Two things to get right:

  1. Verify against the raw text body. Calling req.json() first consumes the stream and the verifier sees an empty string. This is the most common cause of "valid in production, invalid locally" failures in App Router routes.
  2. Use a timing-safe comparison. verifyGitHubSignature does this internally; rolling your own with === leaks information about how many bytes of the signature were correct.

End-to-end test in CI

The full loop — create endpoint, send a signed event, wait for capture, assert, tear down — fits in one file:

import {
  WebhooksCC,
  matchAll,
  matchMethod,
  matchHeader,
  matchBodyPath,
} from "@webhooks-cc/sdk";
import { describe, test, expect, beforeAll, afterAll } from "vitest";
 
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
let slug: string;
 
beforeAll(async () => {
  const endpoint = await client.endpoints.create({
    name: "ci-github",
    isEphemeral: true,
  });
  slug = endpoint.slug;
});
 
afterAll(async () => {
  await client.endpoints.delete(slug);
});
 
test("handler accepts a signed push", async () => {
  // Send a signed push event to the webhooks.cc endpoint
  await client.endpoints.sendTemplate(slug, {
    provider: "github",
    template: "push",
    secret: process.env.GITHUB_WEBHOOK_SECRET!,
  });
 
  // Wait for capture
  const captured = await client.requests.waitFor(slug, {
    timeout: "10s",
    match: matchAll(
      matchMethod("POST"),
      matchHeader("x-github-event", "push"),
      matchBodyPath("ref", "refs/heads/main"),
    ),
  });
 
  expect(captured).toBeDefined();
  expect(captured.headers["x-hub-signature-256"]).toMatch(/^sha256=/);
});

The same pattern works for pull_request.opened (header is x-github-event: pull_request) and ping. If the endpoint was created with --signing-provider github and the matching secret, captured requests carry a verified: true flag — matchVerified() will filter on it directly.

Replay an event the dashboard already has

When a captured request breaks your handler, you don't need to retrigger anything from GitHub. The dashboard's Replay button — and whk replay <request-id> --to http://localhost:3000/api/webhooks/github — re-sends the exact captured bytes, including the original signature, to any target URL. The signature still verifies, because the body is byte-identical.

This is the part Smee and gh webhook forward can't do. A captured request becomes a permanent fixture you can replay against new code, drop into a test, or share with a teammate.

Which to pick

  • One-off demo, no account: Smee.
  • You own the repo and just need to see what GitHub sends: gh webhook forward.
  • Building a handler, writing tests, debugging an intermittent failure, or testing across teammates: webhooks.cc.

If you're already on gh webhook forward for daily dev, webhooks.cc complements it — keep gh for "watch the real thing" and add webhooks.cc when you need to capture, replay, or send signed test events without involving GitHub.

GitHub provider guide

Templates, signing scheme, and the full SDK + CLI reference for GitHub webhooks.