Recuro.

7 Ways to Test Webhooks on Localhost (2026 Guide)

·
Updated March 22, 2026
· Recuro Team
webhookstestingtools

Quick Summary — TL;DR

  • External webhook providers cannot reach localhost, so you need a tunnel (ngrok, Cloudflare Tunnel, VS Code port forwarding) or a proxy to test locally.
  • Provider CLIs like Stripe CLI forward events directly to your local server without requiring a public URL.
  • Use capture services like Webhook.site to inspect payloads before writing handler code, then replay them locally with cURL.
  • For CI/CD, skip tunnels entirely and use mock servers or direct HTTP calls with computed HMAC signatures.
  • Write automated tests covering valid signatures, invalid signatures, replay protection, idempotency, and unknown event types.
How To Test Webhooks Locally

You’ve built a webhook handler. It works in your head. But how do you test it when the webhook provider can’t reach localhost:3000? (If you’re still deciding whether you need webhooks or a traditional API for your integration, start with webhooks vs APIs.)

Quick start: Install ngrok, run ngrok http 3000, and copy the public URL it gives you. You’ll have a working tunnel in under a minute. Read on for free alternatives, zero-install options, and provider-specific tools.

The problem is simple: your development machine sits behind a router and firewall. External services like Stripe, GitHub, or Shopify need a publicly reachable URL to deliver webhook events. Your local server doesn’t have one.

Here are the most effective ways to solve this — from quick one-liners to production-grade testing setups.

Why you can’t just use localhost

When you register a webhook URL with a provider, they store it and send HTTP POST requests to it whenever a webhook event occurs. Your local machine has a private IP address (like 192.168.x.x or 10.x.x.x) that’s invisible to the internet. Even if you give a provider http://localhost:3000/webhooks, they can’t route to it.

You need one of two things:

  1. A tunnel that exposes your local server to the internet
  2. A proxy (sometimes called a webhook relay) that captures webhook payloads and lets you replay them locally

Both approaches have trade-offs. Let’s walk through each.

Method 1: ngrok — The industry standard

ngrok creates a secure tunnel from a public URL to your local machine. It’s the most widely used tool for webhook testing and the one most provider docs recommend.

Setup

Terminal window
# Install (macOS)
brew install ngrok
# Or download from https://ngrok.com/download
# Sign up for a free account and add your auth token
ngrok config add-authtoken YOUR_TOKEN
# Start a tunnel to your local server
ngrok http 3000

ngrok outputs a public URL like https://a1b2c3d4.ngrok-free.app. Use that as your webhook URL in the provider’s dashboard.

What you get

  • Request inspector — ngrok’s web interface at http://127.0.0.1:4040 shows every request with headers, body, and response
  • Replay — click any request to re-send it to your local server
  • HTTPS — all ngrok URLs use HTTPS by default, which many providers require

Limitations

  • Free tier gives you a random URL that changes on every restart (paid plans get a fixed subdomain)
  • Free tier has rate limits (20 connections per minute)
  • Some corporate networks block ngrok domains

Using ngrok with webhook providers

Here’s a common workflow with Stripe:

Terminal window
# Terminal 1: Start your local server
npm run dev # or php artisan serve, python manage.py runserver, etc.
# Terminal 2: Start ngrok
ngrok http 3000
# Copy the ngrok URL and add /webhooks/stripe
# Register it in Stripe Dashboard → Developers → Webhooks
# URL: https://a1b2c3d4.ngrok-free.app/webhooks/stripe

Now trigger an event in Stripe’s test mode (e.g., create a test payment), and watch it arrive at your local server.

Method 2: VS Code port forwarding — Zero installation

If you use VS Code, you already have a tunnel built in. Since 2023, VS Code includes port forwarding powered by dev tunnels — no extension or CLI to install.

