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.io | gh webhook forward | webhooks.cc | |
|---|---|---|---|
| Public URL | Yes | No (uses long poll) | Yes |
| Inspect captured payloads | In-browser only, per session | In terminal only | Persistent dashboard + API |
| Replay a captured event | No | Limited | Yes — exact bytes, signature preserved |
| Send a signed test event without GitHub | No | No | Yes — three templates, HMAC-SHA256 signed |
Verify x-hub-signature-256 automatically | No | No | Yes — green/red badge on every request |
| Share with teammates | URL is public, no auth | No | Endpoint + history + replays |
| Auth | None | GitHub token | API 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/githubThis 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.
Install the CLI and authenticate
brew install webhooks-cc/tap/whk # or: npm i -g @webhooks-cc/cli
whk auth loginThe login flow opens a browser, prints a code, and stores a token at ~/.config/whk/token.json.
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.
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 + zenTo 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:
- 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. - Use a timing-safe comparison.
verifyGitHubSignaturedoes 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.