All articles

how-to

How to Test Webhooks Locally Without ngrok

Skip ngrok. Learn practical techniques to test webhooks locally: localhost tunneling alternatives, request inspection, signature verification, and rep

How to Test Webhooks Locally Without ngrok

The ngrok Problem

If you've built webhook handlers, you know the drill: your service provider sends HTTP POST requests to a public URL. To test locally, most developers reach for ngrok. It works—until it doesn't. Ngrok's free tier throttles bandwidth, paid plans are pricey for a dev tool, and the tunnel URL changes on restart, breaking your provider's webhook configuration.

There's a better way. This article covers practical alternatives to test webhooks locally, from simple request inspection to stable endpoint URLs that survive restarts.

Why Localhost Testing Matters

Webhooks are asynchronous and stateful. You can't easily reproduce them in a unit test:

  • The provider (Stripe, GitHub, Shopify) controls the timing and payload.
  • Signature verification requires the provider's secret.
  • Replay logic, retry behavior, and idempotency are hard to mock convincingly.

Local testing lets you:

  • Inspect the raw request body and headers before parsing.
  • Debug signature verification without guessing.
  • Test error handling: what happens if your handler times out or crashes?
  • Iterate fast without deploying to staging.

Approach 1: Request Inspection with a Local Server

Start simple. Run a basic HTTP server on localhost and log everything:

const http = require('http');
const crypto = require('crypto');

const server = http.createServer((req, res) => {
  if (req.method !== 'POST') {
    res.writeHead(404);
    res.end();
    return;
  }

  let body = '';
  req.on('data', chunk => {
    body += chunk.toString();
  });

  req.on('end', () => {
    console.log('\n=== Webhook Received ===');
    console.log('Headers:', JSON.stringify(req.headers, null, 2));
    console.log('Body:', body);
    
    // Example: Stripe signature verification
    const signature = req.headers['stripe-signature'];
    const secret = process.env.STRIPE_WEBHOOK_SECRET;
    if (signature && secret) {
      const timestamp = signature.split(',')[0].split('=')[1];
      const signed = crypto
        .createHmac('sha256', secret)
        .update(`${timestamp}.${body}`)
        .digest('hex');
      console.log('Signature valid:', signature.includes(signed));
    }
    
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ received: true }));
  });
});

server.listen(3000, () => {
  console.log('Webhook server running on http://localhost:3000');
});

This works for quick debugging, but you still need a way to expose localhost to the internet. That's where tunneling comes in.

Approach 2: Lightweight Tunneling Alternatives

Using SSH Reverse Tunnel

If you have a VPS or cloud server with a public IP, SSH reverse tunneling is free and reliable:

ssh -R 8080:localhost:3000 user@your-server.com

This binds port 8080 on your server to your local port 3000. Configure your webhook provider to send to https://your-server.com:8080/webhook. The tunnel survives network changes and doesn't require third-party accounts.

Trade-off: you need a server. Not ideal for quick testing.

Using Cloudflare Tunnel

Cloudflare's tunnel (formerly Argo) is free and stable:

npm install -g @cloudflare/wrangler
wrangler tunnel --url http://localhost:3000

You get a stable URL like https://abc123.trycloudflare.com. It persists across restarts and requires no payment. The downside: the URL is random unless you authenticate, and Cloudflare's tunnel is designed for production traffic, not high-frequency webhook testing.

Approach 3: Stable Local Endpoints with Anonymily

For a workflow that's both stable and inspector-friendly, use Anonymily, a webhook inspector built for local testing.

Start the listener:

npx @anonymilyhq/cli listen 3000

You'll get a stable endpoint URL like https://api.anonymily.com/h/stripe-prod. Configure your webhook provider to send to that URL. Anonymily captures requests in the cloud and forwards them 1:1 to localhost over Server-Sent Events. Your local handler runs as-is—no code changes.

Key advantages:

  • Stable URL: survives localhost restarts and redeploys. Reconfigure once, test forever.
  • Captures offline: if localhost is down, Anonymily buffers requests. They're waiting when you restart.
  • Request replay: resend any webhook without re-triggering the provider.
  • Signature verification: Pro plan includes a helper to verify HMAC signatures.
  • Synthetic events: Pro plan can generate provider-signed test events (GitHub, Stripe, Shopify, Razorpay, etc.) without hitting the actual provider.

Free tier: 1 named endpoint, 200 requests per hook, 48 hours history. Pro ($9/month): 2000 requests, 30 days history, modify-and-replay, synthetic events.

Honest caveat: Anonymily is a dev/test tool, not a production event gateway. For production receive-at-scale with retries and SLAs, use Hookdeck or Svix.

Signature Verification Best Practices

Whichever method you choose, always verify signatures locally. Providers sign requests so you know they're legitimate.

General pattern:

  1. Extract the signature from headers (e.g., X-Signature, Stripe-Signature).
  2. Reconstruct the signed content (usually timestamp + raw body).
  3. Compute HMAC-SHA256 using your webhook secret.
  4. Compare constant-time.
const crypto = require('crypto');

function verifySignature(rawBody, signature, secret) {
  const computed = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  
  // Constant-time comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(computed)
  );
}

Critical: use the raw body, not the parsed JSON. Parsing and re-stringifying changes whitespace and breaks the signature.

Testing Retry Logic

Webhook providers retry on failure. Test this locally:

  1. Intentional failure: return 500 or timeout on first request, 200 on retry.
  2. Idempotency: ensure your handler is idempotent (safe to run twice). Use an idempotency key or database unique constraint.
  3. Replay: use your tool's replay feature to simulate retries without waiting for the provider's retry schedule.

With Anonymily's replay feature, you can resend any captured webhook instantly, making retry testing fast.

Debugging Common Issues

Signature verification fails: ensure you're using the raw request body, not parsed JSON. Check that your secret is correct and hasn't been rotated.

Webhook not arriving: confirm the endpoint URL is correct and publicly accessible. Check your provider's webhook logs for delivery status and error messages.

Timeout or 502: your handler is too slow. Webhook providers expect a 200 response within seconds. Move heavy work to a background job.

Duplicate processing: webhooks can be delivered twice. Always implement idempotency.

Local Testing Workflow Summary

  1. Start your handler on localhost:3000.
  2. Expose it via SSH tunnel, Cloudflare, or Anonymily.
  3. Configure the webhook URL at your provider.
  4. Trigger a test event (or wait for a real one).
  5. Inspect the request (raw body, headers, signature).
  6. Verify your handler logic (logging, database writes, side effects).
  7. Replay and iterate until it's correct.

For frequent testing, a stable endpoint (Anonymily or Cloudflare) saves time. For one-off debugging, a simple localhost server and temporary tunnel are fine.

Next Steps

If you're tired of ngrok's limitations, try a stable local endpoint. Start with:

npx @anonymilyhq/cli listen 3000

You'll get a persistent webhook URL that works across restarts. Inspect, replay, and debug webhooks without friction. For more details and to explore synthetic events and signature helpers, visit https://anonymily.com.

Try it in 30 seconds

Capture your first webhook — from any provider — with one command. No account required.

npx @anonymilyhq/cli listen 3000Open Dashboard →