Setup

  1. Open VS Code and start your local server (e.g., npm run dev on port 3000)
  2. Open the Ports panel (View > Open View > Ports, or click the port icon in the bottom panel)
  3. Click Forward a Port and enter 3000
  4. VS Code creates a URL like https://abc123-3000.uks1.devtunnels.ms
  5. Right-click the port entry and set Port Visibility to Public (the default is private and requires GitHub authentication, which webhook providers can’t do)
  6. Use the public URL as your webhook endpoint

When to use VS Code port forwarding

  • You already have VS Code open and don’t want to install anything
  • You want a quick tunnel without signing up for another service
  • You’re in a restricted environment where installing CLIs is difficult

Limitations

  • Requires a GitHub or Microsoft account (VS Code uses it for the tunnel)
  • URL changes each time you restart the tunnel
  • No built-in request inspector (use your server’s logs)
  • Slower to set up than ngrok http 3000 if you’ve already installed ngrok

Method 3: Cloudflare Tunnel — Free, no rate limits

If ngrok’s free tier is too restrictive, Cloudflare Tunnel (formerly Argo Tunnel) is a solid alternative. It’s completely free for development use with no connection limits.

Setup

Terminal window
# Install
brew install cloudflared
# Quick tunnel (no account needed)
cloudflared tunnel --url http://localhost:3000

This gives you a URL like https://random-words.trycloudflare.com.

When to use Cloudflare Tunnel over ngrok

  • You need longer sessions without URL changes
  • You’re behind a corporate network that blocks ngrok
  • You want a tunnel without rate limits
  • You already use Cloudflare for DNS

Named tunnels (persistent URLs)

For a stable URL, create a named tunnel with a Cloudflare account:

Terminal window
cloudflared tunnel login
cloudflared tunnel create my-dev-tunnel
cloudflared tunnel route dns my-dev-tunnel webhooks-dev.yourdomain.com
cloudflared tunnel run my-dev-tunnel

Now webhooks-dev.yourdomain.com always points to your local machine when the tunnel is running.

Method 4: localtunnel — Zero config, fully open source

localtunnel is an open-source alternative that requires no account:

Terminal window
npx localtunnel --port 3000

You get a URL like https://wild-puma-42.loca.lt. It’s the fastest way to get a tunnel running — one command, no signup.

Trade-offs

  • Less reliable than ngrok for long sessions
  • No built-in request inspector (use your server’s logs instead)
  • The first request shows a “click to continue” interstitial page — webhook providers can’t click buttons

Bypassing the interstitial: Visit your tunnel URL in a browser once and note the tunnel password shown on the page (it’s your external IP). Then pass it as a header in your webhook requests. If you’re configuring a webhook provider, you can’t control their headers, so visit the URL in your browser first to set the cookie, or use the --print-requests flag to confirm requests are getting through. Alternatively, add the Bypass-Tunnel-Reminder header with any value in your own test scripts:

Terminal window
curl -H "Bypass-Tunnel-Reminder: true" https://wild-puma-42.loca.lt/webhooks

Self-hosted option

If you don’t trust third-party tunnels, localtunnel lets you run your own server:

Terminal window
# On your VPS
npx localtunnel --server https://your-tunnel-server.com --port 3000

Method 5: Provider CLIs — Test without a tunnel

Many webhook providers offer CLI tools that forward events to your local server without requiring a public URL. This is often the best approach for provider-specific testing.

Stripe CLI

The Stripe CLI is the gold standard for webhook testing. It intercepts events from Stripe and forwards them directly to your local endpoint:

Terminal window
# Install
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward events to your local server
stripe listen --forward-to localhost:3000/webhooks/stripe

The CLI outputs a webhook signing secret (whsec_...). Use this in your local environment instead of the secret from the Stripe dashboard.

Trigger test events without touching the dashboard:

Terminal window
# Trigger a specific event
stripe trigger payment_intent.succeeded
# Trigger a checkout session completion
stripe trigger checkout.session.completed

This is the fastest way to test Stripe webhooks — no tunnel, no public URL, no manual event creation.

