DEV Community

Gaurang Sharma
Gaurang Sharma

Posted on Edited on

Testing webhooks locally shouldn’t feel like a hack

If you've ever built anything that talks to Stripe, GitHub, Shopify, or Twilio, you know the drill. You write the handler, you deploy it, and only then do you find out if it actually works. Local development, the part where you're supposed to catch bugs early, just doesn't apply to webhooks. They come from someone else's server, and someone else's server can't reach your laptop.

So you improvise. Maybe you spin up ngrok and paste a new URL into your Stripe dashboard every time you restart it. Maybe you keep a folder of saved JSON payloads and curl them at your endpoint by hand, which works until the payload shape changes and you're debugging against data from three months ago. Maybe you just skip local testing entirely and treat your staging environment as your dev loop, watching logs and hoping.

None of that is actually broken, it's just slow. Every one of those workarounds adds a step between "I changed the code" and "I know if it works." I wanted that gap to be one command wide, so I built a local dev tunnel into RelayKit. Here's what it actually looks like to use it, end to end.

What is RelayKit

RelayKit sits between the provider (Stripe, GitHub, whoever) and your application. You get one ingest URL per project. Every webhook that hits it gets logged, verified, and forwarded to as many destinations as you want, in parallel, each with its own retry logic. It was built as production webhook infrastructure first. The local dev tunnel is the same delivery pipeline, just pointed at your laptop instead of a server.

RelayKit Dashboard

Step 1: create a project and get your ingest URL

From the dashboard, click New Project. Give it a name, and if you know your destination URL already (say, a staging server) you can add it here, along with which provider you're expecting webhooks from (Stripe, GitHub, Shopify, Twilio, SendGrid, Paddle, Clerk, and Resend are all supported out of the box, with signature verification built in).

Create Project

Once it's created, you land on the project page. At the top is your ingest URL, something like https://relaykit.io/api/hook/<token>, with a copy button next to it.

Ingest URL

This is the only URL the provider ever needs to know about. Paste it into Stripe's webhook settings (or GitHub's, or whichever provider you're using) and you're done on their end, permanently. You never touch that setting again, even as your local tunnel comes and goes.

Step 2: install the CLI

npm install -g @relaykit/cli
Enter fullscreen mode Exit fullscreen mode

or run it without installing anything:

npx @relaykit/cli login
Enter fullscreen mode Exit fullscreen mode

Step 3: generate a token and log in

Go to Settings → CLI in your dashboard and click Generate Token. It's shown once, so copy it before you navigate away (you can always regenerate a new one later if you lose it).

CLI Settings

Back in your terminal:

relaykit login
Enter fullscreen mode Exit fullscreen mode

It'll prompt you to paste the token, verify it, and confirm who you're logged in as:

RelayKit CLI Login

The token is account-level, not tied to one project, so you only do this once per machine.

Step 4: start the tunnel

With your local server already running (say, on port 3000):

relaykit tunnel --port 3000
Enter fullscreen mode Exit fullscreen mode

First time through, it'll ask which project and which destination to use. If you don't have a tunnel destination yet, there's a "create new" option right in the picker.

RelayKit CLI Setup

Once it's resolved, you get a small summary and then it just sits there, live:

RelayKit Tunnel

Trigger a test event from your provider's dashboard (Stripe has a "send test webhook" button, most others do too), or just use the real feature in your app that fires one, and watch it show up within a couple seconds.

Ctrl+C when you're done, and it prints a quick summary of how many delivered versus failed during the session.

It's not a separate toy pipeline

This is the part that matters more than the demo. The tunnel isn't a special-cased local-only feature bolted onto the side. It's a destination like any other, running through the exact same fan-out, retry, and signature-verification path as production. A few things fall out of that naturally:

You can run it alongside production. Add your production URL as one destination and your tunnel as another, on the same project. A single incoming webhook gets delivered to both, independently, so you can watch a real event hit your local code while production keeps working undisturbed.

RelayKit Project Destinations

Retries behave the same. If your local server throws a 500 while you're mid-debug, it retries with backoff exactly like it would against a flaky production endpoint. You'll see the retry count tick up in the terminal output ((retry 1), (retry 2), and so on).

Signatures still verify. Headers and body are forwarded untouched, so whatever signature-checking code you've written against Stripe or GitHub's actual signing scheme works identically whether the request originated from the real provider or was relayed through your tunnel.

What happens when you're not connected

If you close your laptop or stop the tunnel, nothing gets queued up waiting for you. It behaves the way stripe listen or ngrok do: while you're not connected, nothing is attempted at all, and there's no backlog to flood your terminal the moment you reconnect. That's a deliberate choice. Coming back to 40 stale webhooks from three hours ago, all firing into whatever you're currently building, is worse than just not seeing them.

If you genuinely need to catch up on something that happened while you were away, that's a separate, on-demand action (replaying a specific time window), not something that ambushes you automatically the second you reconnect.

Useful flags once you're past the basics

Flag What it does
--port <port> Shorthand for http://localhost:<port>
--path <path> Route to append when using --port, e.g. /webhook
--url <url> Full target URL instead of --port, for custom domains, HTTPS, or a local .test domain
--project <id> Skip the project picker
--destination <id> Skip the destination picker

Once you've run it once, it remembers your last project and destination, so day-to-day it's just relaykit tunnel --port 3000 and you're forwarding within a second or two.

Why this changes how you build the integration

Webhook bugs are rarely caught by unit tests. A provider renames a field, adds a new status, or changes a nested object's shape, and you find out when something breaks in production, usually at an inconvenient time. Testing against a live, unedited feed of real events, from the first line of code you write, closes most of that gap before it ships. You're not writing a handler against your best guess of the payload and hoping you guessed right. You're writing it against what's actually arriving, watching it happen, on your own machine.

Try it

Relaykit
The tunnel is on the free Hobby plan, alongside everything else there, no credit card required. If your usage outgrows the free tier later, that's a separate conversation, but for local development there shouldn't be a paywall between you and seeing your webhooks actually work.

npx @relaykit/cli login
npx @relaykit/cli tunnel --port 3000
Enter fullscreen mode Exit fullscreen mode

If you're tired of the ngrok-and-curl dance, give it a shot and let me know what breaks.

Top comments (0)