How to Test Webhooks Locally — 5 Methods Compared (2026 Guide)

May 16, 2026 · 14 min read · By Michael Lip

Webhooks are the backbone of modern event-driven architectures. Stripe fires a webhook when a payment succeeds. GitHub fires one when code is pushed. Shopify fires one when an order is placed. But when you're building the endpoint that receives those events, you hit a wall: the webhook provider cannot reach localhost.

This guide covers five proven methods for testing webhooks on your local development machine. Each section includes step-by-step instructions, real commands you can copy, and a comparison table so you can pick the right tool for your project. At the end, you'll find an interactive payload builder that generates ready-to-run cURL commands for manual testing.

Contents
  1. Why Webhook Testing Is Hard (The Localhost Problem)
  2. 5 Methods to Test Webhooks Locally
  3. Method 1: ngrok
  4. Method 2: Cloudflare Tunnel
  5. Method 3: localtunnel
  6. Method 4: VS Code Port Forwarding
  7. Method 5: Request Bin (InvokeBot / webhook.site)
  8. Side-by-Side Comparison
  9. Debugging Common Webhook Failures
  10. Security Best Practices
  11. Testing Patterns: Replay, Record, Mock
  12. Interactive Webhook Payload Builder
  13. Frequently Asked Questions

Why Webhook Testing Is Hard

When a webhook provider like Stripe or GitHub sends an HTTP request to your endpoint, it originates from their servers on the public internet. Your development machine sits behind multiple layers of isolation:

The result: you cannot simply paste http://localhost:3000/api/webhooks into a Stripe dashboard and expect it to work. You need a bridge between the public internet and your local machine. That bridge is called a tunnel.

Need a quick refresher on how webhooks work end-to-end? Read our short answer on testing webhooks locally or the deep dive on debugging webhook failures.

5 Methods to Test Webhooks Locally

Each method solves the localhost problem differently. Some create a real TCP tunnel. Others intercept and forward requests through a proxy. The right choice depends on your priorities: speed of setup, URL stability, team workflows, and whether you need to inspect raw payloads.

Method 1: ngrok

ngrok is the most widely used tunneling tool for local webhook development. It creates a secure tunnel from a public HTTPS URL to a port on your machine, with a built-in web dashboard for inspecting every request and response.

Setup (macOS/Linux):

1 Install ngrok:

# macOS (Homebrew)
brew install ngrok

# Linux (snap)
snap install ngrok

# Or download directly
curl -s https://ngrok-agent.s3.amazonaws.com/ngrok-v3-stable-linux-amd64.tgz | tar xz
sudo mv ngrok /usr/local/bin/

2 Authenticate (free account required since 2024):

ngrok config add-authtoken YOUR_AUTH_TOKEN

3 Start the tunnel pointing to your local server:

# If your server runs on port 3000
ngrok http 3000

4 Copy the Forwarding URL from the output:

Forwarding  https://a1b2c3d4.ngrok-free.app -> http://localhost:3000

5 Register the URL in your webhook provider. For Stripe, that would be:

https://a1b2c3d4.ngrok-free.app/api/webhooks/stripe

6 Open the inspection dashboard at http://127.0.0.1:4040 to see every request in real time.

Pros: Battle-tested, excellent inspection UI, replay button for re-sending requests, supports custom domains on paid plans.
Cons: Free URLs change on every restart. Free tier shows an interstitial warning page for browser requests (does not affect API/webhook calls). Requires an account.

Method 2: Cloudflare Tunnel (cloudflared)

Cloudflare Tunnel (formerly Argo Tunnel) creates a secure outbound-only connection from your machine to Cloudflare's edge network. The --url quick-tunnel mode requires no Cloudflare account and generates a temporary public URL instantly.

Setup:

1 Install cloudflared:

# macOS
brew install cloudflared

# Linux (Debian/Ubuntu)
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o cloudflared.deb
sudo dpkg -i cloudflared.deb

2 Start a quick tunnel:

cloudflared tunnel --url http://localhost:3000

3 Copy the generated URL from the output:

+-----------------------------------------------------------+
|  Your quick Tunnel has been created! Visit it at:          |
|  https://random-words-here.trycloudflare.com              |
+-----------------------------------------------------------+

4 Use the URL as your webhook endpoint. For persistent tunnels with a custom domain, authenticate with cloudflared tunnel login and create a named tunnel.