GitHub CLI

GitHub doesn’t have a dedicated webhook CLI, but you can use the gh CLI with the API to redeliver events:

Terminal window
# List recent webhook deliveries
gh api repos/OWNER/REPO/hooks/HOOK_ID/deliveries
# Redeliver a specific delivery
gh api repos/OWNER/REPO/hooks/HOOK_ID/deliveries/DELIVERY_ID/attempts -X POST

Alternatively, use smee.io — GitHub’s recommended proxy for webhook development:

Terminal window
# Install the smee client
npm install -g smee-client
# Create a channel at https://smee.io, then:
smee --url https://smee.io/YOUR_CHANNEL --target http://localhost:3000/webhooks/github

Shopify CLI

Terminal window
shopify app dev

The Shopify CLI automatically creates a tunnel and registers webhook URLs when running in development mode.

Method 6: Webhook.site and RequestBin — Capture and inspect

Sometimes you don’t need to test your handler — you just need to see what the provider sends. Capture services let you inspect webhook payloads without writing any code.

Webhook.site

Go to webhook.site. You get a unique URL immediately. Register it with your webhook provider, trigger an event, and see the full request — headers, body, query parameters, method.

What it’s good for:

  • Seeing exactly what a provider sends before you write any code
  • Debugging payload format issues
  • Verifying that a provider is actually sending events
  • Checking header names for signature verification

What it’s not good for:

  • Testing your actual handler code (the request goes to Webhook.site, not your server)

RequestBin (Pipedream)

RequestBin works similarly but integrates with Pipedream’s workflow engine. You can capture a webhook and then build a processing pipeline around it.

Beeceptor

Beeceptor adds mock response capabilities — you can define what status code and body to return for captured webhooks. Useful for testing how providers handle non-200 responses.

Using capture services with your local server

The power move: use a capture service to record a real webhook payload, then replay it locally with curl:

Terminal window
# 1. Register webhook.site URL with your provider
# 2. Trigger an event
# 3. Copy the payload from webhook.site
# 4. Replay it against your local server:
curl -X POST http://localhost:3000/webhooks/stripe \
-H "Content-Type: application/json" \
-H "Stripe-Signature: t=1234567890,v1=abc123..." \
-d '{
"id": "evt_test_123",
"type": "payment_intent.succeeded",
"data": {
"object": {
"id": "pi_test_456",
"amount": 2000,
"currency": "usd"
}
}
}'

This approach is great for building a library of test fixtures.

Beyond localhost: testing webhook handlers

The methods above solve the core problem — getting external webhook events to reach your local machine. The techniques below address a different concern: verifying that your webhook handler code behaves correctly. You don’t need a tunnel for these; they’re about testing your code in isolation.

Mock servers — Full control, no external dependencies

For automated testing and CI/CD, you don’t want to depend on tunnels or external services. Mock the webhook provider instead.

Build a simple webhook simulator

Create a script that sends the same payloads your provider sends:

Node.js:

webhook-simulator.js
import crypto from 'crypto';
const SECRET = 'whsec_test_secret';
const ENDPOINT = 'http://localhost:3000/webhooks/stripe';
const events = [
{
id: 'evt_1',
type: 'payment_intent.succeeded',
data: { object: { id: 'pi_123', amount: 5000, currency: 'usd' } },
},
{
id: 'evt_2',
type: 'customer.subscription.deleted',
data: { object: { id: 'sub_456', customer: 'cus_789' } },
},
];
for (const event of events) {
const body = JSON.stringify(event);
const timestamp = Math.floor(Date.now() / 1000);
const signature = crypto
.createHmac('sha256', SECRET)
.update(`${timestamp}.${body}`)
.digest('hex');
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Stripe-Signature': `t=${timestamp},v1=${signature}`,
},
body,
});
console.log(`${event.type}: ${res.status}`);
}

Python:

import hmac, hashlib, json, time, requests
SECRET = 'whsec_test_secret'
ENDPOINT = 'http://localhost:3000/webhooks/stripe'
events = [
{"id": "evt_1", "type": "payment_intent.succeeded",
"data": {"object": {"id": "pi_123", "amount": 5000}}},
{"id": "evt_2", "type": "invoice.payment_failed",
"data": {"object": {"id": "in_456", "customer": "cus_789"}}},
]
for event in events:
body = json.dumps(event)
timestamp = str(int(time.time()))
signature = hmac.new(
SECRET.encode(), f"{timestamp}.{body}".encode(), hashlib.sha256
).hexdigest()
r = requests.post(ENDPOINT, json=event, headers={
"Stripe-Signature": f"t={timestamp},v1={signature}",
})
print(f"{event['type']}: {r.status_code}")

Using WireMock for complex scenarios

For more sophisticated mocking (sequence-dependent events, conditional responses, stateful scenarios), WireMock is purpose-built:

{
"request": {
"method": "POST",
"url": "/webhooks/orders"
},
"response": {
"status": 200,
"jsonBody": { "received": true }
}
}

WireMock can simulate delays, timeouts, and error responses — useful for testing how your handler behaves under degraded conditions.

Automated tests — The foundation

Tunnels and capture services are great for development, but your test suite is the most reliable way to verify webhook handling. Write tests that exercise your handler with realistic payloads.

Unit testing a webhook handler

Node.js (Jest/Vitest):

import { describe, it, expect } from 'vitest';
import crypto from 'crypto';
import app from '../app';
import request from 'supertest';
const SECRET = 'whsec_test_secret';
function signPayload(body, secret) {
const timestamp = Math.floor(Date.now() / 1000);
const signature = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex');
return `t=${timestamp},v1=${signature}`;
}
describe('POST /webhooks/stripe', () => {
it('processes a valid payment event', async () => {
const body = JSON.stringify({
id: 'evt_test_123',
type: 'payment_intent.succeeded',
data: { object: { id: 'pi_456', amount: 2000 } },
});
const res = await request(app)
.post('/webhooks/stripe')
.set('Content-Type', 'application/json')
.set('Stripe-Signature', signPayload(body, SECRET))
.send(body);
expect(res.status).toBe(200);
});
it('rejects requests with invalid signatures', async () => {
const res = await request(app)
.post('/webhooks/stripe')
.set('Content-Type', 'application/json')
.set('Stripe-Signature', 't=123,v1=invalid')
.send('{"type":"payment_intent.succeeded"}');
expect(res.status).toBe(401);
});
it('handles duplicate events idempotently', async () => {
const body = JSON.stringify({
id: 'evt_duplicate_test',
type: 'payment_intent.succeeded',
data: { object: { id: 'pi_789', amount: 3000 } },
});
const sig = signPayload(body, SECRET);
// Send the same event twice
await request(app)
.post('/webhooks/stripe')
.set('Content-Type', 'application/json')
.set('Stripe-Signature', sig)
.send(body);
const res = await request(app)
.post('/webhooks/stripe')
.set('Content-Type', 'application/json')
.set('Stripe-Signature', sig)
.send(body);
expect(res.status).toBe(200);
// Verify the payment was only processed once
});
});

PHP (PHPUnit / Laravel):

class StripeWebhookTest extends TestCase
{
public function test_valid_payment_event_is_processed(): void
{
$payload = json_encode([
'id' => 'evt_test_123',
'type' => 'payment_intent.succeeded',
'data' => ['object' => ['id' => 'pi_456', 'amount' => 2000]],
]);
$timestamp = time();
$signature = hash_hmac('sha256', "{$timestamp}.{$payload}", config('services.stripe.webhook_secret'));
$this->postJson('/webhooks/stripe', json_decode($payload, true), [
'Stripe-Signature' => "t={$timestamp},v1={$signature}",
])->assertOk();
}
public function test_invalid_signature_returns_401(): void
{
$this->postJson('/webhooks/stripe', ['type' => 'test'], [
'Stripe-Signature' => 't=123,v1=invalid',
])->assertUnauthorized();
}
}

