How to Test Webhooks Locally — 5 Methods Compared (2026 Guide)
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.
- Why Webhook Testing Is Hard (The Localhost Problem)
- 5 Methods to Test Webhooks Locally
- Method 1: ngrok
- Method 2: Cloudflare Tunnel
- Method 3: localtunnel
- Method 4: VS Code Port Forwarding
- Method 5: Request Bin (InvokeBot / webhook.site)
- Side-by-Side Comparison
- Debugging Common Webhook Failures
- Security Best Practices
- Testing Patterns: Replay, Record, Mock
- Interactive Webhook Payload Builder
- 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:
- NAT (Network Address Translation) — Your router assigns private IP addresses (192.168.x.x) to devices on your local network. The outside world cannot route packets to those addresses.
- Firewalls — Your OS and network firewall block unsolicited incoming connections by default. Even if NAT weren't an issue, the request would likely be dropped.
- DNS resolution —
localhostand127.0.0.1resolve to the machine that runs the lookup. When Stripe resolveslocalhost, it points to Stripe's own server, not yours. - HTTPS requirements — Most providers require webhook endpoints to use HTTPS. Self-signed certificates on localhost get rejected.
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:
- Extract the signature from the request header.
- Compute HMAC-SHA256 of the raw request body using your webhook secret.
- 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
- Timestamp validation: Reject events older than 5 minutes to prevent replay attacks. Stripe includes a timestamp in the
Stripe-Signatureheader for this purpose. - HTTPS only: Never accept webhooks over plain HTTP in production. All tunnel tools in this guide provide HTTPS by default.
- Rate limiting: Protect your webhook endpoint from abuse. A legitimate provider sends events at a known rate; sudden spikes likely indicate an attack.
- Secrets rotation: Rotate your webhook signing secrets periodically. Most providers let you have two active secrets during rotation so you do not miss events.
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.
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.