Back to Blog
guides7 min readApril 11, 2026

How to Test GitHub Webhooks Locally Without ngrok

Building a GitHub bot, CI trigger, or PR automation means dealing with GitHub webhooks. Testing them requires a public HTTPS URL that GitHub can reach — which usually means ngrok or deploying to staging. This guide shows the fastest way to test GitHub webhooks locally with no tunnel, no binary install, and no deploy.

A
Abinash B
April 11, 2026

The GitHub Webhook Testing Problem

GitHub webhooks power a huge range of automations: CI/CD triggers, PR bots, auto-labellers, deploy hooks, Slack notifications, changelog generators. All of them share one constraint: GitHub needs a public HTTPS URL to deliver events.

During development, your handler runs locally at something like localhost:3000. GitHub cannot reach that. The usual workarounds:

  • ngrok — creates a public tunnel to your port, but requires installing and authenticating a binary, running a persistent terminal process, and updating GitHub every time the URL rotates (free plan)
  • Deploy to staging — painfully slow iteration loop; you can't breakpoint a deployed handler
  • GitHub's "Redeliver" button — re-sends the last event, but only shows up after the first delivery, and you still need a public URL

There's a faster path. This guide walks through the complete setup for local GitHub webhook testing using a cloud-side relay that requires nothing to install.


What You Need

  • A GitHub account and repository (or GitHub App)
  • A local server running your webhook handler (e.g. localhost:3000)
  • A free WebhookWhisper account — or use guest mode with no account at all

No binary installs, no CLI setup, no persistent tunnel process.


Step 1 — Get a Public Endpoint

Go to webhookwhisper.com and click Get my free webhook URL. You get an HTTPS URL like:

https://webhookwhisper.com/hook/abc123xyz

This is live immediately. No configuration, no account required for the guest endpoint.

If you're working on an integration you'll develop over days or weeks, sign up for a free account to get a permanent URL that doesn't expire. You register it with GitHub once and never touch that setting again.


Step 2 — Add the URL to GitHub

In your GitHub repository:

  1. Go to Settings → Webhooks → Add webhook
  2. Paste your WebhookWhisper URL in the Payload URL field
  3. Set Content type to application/json
  4. Add a Secret if you want to test signature verification (optional)
  5. Under Which events, choose Send me everything or select specific events
  6. Click Add webhook

GitHub immediately fires a ping event to verify your URL. You'll see it arrive in the WebhookWhisper inspector within a second.


You can also fire realistic GitHub payloads instantly without a real repo using the GitHub webhook testing page.

Step 3 — Forward Events to Your Local Handler

  1. In your WebhookWhisper endpoint, open the Forwarding tab
  2. Click Add rule
  3. Set the target URL to your local handler: http://localhost:3000/webhooks/github
  4. Click Save

Now every GitHub event that hits your WebhookWhisper URL is automatically relayed to your local server — with all original headers intact, including:

  • X-GitHub-Event — the event type (push, pull_request, etc.)
  • X-Hub-Signature-256 — HMAC-SHA256 signature of the payload
  • X-GitHub-Delivery — unique delivery ID for deduplication

Step 4 — Verify GitHub Webhook Signatures

If you added a secret in Step 2, GitHub signs every payload with it. Here's how to verify the signature in Node.js:

import crypto from 'crypto'
import express from 'express'

const app = express()

// ⚠️ Use raw body — signature check fails if body is already parsed
app.post('/webhooks/github',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['x-hub-signature-256']
    const githubEvent = req.headers['x-github-event']
    const deliveryId = req.headers['x-github-delivery']

    // Verify signature
    const expected = 'sha256=' + crypto
      .createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET)
      .update(req.body)
      .digest('hex')

    if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
      return res.status(401).send('Signature mismatch')
    }

    const payload = JSON.parse(req.body.toString())

    // Route by event type
    switch (githubEvent) {
      case 'push':
        console.log(`Push to ${payload.ref} by ${payload.pusher.name}`)
        // trigger CI, invalidate cache, etc.
        break
      case 'pull_request':
        if (payload.action === 'opened') {
          console.log(`PR #${payload.number} opened: ${payload.pull_request.title}`)
        }
        break
      case 'workflow_run':
        console.log(`Workflow ${payload.workflow.name}: ${payload.action}`)
        break
    }

    res.json({ received: true })
  }
)

