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:

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

  1. Create a HubHook endpoint with relay to localhost
  2. Configure the public URL in your GitHub repository settings
  3. Start your local development server
  4. Trigger GitHub events (push code, open PRs, etc.)
  5. 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

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
Note: As of February 2026, GitHub CLI webhook forwarding is still experimental. Check 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:

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});
});
Important: Use 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:

  1. Go to your repository → Settings → Webhooks
  2. Click on your webhook URL
  3. Scroll to "Recent Deliveries"
  4. 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:

Problem: Signature Verification Fails

Check:

Problem: Webhook Arrives But Handler Crashes

Check:

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

  1. Use separate webhooks for dev/staging/production: Don't mix test events with production traffic
  2. Implement idempotency: GitHub may send the same event multiple times
  3. Return 200 quickly: Process events asynchronously; don't make GitHub wait
  4. Log everything: Capture event type, delivery ID, and processing result
  5. Handle all event types: Don't crash on unexpected events; log and return 200
  6. 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.