Python (pytest):

import hmac, hashlib, json, time
def sign_payload(body, secret):
timestamp = str(int(time.time()))
sig = hmac.new(
secret.encode(), f"{timestamp}.{body}".encode(), hashlib.sha256
).hexdigest()
return f"t={timestamp},v1={sig}"
def test_valid_webhook(client):
body = json.dumps({"id": "evt_1", "type": "payment_intent.succeeded",
"data": {"object": {"id": "pi_123"}}})
sig = sign_payload(body, "whsec_test")
res = client.post("/webhooks/stripe",
data=body,
content_type="application/json",
headers={"Stripe-Signature": sig})
assert res.status_code == 200
def test_invalid_signature(client):
res = client.post("/webhooks/stripe",
data='{"type":"test"}',
content_type="application/json",
headers={"Stripe-Signature": "t=0,v1=bad"})
assert res.status_code == 401

What to test

Test caseWhy it matters
Valid signature → 200Core functionality works
Invalid signature → 401Rejects forged requests
Missing signature → 401Doesn’t skip verification
Old timestamp → 401Replay protection works
Duplicate event ID → 200 (no reprocessing)Idempotency works
Unknown event type → 200 (ignored)Doesn’t break on new events
Malformed JSON → 400Handles bad input gracefully
Large payload → 200 or 413Size limits enforced
Timeout simulation → 200 (queued)Async processing works

Tool comparison

Here’s a side-by-side comparison of the tools and methods covered:

ToolTypeFree tierSetup timeBest for
ngrokTunnel20 conn/min, random URL2 minGeneral-purpose local testing
VS Code port forwardingTunnelUnlimited1 minAlready in VS Code, zero install
Cloudflare TunnelTunnelUnlimited3 minLong sessions, corporate networks
localtunnelTunnelUnlimited, OSS30 secQuick one-off testing
Stripe CLIProvider CLIFull access2 minStripe-specific development
smee.ioProxyUnlimited1 minGitHub webhook development
Webhook.siteCapture500 req/day0 minInspecting payloads
RequestBinCaptureLimited0 minCapture + workflow automation
BeeceptorCapture + Mock50 req/day0 minCustom mock responses

Security considerations when testing

Exposing your local machine to the internet — even temporarily — introduces risk. Follow these rules:

  1. Never use tunnels in production. They’re for development only.
  2. Use HTTPS tunnels. ngrok and Cloudflare Tunnel provide this by default. Don’t use plain HTTP tunnels for anything involving real credentials.
  3. Don’t leave tunnels running. Close them when you’re done testing. An open tunnel is an open door.
  4. Use test/sandbox environments. Never point production webhook subscriptions at a tunnel URL. Use the provider’s test mode.
  5. Rotate secrets after testing. If you used real signing secrets during development, rotate them before deploying.
  6. Be careful with logs. Tunnel inspection tools show full request bodies. If you’re testing with real data, those bodies may contain PII.

Testing webhooks in CI/CD

For automated pipelines, tunnels don’t work. Use mock servers or direct HTTP calls instead:

# GitHub Actions example
name: Test Webhooks
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm install
- run: npm run dev &
- run: sleep 3 # Wait for server to start
- name: Run webhook tests
run: npm test -- --grep "webhook"
env:
WEBHOOK_SECRET: whsec_test_secret_for_ci

The key insight: in CI, your tests should send webhook requests to your running server, not receive them from external providers. This makes tests deterministic and fast.

Debugging common issues

Webhook arrives but signature verification fails

Cause: Your framework parsed the JSON body before you read the raw bytes. The re-serialized JSON has different formatting.

Fix: Capture the raw body before any middleware processes it:

// Express.js — capture raw body
app.use('/webhooks', express.raw({ type: 'application/json' }));
// Laravel — get raw body
$payload = request()->getContent(); // Not request()->all()