Since WebhookWhisper forwards all headers intact, X-Hub-Signature-256 arrives at your local handler exactly as GitHub sent it. The signature verification works identically to production.


Step 5 — Trigger Events and Test Your Handler

With forwarding active, trigger events naturally:

  • push event: push a commit to the repo
  • pull_request event: open or update a PR
  • workflow_run event: trigger a GitHub Actions workflow
  • issues event: create or close an issue

Each event appears in the WebhookWhisper inspector immediately. You see the full pretty-printed payload and all headers. Simultaneously, WebhookWhisper forwards the event to your local handler and logs the HTTP status your handler returned.

If your handler returns 500, WebhookWhisper shows it in the delivery log immediately — no need to dig through server logs to know whether it succeeded.


Replaying Events After Fixing a Bug

This is where WebhookWhisper's approach beats every alternative. Fix the bug in your handler, restart your local server, then click Replay on any past event in the WebhookWhisper inspector.

The exact payload is re-delivered to your handler immediately — no need to push another commit, open another PR, or wait for GitHub to retry. The entire fix-and-retest cycle takes seconds.


Testing GitHub App Webhooks

GitHub Apps receive events from all installed repositories through a single webhook URL configured on the App itself. The process is the same:

  1. In your GitHub App settings, set the Webhook URL to your WebhookWhisper endpoint
  2. Add a forwarding rule to your local handler
  3. All installation events, repository events, and check runs appear in the inspector and are forwarded to your local server

During active GitHub App development, you can leave this setup permanently. The WebhookWhisper URL is stable — you never update the App settings again.


GitHub Webhook Events Reference

Here are the most commonly tested GitHub webhook events and what they're used for:

EventWhen it firesCommon use case
pushCommits pushed to any branchTrigger CI, invalidate caches
pull_requestPR opened, closed, merged, reviewedAuto-assign, status checks
pull_request_reviewReview submitted on a PRAuto-merge on approval
workflow_runActions workflow completesPost-deploy hooks, notifications
issuesIssue opened, closed, labelledSync to Linear/Jira, triage
issue_commentComment posted on issue or PRBot slash commands
releaseRelease published or createdTrigger deploy, update changelog
createBranch or tag createdAuto-provision environments
check_runCI check completesUpdate PR status, notifications
starRepo starred/unstarredTrack growth metrics

Common Gotchas

Content type must be application/json

When adding a webhook in GitHub settings, the Content type dropdown defaults to application/x-www-form-urlencoded. Change it to application/json. If you leave it as form-encoded, your handler receives a URL-encoded string instead of JSON.

Deduplication with X-GitHub-Delivery

GitHub guarantees at-least-once delivery. If your endpoint returns 5xx, GitHub retries — meaning your handler can receive the same event twice. Use the X-GitHub-Delivery header value as an idempotency key to detect and skip duplicate deliveries.

Different payloads for the same event type

The pull_request event has an action field that can be opened, closed, merged, synchronize, labeled, and more. Always check the action field before acting — otherwise your "PR merged" handler will fire on every PR update. WebhookWhisper's payload inspector makes it easy to compare payloads for different action values side by side.



Related Guides

Summary: The Full Local Testing Workflow

Once set up, your GitHub webhook test loop looks like:

  1. Start your local handler: npm run dev
  2. Make a code change to your handler
  3. Push a commit (or open a PR, or trigger whatever event you're handling)
  4. Watch the event arrive in the WebhookWhisper inspector
  5. Check the delivery log — did your handler return 200?
  6. If not, fix the bug and click Replay

No deploy. No staging server. No ngrok binary. Just fast, local iteration on real GitHub payloads.

Create your free WebhookWhisper account and have your first GitHub webhook flowing to localhost in under two minutes.

#github#webhooks#testing#localhost#tutorial

Ready to test your webhooks?

Get a free HTTPS endpoint in under 5 seconds — no signup required.

Create Free Account
How to Test GitHub Webhooks Locally (2026 Guide) | WebhookWhisper