Pros: No account needed for quick tunnels. Free for personal use. Faster cold-start than ngrok. No interstitial pages. Can use your own domain for stable URLs at no extra cost.
Cons: No built-in request inspection dashboard (you need to log requests in your own app). Quick tunnel URLs change on restart.

Method 3: localtunnel

localtunnel is an open-source npm package that creates a public URL for your local server with a single command. It's the fastest option if you already have Node.js installed.

Setup:

1 Install globally:

npm install -g localtunnel

2 Start the tunnel:

lt --port 3000

3 For a consistent subdomain (helpful to avoid updating webhook URLs):

lt --port 3000 --subdomain my-webhook-dev

This gives you https://my-webhook-dev.loca.lt — the subdomain persists across restarts as long as nobody else claims it.

Pros: Zero config, open source, supports custom subdomains for free, lightweight.
Cons: Shows a click-through interstitial on first browser visit (can be bypassed by setting the Bypass-Tunnel-Reminder header). Less reliable than ngrok/cloudflared for high-throughput testing. No inspection dashboard.

Method 4: VS Code Port Forwarding

If you use VS Code (or a GitHub Codespace), port forwarding is built in. No installation required.

Setup:

1 Open the VS Code terminal and start your local server on any port.

2 Open the Ports panel (View → Ports, or click the port icon in the bottom panel).

3 Click Forward a Port, enter your local port (e.g., 3000).

4 VS Code generates a public URL like https://username-port-3000.app.github.dev.

5 Set the port visibility to Public (right-click the port entry) so webhook providers can reach it without authentication.

6 Use the URL as your webhook endpoint.

Pros: Zero installation, integrated into the editor, works in GitHub Codespaces out of the box, free.
Cons: Requires VS Code. URL changes per session. Not scriptable for CI/CD. Port must be manually set to public for unauthenticated webhook access.

Method 5: Request Bin (InvokeBot / webhook.site)

Sometimes you do not need a tunnel at all. A request bin is a hosted URL that captures incoming HTTP requests and displays them in a web UI. You paste the bin URL into the webhook provider, trigger an event, and inspect the raw payload in your browser.

Setup with InvokeBot:

1 Open invokebot.com.

2 You get a unique inspection URL instantly — no account needed.

3 Paste the URL into your webhook provider dashboard.

4 Trigger an event. The incoming request appears in the InvokeBot inspector with full headers, body, and timing data.

5 Once you understand the payload shape, build your local handler. Use the payload builder below to replay the exact request against your local endpoint.

Pros: Zero setup, no installation, works from any device, ideal for quick inspection and team debugging.
Cons: Your local code does not execute — you are only capturing payloads. Not suitable for end-to-end testing of your handler logic. Payloads are visible to the service operator.

Side-by-Side Comparison

Feature ngrok Cloudflare Tunnel localtunnel VS Code Request Bin
Install required Yes Yes Yes (npm) No No
Account required Yes (free) No (quick mode) No GitHub login No
Stable URL Paid Free (named tunnel) Free (subdomain flag) No Per-session
HTTPS Yes Yes Yes Yes Yes
Inspection UI Yes (excellent) No No No Yes
Replay requests Yes No No No Manual
Custom domain Paid Free No No No
Best for Full-stack dev Production tunnels Quick prototypes Codespaces users Payload inspection

Debugging Common Webhook Failures

Even after you set up a tunnel, webhooks can fail in subtle ways. Here are the most common failure modes and how to fix them.

1. Signature Verification Fails

This is the number one issue developers encounter. The webhook provider signs the request body with a secret key and sends the signature in a header (e.g., X-Hub-Signature-256 for GitHub, Stripe-Signature for Stripe). Your handler computes the signature from the raw body and compares.

The problem: Most web frameworks parse the JSON body automatically, and when you re-serialize it to verify the signature, whitespace or key ordering changes. The computed hash no longer matches.

The fix: Capture the raw body before any parsing middleware runs.

// Express.js — correct approach
app.post('/api/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['stripe-signature'];
    const event = stripe.webhooks.constructEvent(
      req.body,  // raw Buffer, not parsed JSON
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    );
    // handle event...
    res.json({ received: true });
  }
);
# Python Flask — correct approach
@app.route('/api/webhooks/stripe', methods=['POST'])
def stripe_webhook():
    payload = request.get_data()  # raw bytes
    sig = request.headers.get('Stripe-Signature')
    event = stripe.Webhook.construct_event(
        payload, sig, WEBHOOK_SECRET
    )
    # handle event...
    return jsonify(success=True)

