All posts

Test webhooks locally without ngrok

Evan4 min read

cliwebhookslocal-development

The local webhook development loop is famously bad. You need a public URL, so you start a tunnel. You paste the tunnel URL into a provider's settings page. You trigger a real event — push a commit, cancel a test subscription — then squint at a raw payload, fix your handler, and go find the provider's redelivery button to try again. When your tunnel restarts and the URL changes, you do the paste dance again.

A generic tunnel solves exactly one of those problems: the URL. Everything else — signature verification, filtering noise, replaying events, keeping a record of what your handler returned — is still on you.

dispatch listen is our answer to the rest of it. Dispatch already receives your webhooks at a stable endpoint, verifies their signatures, and evaluates your filters server-side. The CLI just extends that pipeline one hop further: to a port on your laptop.

The 60-second version

Install the CLI (Node 18+; it downloads a prebuilt native binary for your platform):

npm install -g @dispatch.tech/cli

Log in, then start listening:

dispatch login
dispatch listen 3000

With no source specified, listen walks you through an interactive picker — organization, project, source, then optional extras we'll get to below. Already know the source? Name it to skip that step, and add --project to skip the org and project steps too:

dispatch listen 3000 my-github-source

Either way you end up here:

  Listening on  my-github-source → localhost:3000/

  Connected.

  → POST  push          200  42ms
  → POST  pull_request  200  11ms
  → POST  push          502  88ms  upstream returned 502

Each line is one delivery: method, event type, the status code your local server returned, and latency. If your server isn't running, you see the connection error instead of a status.

Your webhook URL never changes — providers keep pointing at your Dispatch endpoint. Where events go (production destination, your laptop, both) is configuration, not URL surgery.

What just happened

When an event arrives while you're listening:

  1. The provider POSTs to your Dispatch endpoint URL, same as always.
  2. Dispatch verifies the signature and stores the event.
  3. Your filters run server-side. Filtered events are never forwarded.
  4. The event streams over a WebSocket to your dispatch listen session, which POSTs it to localhost:3000 (or wherever) with the original headers and body.
  5. Your server's response — status, headers, body, latency — is recorded as a delivery attempt, right next to your production deliveries in the dashboard.

That last step matters more than it sounds: your local debugging session has the same paper trail as production. "What did my handler return for that event at 4:52?" is a dashboard lookup, not scrollback archaeology.

The parts a plain tunnel doesn't do

Replay is one keypress. Hit r and the last event is delivered to your local server again. No provider UI, no re-triggering real actions. Fix the handler, hit r, watch the status line flip from 500 to 200.

Filters run before your laptop ever sees the event. Listening on a busy GitHub source but only working on the pull_request handler? Apply a filter to the session and the noise never crosses the wire. The picker offers any named filter in your project when you start the session.

Transforms and templates, optionally. The session picker can also attach a JSONata transform or a message template — so you can develop against the exact reshaped payload your production destination receives, not the provider's raw shape.

Change everything mid-session. Press c to switch source, filter, transform, template, or delivery delay without restarting. Press p to pause forwarding while you reset a database. Press h if you forget any of this.

Simulated latency. The delay picker (off, 5s up to 5 minutes, or custom) exists for the unglamorous work: testing what your handler does when events arrive late or out of order.

Already know exactly where you're pointing? Skip the navigation:

dispatch listen 3000 my-github-source --project proj_abc123 --path /api/webhooks

The delay picker still asks on startup — and the filter, transform, and template pickers do too whenever your project has any — so there's no fully headless mode today.

What it deliberately isn't

Honesty section. dispatch listen is not a general-purpose tunnel, and a few limits are by design:

  • No public URL to your machine. The only traffic that reaches your port is webhook events that already passed signature verification and your filters. If you need to expose an arbitrary local service to the internet, that's ngrok's job, not ours.
  • Events aren't buffered for offline sessions. If the CLI isn't running when an event arrives, that event isn't queued up to ambush you on reconnect — only new events are forwarded. The event itself is still stored in Dispatch, so you can replay it from the dashboard when you're back.
  • Local sessions don't pollute delivery stats. Tunnel destinations are excluded from delivery rollups, so a missed local delivery never marks a production event as failed.

Where config lives

Credentials land in ~/.dispatch/config.yaml (mode 0600) on login. For per-project or self-hosted setups, dispatch login --local writes .dispatch.yaml in the current directory instead — and warns you, loudly, if that file isn't gitignored. The tokens are written either way, so add it to .gitignore first. Flags beat DISPATCH_* environment variables, which beat local config, which beats global. When something resolves unexpectedly:

dispatch config

prints which files are active, which URLs are in effect, and your login status — without ever printing a token.


The CLI reference has the full command surface, and the quickstart gets you from zero to a listening session — sources, destinations, and all — in a few minutes.

Dispatch receives webhooks from the tools your team already uses, verifies and filters them, and delivers them to Discord, Slack, Telegram, and more — with retries, replay, and full delivery history.