DEV Community

Henry Hang
Henry Hang

Posted on • Originally published at hookcap.dev

How to Test Webhooks Locally: The Complete Guide

Every webhook integration hits the same wall during development: the service sending webhooks needs a public HTTPS URL, but your code is running on localhost:3000. Stripe, GitHub, Shopify, Slack — none of them can reach your laptop.

This guide covers every practical approach to solving this, from quick tunnel setups to persistent capture-and-forward workflows.

Originally published at hookcap.dev

The Core Problem

Webhooks are HTTP callbacks. When an event occurs (a payment succeeds, a pull request is opened, an order is placed), the service sends an HTTP POST request to a URL you've configured. That URL must be:

  1. Publicly reachable — the service's servers need to connect to it over the internet
  2. HTTPS — most webhook providers require TLS
  3. Responsive — most providers expect a 2xx response within 3-30 seconds

Your local development server meets none of these requirements.

Approach 1: Tunnel Tools (ngrok, Cloudflare Tunnel, localtunnel)

The most common approach: run a tunnel that creates a public URL pointing to your local server.

ngrok

brew install ngrok
ngrok http 3000
Enter fullscreen mode Exit fullscreen mode

ngrok gives you a URL like https://a1b2c3d4.ngrok-free.app that forwards traffic to localhost:3000.

Pros:

  • Fast setup — running in under a minute
  • Supports HTTPS out of the box
  • Built-in request inspector at http://127.0.0.1:4040

Cons:

  • URL changes every time you restart (free tier)
  • No persistent event history — if your server is down when the webhook fires, the event is lost
  • Your local server must be running to receive events
  • Free tier has rate limits

Cloudflare Tunnel

brew install cloudflared
cloudflared tunnel --url http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

Pros: Free, no account required. Cloudflare's network is fast.

Cons: Same fundamental limitations as ngrok.

localtunnel

npm install -g localtunnel
lt --port 3000
Enter fullscreen mode Exit fullscreen mode

Open source, free, but less reliable than ngrok or Cloudflare.

When tunnels work well

Tunnels are right when you need something working in 5 minutes or doing a one-off test. They become painful when you restart your tunnel and have to update webhook URLs in every service, or when you miss events because your laptop went to sleep.

Approach 2: Mock Webhook Servers

Manual curl

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": "checkout.session.completed",
    "data": { "object": { "amount_total": 2000 } }
  }'
Enter fullscreen mode Exit fullscreen mode

Simple but error-prone — real webhook payloads have dozens of fields.

Provider CLI tools

Stripe CLI:

stripe listen --forward-to localhost:3000/webhooks/stripe
stripe trigger checkout.session.completed
Enter fullscreen mode Exit fullscreen mode

Generates realistic payloads with valid signatures — but only for Stripe. You need a different tool for every provider.

When mocks work well

Good for unit testing individual handler functions. Not a substitute for real webhook deliveries, because real payloads have fields you wouldn't think to include.

Approach 3: Capture and Forward (HookCap)

A different model: use a persistent capture endpoint that records every webhook delivery and optionally forwards it to your local server.

How it works

  1. Create a HookCap endpoint — permanent HTTPS URL like https://hook.hookcap.dev/ep_a1b2c3d4e5f6
  2. Register that URL with your webhook provider (once)
  3. Events are captured — every delivery stored with full headers, body, metadata
  4. Auto-forward to localhost — events forwarded to your local dev server (Pro plan)
  5. Replay any event — one click, any time
Stripe/GitHub/Slack
        │
        ▼
   HookCap endpoint (https://hook.hookcap.dev/ep_...)
        │
        ├── Captures & stores full request
        │
        └── Auto-forwards to localhost:3000/webhooks
Enter fullscreen mode Exit fullscreen mode

Setting up HookCap

  1. Sign in at hookcap.dev and create an endpoint
  2. Register the endpoint URL with your webhook provider
  3. Configure auto-forward (Pro) to your local server: http://localhost:3000/webhooks/stripe
  4. Trigger an event
  5. Event appears in HookCap dashboard and is forwarded to your local server

Why this is different

Your URL never changes. Register once, works indefinitely.

Events are never lost. Server down? Events still captured, replay later.

Replay without re-triggering. No need to make another test payment or push another commit.

Works with every provider. One tool covers Stripe, GitHub, Shopify, Slack.

Comparing the Approaches

Tunnel (ngrok) Mock/CLI HookCap Auto-Forward
Setup time ~1 min Varies ~2 min
Persistent URL Paid only N/A Always
Event history No No Yes
Works when server is off No N/A Yes (captures + replay)
Replay events No Manual One click
Multi-provider Separate tunnel Separate CLI Single endpoint
Cost Free limited; $8+/mo Free Free tier; Pro $12/mo

Practical Workflows

Workflow 1: Building a new webhook handler

  1. Create a HookCap endpoint and register it in Stripe
  2. Make a test payment
  3. Inspect the captured event — see the exact payload structure
  4. Write your handler based on the real payload (docs sometimes lag)
  5. Enable auto-forward, replay to test
  6. Iterate: modify handler → replay → check response

Workflow 2: Debugging a failing handler

  1. Find the failing event in HookCap history
  2. Replay it to your local server with the debugger attached
  3. Step through with the exact payload that caused the failure
  4. Fix, replay, verify, deploy

Workflow 3: Testing idempotency

  1. Capture a payment_intent.succeeded event
  2. Replay it — first run creates the record
  3. Replay again — should detect duplicate and skip
  4. Check database: only one record

Workflow 4: Multi-provider integration testing

  1. Create three HookCap endpoints (one per provider)
  2. Register each in Stripe, GitHub, Slack
  3. Auto-forward to /webhooks/stripe, /webhooks/github, /webhooks/slack
  4. Test the full flow end-to-end

Signature Verification During Local Testing

Most providers sign their payloads. When replaying from HookCap, original headers are preserved — signatures still valid.

Timestamp staleness: Stripe and Slack reject events older than 5 minutes. For development, extend the tolerance:

if (process.env.NODE_ENV === "development") {
  // 5-day tolerance for replayed events
  event = stripe.webhooks.constructEvent(body, signature, secret, 432000);
} else {
  event = stripe.webhooks.constructEvent(body, signature, secret);
}
Enter fullscreen mode Exit fullscreen mode

Common Mistakes

Not preserving raw request body: Signature verification needs the raw bytes. Mount webhook routes before the JSON parser:

app.post("/webhooks/stripe",
  express.raw({ type: "application/json" }),
  stripeWebhookHandler
);
app.use(express.json()); // after webhook routes
Enter fullscreen mode Exit fullscreen mode

Assuming delivery order: Providers don't guarantee order. Build order-independent handlers.

Not handling retries: A non-2xx response triggers retries for hours. Fix bugs, then process the retry queue.

Testing only the happy path: Use HookCap's history to find real edge cases — null fields, unexpected structures, API version changes.

Getting Started

  1. Sign up at hookcap.dev — free tier available
  2. Create an endpoint
  3. Register it with your webhook provider
  4. Trigger an event and inspect it
  5. Use one-click replay to test your handler
  6. Upgrade to Pro ($12/month) for auto-forward and longer retention

Your HookCap URL is permanent. Register it once, use it for the entire development lifecycle.


Free tier at hookcap.dev — capture, inspect, replay, real-time streaming. Pro $12/month for auto-forward to localhost.

Top comments (0)