2. Timeout Errors

Webhook providers expect a response within a tight window — typically 5 to 30 seconds. If your handler takes longer (e.g., it writes to a slow database, calls a third-party API, or processes a large file), the provider marks the delivery as failed and retries.

The fix: Acknowledge the webhook immediately, then process asynchronously.

// Acknowledge first, process later
app.post('/api/webhooks', express.json(), async (req, res) => {
  // Respond immediately
  res.status(200).json({ received: true });

  // Process in background
  try {
    await processWebhookEvent(req.body);
  } catch (err) {
    console.error('Background processing failed:', err);
    // Queue for retry via your own mechanism
  }
});

3. Retry Storms and Duplicate Processing

When a provider does not receive a 2xx response, it retries — sometimes aggressively. Stripe retries up to 3 times over 3 hours. GitHub retries once after 10 seconds. If your handler is not idempotent, retries cause duplicate side effects: double charges, duplicate emails, duplicated database rows.

The fix: Store the event ID and check for duplicates before processing.

async function handleWebhook(event) {
  // Check if we've already processed this event
  const existing = await db.webhookEvents.findOne({
    eventId: event.id
  });
  if (existing) {
    console.log(`Duplicate event ${event.id}, skipping`);
    return;
  }

  // Store the event ID first
  await db.webhookEvents.insertOne({
    eventId: event.id,
    type: event.type,
    processedAt: new Date()
  });

  // Now process
  await processEvent(event);
}

4. Event Ordering Issues

Webhooks are not guaranteed to arrive in order. A payment_intent.succeeded event might arrive before the payment_intent.created event. If your handler assumes creation happens first, it will break.

The fix: Design handlers that are order-independent. Use the event timestamp or sequence number (if the provider includes one) to determine the latest state. Alternatively, fetch the current resource state from the provider API instead of relying on the webhook payload alone.

5. Wrong Content-Type

Some providers send application/x-www-form-urlencoded instead of application/json. If your handler only parses JSON, the body appears empty. Always check the provider documentation for the expected content type and configure your parser accordingly.

Security Best Practices

Webhook endpoints are publicly accessible HTTP routes. Without proper security, an attacker could forge webhook payloads to trigger actions in your system — creating fake orders, granting unauthorized access, or corrupting data.

HMAC Signature Validation

Always verify the webhook signature. The standard approach:

  1. Extract the signature from the request header.
  2. Compute HMAC-SHA256 of the raw request body using your webhook secret.
  3. Compare the computed signature to the received one using a constant-time comparison function to prevent timing attacks.
const crypto = require('crypto');

function verifySignature(rawBody, signature, secret) {
  const computed = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  // Constant-time comparison — prevents timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(computed, 'hex'),
    Buffer.from(signature, 'hex')
  );
}

IP Allowlisting

Major providers publish the IP ranges their webhook servers use. Add firewall rules or middleware to reject requests from unknown IPs. This provides defense-in-depth alongside signature verification.

# Stripe webhook IPs (check docs for current list)
# https://docs.stripe.com/ips

# GitHub webhook IPs
curl -s https://api.github.com/meta | jq '.hooks'

In your application:

const ALLOWED_IPS = new Set([
  '3.18.12.63',
  '3.130.192.149',
  // ... add all provider IPs
]);

function ipAllowlist(req, res, next) {
  const clientIp = req.headers['x-forwarded-for']?.split(',')[0]
    || req.socket.remoteAddress;
  if (!ALLOWED_IPS.has(clientIp)) {
    return res.status(403).json({ error: 'Forbidden' });
  }
  next();
}

Additional Measures

Testing Patterns: Replay, Record, Mock

Beyond tunnel-based development, three patterns make webhook testing reliable and repeatable.

Record and Replay

