How to Forward GitHub Webhooks to Localhost
Three ways to receive GitHub webhooks on your local machine for development — including the one that does not require an ngrok account, a public URL, or punching holes in your firewall.
Why This Is Annoying
You're building a GitHub App or a CI integration. You need to test how your code reacts to a real pull_request event with a real payload from a real repo. The problem: GitHub sends webhooks to public URLs, and your dev server is on localhost:3000.
The traditional answer is "set up ngrok," but that has tradeoffs:
- Free ngrok URLs change every restart — you have to update your GitHub webhook config every session
- Stable URLs need a paid plan
- The tunnel goes through ngrok's network, with their bandwidth limits and inspection
- Your local machine is exposed to anyone who guesses the URL
Here are three approaches, ranked by how much friction they introduce.
Option 1: Hookbase CLI (Recommended)
Install the Hookbase CLI and run a single command:
npm install -g @hookbase/cli
hookbase login
hookbase tunnel --port 3000
You get a stable URL like https://acme.hookbase.app/ingest/your-source that doesn't change between sessions. Point GitHub at it once and you're done — restart your dev server as many times as you want.
Hookbase verifies the GitHub HMAC signature for you, stores every event for replay, and shows the full payload and your handler's response in the dashboard. When you break your handler at 2am and want to replay the webhook that crashed it, the event is right there.
# Replay any past event against your local handler
hookbase replay evt_abc123 --port 3000
This is the no-friction option for ongoing development.
Option 2: smee.io
GitHub's official suggestion. Free, no account required, runs in your browser:
npm install -g smee-client
smee --url https://smee.io/your-channel --target http://localhost:3000/webhook
Pros: free forever, official GitHub recommendation.
Cons: no signature verification on Smee's side (you still verify in your handler), no event history beyond the browser tab, no replay, no payload inspection. Fine for one-off testing, painful for daily use.
Option 3: ngrok
Still works, still familiar:
ngrok http 3000
Pros: well-known, mature.
Cons: URL changes on every restart unless you pay, no GitHub-specific features, no event history.
Configuring the GitHub Webhook
Whichever tunnel you pick, the GitHub setup is the same:
- Go to your repo → Settings → Webhooks → Add webhook
- Payload URL: your tunnel URL (the Hookbase ingest URL, smee channel, or ngrok address)
- Content type:
application/json - Secret: a random string — generate with
openssl rand -hex 32 - Events: pick the ones you care about (or "Send me everything" while developing)
- Save
Make a commit, open a PR, or trigger whatever event you configured. The webhook should arrive within a second or two.
Verifying the Signature in Your Handler
GitHub signs every payload with HMAC-SHA256 using your secret. Always verify it — even in development. Catching a signature bug locally is much cheaper than catching it in production.
import crypto from 'crypto';
function verifyGitHubSignature(payload: string, signature: string, secret: string) {
const hmac = crypto.createHmac('sha256', secret);
const digest = 'sha256=' + hmac.update(payload).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest));
}
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.header('X-Hub-Signature-256');
if (!verifyGitHubSignature(req.body.toString(), signature, process.env.GITHUB_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString());
console.log('Event type:', req.header('X-GitHub-Event'));
console.log('Action:', event.action);
res.status(200).send('ok');
});
Note the use of timingSafeEqual — never use === for signature comparison. Timing attacks are real even on localhost.
Common Issues
"I get a 401 on every webhook." Your secret is wrong, or you're hashing the parsed JSON instead of the raw body. The signature is computed over the exact bytes GitHub sent — re-serializing the JSON changes whitespace and breaks the hash.
"It works once, then GitHub stops sending." Your handler returned a non-2xx, GitHub marked the delivery as failed, and disabled the webhook. Check Settings → Webhooks → Recent Deliveries to see the response and re-enable the hook.
"My tunnel URL changed and now nothing works." This is the daily friction with ngrok free and smee. The Hookbase tunnel URL is stable for the lifetime of the source — restart as many times as you want.
Beyond Local Development
The same Hookbase tunnel that forwards to your laptop today is the same infrastructure you'd use to receive GitHub webhooks in production. You'd just point it at your production handler instead of localhost. No code changes, no second integration to maintain.