Skip to main content

Api

Test webhooks locally without ngrok (or any tunnel)

Your tunnel URL rotated again, the provider dashboard has a stale endpoint, and you still haven't tested the retry path. You don't need webhook delivery to your laptop — you need the delivery lifecycle in a sandbox.

FetchSandbox EngineeringFetchSandbox Engineering

Every guide to testing webhooks locally starts the same way: install ngrok, expose port 3000, paste the tunnel URL into the provider dashboard. Then the session expires, the URL rotates, the dashboard points at a dead endpoint, and you're re-pasting URLs instead of testing your handler. If you're behind a corporate proxy that blocks tunnels, you never even get that far.

Here's the part those guides skip: the tunnel was never the point. You don't need webhook delivery to reach your laptop. You need to know that your handler does the right thing for every event the provider will send — including the ones a tunnel demo never produces.

What the tunnel actually tests

When ngrok works perfectly, what have you verified? That one hand-triggered event, in the shape you expected, reaches your handler once. That's it.

The failures that reach production are a different family:

  • the retry — the provider redelivers the same event because your 200 was slow, and your handler processes it twice
  • the out-of-order pair — subscription.activated arrives before subscription.created, and your upsert logic writes garbage
  • the missed event — delivery fails while your dev server was restarting, and nothing reconciles the gap
  • the shape you didn't send yourself — the multipart payload, the null field, the event type you didn't subscribe to in dev

None of these get easier with a better tunnel. localtunnel, cloudflared, ssh -R — every "ngrok alternative" article swaps one delivery pipe for another and leaves the actual test coverage untouched.

Run the lifecycle instead

A stateful sandbox flips the problem. Instead of making the provider reach your machine, you run the provider's event lifecycle — creation, delivery, retry, failure — against your handler logic, from your IDE, with no public URL at all.

With the FetchSandbox MCP wired into Cursor or Claude Code, that's one prompt:

./fetchsandbox run the stripe checkout workflow and deliver
charge.succeeded to my handler twice — verify exactly-once fulfillment

The sandbox fires the events in provider-realistic shapes, replays the retry, and hands back a receipt URL — an openable run trace showing what was delivered, in what order, and what your handler did with it. Not a green checkmark in your terminal; a trace you can put in the PR.

No account with the provider. No API keys. No tunnel daemon eating a terminal tab.

When you still want a tunnel

Real-device OAuth callbacks and third-party dashboard verification pings genuinely need a public URL — keep ngrok around for those. But "does my handler survive what this provider will actually send" is a lifecycle question, not a networking question. Test it where the lifecycle lives.

Start with the webhook sandbox, or see how the same approach catches the retry bug code review misses and GitHub webhooks that disappear.