Capture real webhook payloads during development (using a request bin or ngrok's inspector), save them as JSON fixtures, and replay them in your test suite.

# Record: save a webhook payload to a file
curl -X POST https://a1b2c3d4.ngrok-free.app/api/webhooks \
  -H "Content-Type: application/json" \
  -d @stripe-payment-succeeded.json

# Replay: send a saved payload to your local server
curl -X POST http://localhost:3000/api/webhooks \
  -H "Content-Type: application/json" \
  -H "Stripe-Signature: t=1234,v1=abc..." \
  -d @fixtures/stripe-payment-succeeded.json

This approach gives you deterministic, repeatable test inputs without depending on external services.

Mock Webhook Servers

For integration tests, spin up a mock server that sends webhook-shaped requests to your handler. This lets you test edge cases (malformed payloads, missing headers, wrong content types) that are impossible to trigger from real providers.

// test/webhook.test.js
const request = require('supertest');
const app = require('../app');

describe('Webhook handler', () => {
  it('processes a valid Stripe event', async () => {
    const payload = require('./fixtures/stripe-payment-succeeded.json');
    const signature = generateTestSignature(
      JSON.stringify(payload),
      process.env.STRIPE_WEBHOOK_SECRET
    );

    const res = await request(app)
      .post('/api/webhooks/stripe')
      .set('Content-Type', 'application/json')
      .set('Stripe-Signature', signature)
      .send(payload);

    expect(res.status).toBe(200);
    expect(res.body.received).toBe(true);
  });

  it('rejects requests with invalid signatures', async () => {
    const res = await request(app)
      .post('/api/webhooks/stripe')
      .set('Stripe-Signature', 'invalid')
      .send({ type: 'payment_intent.succeeded' });

    expect(res.status).toBe(400);
  });
});

Provider CLI Tools

Several providers offer CLI tools that forward events directly to your local server, eliminating the need for a tunnel entirely:

# Stripe CLI — forward events to localhost
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# GitHub CLI — create a webhook that forwards to smee.io
# (smee.io acts as a proxy to your local machine)
smee -u https://smee.io/YOUR_CHANNEL -t http://localhost:3000/api/webhooks/github

These tools handle signature generation automatically, making local development nearly identical to production.

Interactive Webhook Payload Builder

Use this tool to build a webhook request and generate a ready-to-run cURL command. Paste the command in your terminal to send a test webhook to your local server or tunnel URL.

Webhook Payload Builder

Frequently Asked Questions

Can I test webhooks without ngrok?

Yes. Cloudflare Tunnel, localtunnel, VS Code port forwarding, and request bin services like InvokeBot are all viable alternatives. Cloudflare Tunnel is the strongest free alternative, with support for custom domains and no interstitial pages.

Why can't webhook providers send to localhost directly?

localhost and 127.0.0.1 resolve to the machine making the request — which is the provider's server, not yours. Your machine is also behind NAT and firewalls that block incoming connections. A tunneling tool solves this by creating an outbound connection from your machine to a relay server that the provider can reach.

How do I verify webhook signatures locally?

Use the same verification code you would use in production. Compute HMAC-SHA256 of the raw request body using your webhook secret, then compare it to the signature header. The critical mistake is using a parsed/re-serialized body instead of the raw bytes. In Express.js, use express.raw() middleware on your webhook route.

Do free ngrok URLs change every time I restart?

Yes. Free ngrok accounts get a random subdomain on each restart, which means you need to update the webhook URL in your provider's dashboard every time. Paid ngrok plans support reserved subdomains. Cloudflare Tunnel with a named tunnel and custom domain provides a stable URL for free.

What is the best webhook testing tool for teams?

For teams, use Cloudflare Tunnel with a shared domain or ngrok with team accounts for tunnel-based testing. For payload inspection, InvokeBot lets your team share a single inspection URL. For automated testing in CI/CD, record webhook payloads as JSON fixtures and replay them with cURL or your test framework.

How do I handle webhook retries?

Return a 200 status code quickly (within 5 seconds) to prevent retries. Process the event asynchronously after responding. Store event IDs to detect and skip duplicates. If you intentionally want retries (e.g., for at-least-once delivery), make your handler idempotent so duplicate processing is safe.

Next Steps

Now that you can receive webhooks locally, the next challenge is building a robust production handler. Read our guide to debugging webhook failures for a systematic framework covering network, transport, application, validation, and processing layers. For a quick reference, see the concise answer on testing webhooks locally.

Ready to inspect payloads without any local setup? Open the InvokeBot webhook tester and get a unique inspection URL in seconds.

Related Tools