Provider says delivery failed, but your server shows no request

Cause: Your tunnel URL changed (ngrok free tier), the tunnel isn’t running, or a firewall is blocking the connection.

Fix: Check that the tunnel is active and the URL matches what’s registered with the provider. Use the tunnel’s inspector to see if requests are arriving.

Events arrive out of order

Cause: Webhooks are not guaranteed to arrive in order. A payment_intent.succeeded might arrive before payment_intent.created.

Fix: Design your handler to be order-independent. If you need ordering, fetch the current state from the provider’s API rather than relying on webhook sequence.

Duplicate events in development

Cause: The provider retried because your handler took too long to respond, or you registered the same webhook URL multiple times.

Fix: Return 200 immediately and process asynchronously. Check for duplicate webhook registrations in the provider dashboard.

  1. Start with Webhook.site — register the URL with your provider and trigger a real event. Inspect the payload format, headers, and signature scheme before writing any code.
  2. Build your handler using the captured payload as a reference. Write unit tests with that payload as a fixture.
  3. Use a provider CLI if available (Stripe CLI, Shopify CLI) — they’re faster and more reliable than tunnels for provider-specific testing.
  4. Use ngrok or Cloudflare Tunnel for integration testing — verify end-to-end behavior with real events hitting your local server.
  5. Add CI tests with mock payloads that cover the test matrix above.

This catches bugs at each level before they compound.

Frequently asked questions

How do I test webhooks on localhost?

You need to expose your local server to the internet using a tunneling tool. The most common options are ngrok (industry standard, free tier available), VS Code port forwarding (built-in, zero install), Cloudflare Tunnel (free, no rate limits), and localtunnel (open source, zero config). Start a tunnel to your local port and use the generated public URL as your webhook endpoint in the provider's dashboard.

What is the best free tool for testing webhooks?

For tunneling, Cloudflare Tunnel is the best free option — it has no rate limits or connection restrictions. For quick payload inspection without running a server, Webhook.site gives you a URL instantly with no signup. For Stripe specifically, the Stripe CLI is the best tool regardless of price — it forwards events directly to your local server without a tunnel.

Can I test webhooks without ngrok?

Yes. Alternatives include VS Code port forwarding (built in, no install), Cloudflare Tunnel (free, no rate limits), localtunnel (open source), provider-specific CLIs (Stripe CLI, Shopify CLI), and capture services (Webhook.site, RequestBin). For automated testing in CI/CD, use mock payloads and direct HTTP calls rather than any tunneling tool.

How do I test webhook signatures locally?

Use your webhook provider's test/sandbox mode to send real signed events through a tunnel (ngrok or Cloudflare Tunnel). For unit tests, compute the HMAC-SHA256 signature yourself using the test signing secret and include it in the request header. The Stripe CLI automatically provides a test signing secret when forwarding events. You can also use an online HMAC signature verifier to generate test signatures.

Why is my webhook not reaching localhost?

Your local machine has a private IP address that external services cannot reach. You need a tunneling tool (ngrok, VS Code port forwarding, Cloudflare Tunnel, localtunnel) to create a public URL that forwards traffic to your local server. Common issues include: the tunnel URL changed after restart (ngrok free tier), the tunnel process isn't running, a firewall is blocking the connection, or the webhook URL registered with the provider doesn't match the current tunnel URL.

How do I test webhooks in CI/CD pipelines?

In CI/CD, don't use tunnels. Instead, start your application server in the pipeline, then run tests that send HTTP POST requests directly to it with mock webhook payloads. Include proper HMAC signatures computed with a test secret. This approach is deterministic, fast, and doesn't depend on external services. Tools like WireMock can simulate complex webhook scenarios including delays and error responses.


Stop managing infrastructure. Start scheduling jobs.

Recuro handles cron scheduling, retries, alerts, and execution logs — so you can focus on building your product.

No credit card required