How-To Guide

How to Debug Webhooks Without ngrok: Free Tools and Better Alternatives

ngrok is not the only way to receive and inspect incoming webhooks during development. This guide covers free browser-based alternatives, what headers and signatures to inspect, and how to replay payloads for faster debugging cycles.

Webhooks are fundamentally harder to debug than REST APIs. With a REST call, you initiate the request — you control the timing, you can retry it, you can inspect the full round-trip in a browser's network tab. With webhooks, the third-party service initiates the request. Your server is the receiver.

That asymmetry creates a specific problem during development: your localhost:3000 is not reachable from Stripe's servers, GitHub's push event system, or Shopify's order hooks.

The traditional solution is ngrok — a tunneling tool that creates a public URL forwarding to your local machine. It works, but it has friction: an account, a client binary, expiring URLs on the free tier, and a rotating domain that breaks any webhook registration that hardcodes the URL.

This guide covers the alternatives.

What You Actually Need When Debugging a Webhook

Before choosing a tool, get specific about what you're trying to do:

Scenario A: Inspect the raw payload You want to see exactly what headers, body, and HTTP method a service sends. You don't need to process it yet — you just need to capture it.

Scenario B: Test your handler locally You have a webhook handler at localhost:3000/webhook. You want to receive a real payload from Stripe/GitHub and run your actual code against it.

Scenario C: Replay a specific event Something broke in production. You want to replay the exact payload that caused the failure against a local or staging handler.

These are different problems requiring different tools.

Option 1: Browser-Based Webhook Inspector (No Install)

For Scenario A — inspecting raw payloads — a browser-based webhook inspector is the fastest path. Generate a unique URL, configure it as your webhook endpoint in the external service, and watch requests appear in real-time.

DeveloperLab's Webhook Tester does this without any account or install:

  1. Click Generate Temporary Webhook URL — you get a URL like https://developerlab.dev/bucket/abc123xyz
  2. Paste that URL into your service's webhook settings (Stripe, GitHub, Shopify, etc.)
  3. Trigger an event (complete a test payment, push a commit, create an order)
  4. The full request appears in your browser: HTTP method, all headers, complete JSON body, query params, response time

The URL stays active for 4 hours and accepts GET, POST, PUT, PATCH, and DELETE. No tunnel, no binary, no expiring token.

What to look at first when a payload arrives:

Option 2: Local Tunnel with a Persistent Domain

When you need your actual application code to run against the payload (Scenario B), you need a publicly accessible URL that forwards to localhost.

Cloudflare Tunnel (free, no account required for quick tunnels)

# One-liner — no binary install required
npx cloudflared tunnel --url http://localhost:3000

You get a public trycloudflare.com URL that forwards to port 3000. It's free, doesn't require a Cloudflare account, and the URL is stable for the session.

For a persistent named tunnel (free Cloudflare account required):

cloudflared tunnel create my-webhook-tunnel
cloudflared tunnel route dns my-webhook-tunnel webhooks.yourdomain.com
cloudflared tunnel run my-webhook-tunnel

This gives you a stable, permanently named URL — ideal for webhooks in staging environments where rotating URLs would require re-registration.

Localtunnel (open source)

npx localtunnel --port 3000 --subdomain myapp-webhooks
# → https://myapp-webhooks.loca.lt

Fully open source. Subdomain is first-come-first-served, so it may be taken. Less reliable uptime than Cloudflare Tunnel.

Smee.io (GitHub-specific)

If you're debugging GitHub webhooks specifically, smee.io is purpose-built for it:

npm install -g smee-client
smee --url https://smee.io/your-channel --path /webhook --port 3000

Smee acts as a relay — events are buffered and replayed, so you can restart your local server without missing events.

Option 3: Replay with Saved Payloads

For Scenario C — replaying specific events — the best approach is to capture the raw payload once, save it, and use curl or a test helper to replay it against your handler.

Step 1: Capture the real payload

Use a webhook inspector (Option 1) to capture the exact headers and body of a real event. Copy the raw JSON body and save it to a file:

# stripe-payment-success.json
{
  "id": "evt_1234",
  "type": "payment_intent.succeeded",
  "data": {
    "object": {
      "amount": 4200,
      "currency": "usd",
      "status": "succeeded"
    }
  }
}

Step 2: Replay against your local handler

curl -X POST http://localhost:3000/webhook/stripe \
  -H "Content-Type: application/json" \
  -H "Stripe-Signature: t=1720000000,v1=fake-sig-for-testing" \
  -d @stripe-payment-success.json

For testing with real signature verification disabled, you can temporarily accept all signatures in test mode. For testing with real signatures, Stripe's stripe-cli can generate valid test signatures.

Using stripe-cli for Stripe webhooks

# Install once
brew install stripe/stripe-cli/stripe

# Forward to local server with real signature injection
stripe listen --forward-to localhost:3000/webhook/stripe

# Trigger specific events
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.deleted

The Stripe CLI injects real Stripe-Signature headers, so your HMAC verification code runs as it would in production.

Inspecting Webhook Signatures

Most services sign their payloads using HMAC-SHA256. The signature is in a request header. Here's how the major services format theirs:

Service Header Format
Stripe Stripe-Signature t=timestamp,v1=signature
GitHub X-Hub-Signature-256 sha256=signature
Shopify X-Shopify-Hmac-Sha256 Base64-encoded signature
Twilio X-Twilio-Signature Base64-encoded HMAC
Slack X-Slack-Signature v0=signature

A robust Node.js signature verifier for GitHub:

import { createHmac, timingSafeEqual } from 'crypto';

export function verifyGitHubSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const expected = `sha256=${createHmac('sha256', secret).update(payload).digest('hex')}`;
  // timingSafeEqual prevents timing attacks
  return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

// In your Express handler:
app.post('/webhook/github', express.raw({ type: '*/*' }), (req, res) => {
  const sig = req.headers['x-hub-signature-256'] as string;
  const body = req.body.toString();

  if (!verifyGitHubSignature(body, sig, process.env.GITHUB_WEBHOOK_SECRET!)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(body);
  // process event...
  res.status(200).json({ ok: true });
});

Important: Use express.raw() rather than express.json() for webhook handlers. HMAC is computed over the raw request body bytes. If Express parses and re-serializes the JSON, the bytes change and signature verification fails.

Debugging Checklist

When a webhook isn't arriving or is failing verification, work through this list:

Not arriving at all:

Arriving but signature fails:

Arriving but payload looks wrong:

Storing Payloads for Future Replay

In staging environments, it's worth logging every incoming webhook payload to a database or structured log before processing it. This gives you a permanent replay library:

app.post('/webhook/:provider', express.raw({ type: '*/*' }), async (req, res) => {
  // 1. Log raw payload first
  await db.webhookLog.create({
    provider: req.params.provider,
    headers: JSON.stringify(req.headers),
    body: req.body.toString(),
    receivedAt: new Date(),
  });

  // 2. Then process
  await processWebhook(req);
  res.status(200).json({ ok: true });
});

Combine this with a simple admin endpoint to replay any stored event against your current handler code — you've built a lightweight webhook replay system without any external tooling.