Testing GitHub Webhooks Locally Without ngrok
Developing applications that consume GitHub webhooks requires testing those integrations locally. The traditional solution—ngrok—works, but has significant drawbacks: URLs change on every restart (unless you pay), no built-in request inspection, and it's just a tunnel with no debugging features.
In this guide, we'll explore better alternatives for testing GitHub webhooks during development, including webhook relay services, the GitHub CLI, and strategies for robust local testing without external dependencies.
Why Testing GitHub Webhooks is Challenging
GitHub webhooks require a publicly accessible HTTPS URL. Your localhost server isn't publicly accessible, which creates a chicken-and-egg problem for development. You need to:
- Receive real GitHub webhook events (push, pull request, issues, etc.)
- Test signature verification with actual GitHub signatures
- Debug payload structure and event types
- Iterate quickly without deploying to production
While ngrok solves the accessibility problem, it doesn't help with debugging, inspection, or maintaining stable URLs across development sessions. Let's look at better approaches.
Method 1: Using a Webhook Relay Service (Recommended)
Webhook relay services give you a stable public URL that forwards webhooks to your localhost, plus powerful debugging features. HubHook is purpose-built for this workflow:
Setup Steps
- Create a HubHook endpoint with relay to localhost
- Configure the public URL in your GitHub repository settings
- Start your local development server
- Trigger GitHub events (push code, open PRs, etc.)
- Webhooks arrive at HubHook, get inspected, then forwarded to localhost
# Create endpoint that relays to localhost
curl -X POST https://api.hubhook.io/v1/endpoints \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"name": "GitHub Dev",
"relay_to": "http://localhost:3000/github/webhook"
}'
# Response includes your stable public URL
{
"id": "ep_abc123",
"url": "https://hooks.hubhook.io/ep_abc123",
"relay_to": "http://localhost:3000/github/webhook"
}
Why This Approach Wins
- Stable URLs: Your webhook URL never changes, even across restarts
- Request Inspection: See every header, payload, and signature before it reaches localhost
- Replay Webhooks: Re-send captured webhooks to test different code paths
- Signature Testing: Verify GitHub's signature validation with real payloads
- History: Browse all webhook attempts even if your local server was down
Method 2: GitHub CLI Forwarding
GitHub's official CLI can forward webhooks from GitHub to localhost, similar to Stripe CLI:
# Install GitHub CLI
brew install gh
# Authenticate
gh auth login
# Forward webhooks to localhost (experimental feature)
gh webhook forward --repo OWNER/REPO --events=push,pull_request --url=http://localhost:3000/webhook
gh webhook --help for current capabilities.
Method 3: Smee.io (Free, Open Source)
Smee.io is a free webhook relay service from GitHub:
# Install Smee client
npm install -g smee-client
# Start forwarding
smee --url https://smee.io/ABC123 --path /github/webhook --port 3000
Limitations:
- Webhooks are publicly visible (not private)
- URLs expire after inactivity
- No built-in signature verification testing
- Limited request history
Method 4: Mock Webhooks for Unit Testing
For comprehensive test coverage, mock GitHub webhooks in your test suite:
const crypto = require('crypto');
function createGitHubWebhook(event, payload, secret) {
const body = JSON.stringify(payload);
// Generate GitHub signature
const signature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');
return {
headers: {
'x-github-event': event,
'x-hub-signature-256': signature,
'x-github-delivery': crypto.randomUUID(),
'content-type': 'application/json',
},
body: body,
};
}
// Example test
test('handles push event', async () => {
const payload = {
ref: 'refs/heads/main',
commits: [{message: 'test commit'}],
repository: {full_name: 'user/repo'},
};
const webhook = createGitHubWebhook('push', payload, 'secret');
const response = await fetch('http://localhost:3000/webhook', {
method: 'POST',
headers: webhook.headers,
body: webhook.body,
});
expect(response.status).toBe(200);
});
Verifying GitHub Webhook Signatures
Regardless of which method you use, always verify GitHub's signature in your handler:
const crypto = require('crypto');
const express = require('express');
const GITHUB_WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET;
app.post('/github/webhook', express.raw({type: 'application/json'}), (req, res) => {
const signature = req.headers['x-hub-signature-256'];
const event = req.headers['x-github-event'];
// Compute expected signature
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', GITHUB_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
// Constant-time comparison
const isValid = crypto.timingSafeEqual(
Buffer.from(signature || ''),
Buffer.from(expectedSignature)
);
if (!isValid) {
console.error('Invalid signature');
return res.status(401).json({error: 'Invalid signature'});
}
// Parse and handle the event
const payload = JSON.parse(req.body);
handleGitHubEvent(event, payload);
res.json({received: true});
});
express.raw() middleware for webhook routes, not express.json(). GitHub's signature is computed on the raw request body. If you parse it first, signature verification will fail.
Testing Different GitHub Events
GitHub sends webhooks for 40+ event types. Here are the most common ones you'll want to test:
| Event Type | Trigger | Common Use Cases |
|---|---|---|
push |
Code pushed to repository | CI/CD, deploy triggers, notifications |
pull_request |
PR opened/closed/merged | Code review automation, CI checks |
issues |
Issue opened/closed/edited | Project management integration |
issue_comment |
Comment on issue/PR | Bot commands, notifications |
release |
Release published | Deploy to production, announcements |
workflow_run |
GitHub Actions completed | Build status notifications |
Manually Triggering Test Events
You can trigger GitHub webhooks manually from the repository settings:
- Go to your repository → Settings → Webhooks
- Click on your webhook URL
- Scroll to "Recent Deliveries"
- Click "Redeliver" on any past event to re-send it
This is perfect for testing your handler without having to actually push code or create issues.
Debugging GitHub Webhook Issues
Problem: Webhook Never Arrives
Check:
- Is your relay service running? (HubHook, Smee, etc.)
- Is your local server running on the correct port?
- Check GitHub's webhook delivery log for error messages
- Ensure your endpoint returns 200-299 status codes
Problem: Signature Verification Fails
Check:
- Using the correct webhook secret from GitHub settings
- Using
express.raw()notexpress.json() - Comparing against
x-hub-signature-256not the olderx-hub-signature - Secret matches between GitHub settings and your
.envfile
Problem: Webhook Arrives But Handler Crashes
Check:
- Add try-catch blocks around event handling
- Log the full payload to see what GitHub is actually sending
- Different event types have different payload structures
- Handle unknown event types gracefully
Test GitHub Webhooks Like a Pro
HubHook gives you stable URLs, full request inspection, webhook replay, and instant relay to localhost. Debug GitHub integrations in minutes, not hours.
Start Testing Free →Best Practices for GitHub Webhook Development
- Use separate webhooks for dev/staging/production: Don't mix test events with production traffic
- Implement idempotency: GitHub may send the same event multiple times
- Return 200 quickly: Process events asynchronously; don't make GitHub wait
- Log everything: Capture event type, delivery ID, and processing result
- Handle all event types: Don't crash on unexpected events; log and return 200
- Monitor webhook health: Set up alerts for signature failures or processing errors
Comparison: Local Testing Methods
| Method | Pros | Cons |
|---|---|---|
| HubHook | Stable URLs, inspection UI, replay, history | Paid service (free tier available) |
| GitHub CLI | Official tool, simple setup | Still experimental, limited features |
| Smee.io | Free, open source | Public webhooks, URLs expire, basic features |
| ngrok | General-purpose tunneling | URLs change on restart, no webhook-specific features |
| Mock Tests | Fast, offline, comprehensive | Not testing real GitHub integration |
Conclusion
Testing GitHub webhooks locally doesn't require settling for ngrok's limitations. Modern webhook relay services provide stable URLs, powerful debugging tools, and seamless localhost forwarding that make development dramatically faster.
For production-quality GitHub integrations, combine a relay service for manual testing with comprehensive mock tests for CI. This gives you the best of both worlds: real-world testing during development and fast, reliable tests in your pipeline.
For more webhook development guides, check out our posts on debugging Stripe webhooks and webhook security best practices. And if you're building developer tools or analytics platforms, explore Stack Stats Apps for productivity tools or ChainOptics for blockchain monitoring.