# How to Test Webhooks Locally: Tunnels, Provider CLIs, and Replays

> Three ways to receive webhooks on localhost (ngrok or cloudflared tunnels, provider CLIs like stripe listen, replayed payloads) and when to use each.

Published: 2026-10-17
Source: https://webhookadmin.com/blog/test-webhooks-locally/

## Key points

- Use a tunnel (ngrok, cloudflared) when you want real webhooks from the real sender to reach your laptop over the real network path.
- Use the provider's CLI (for example stripe listen) when you only need that provider's events forwarded to localhost, with no public URL.
- Replay saved payloads with a small script when you want the same input again and again, or in automated tests.

There are three practical ways to receive webhooks on your development machine. You can open a tunnel so your local server gets a public URL and the real sender delivers to it. You can use the provider's CLI to forward its events to localhost. Or you can save a payload once and replay it, re-signed, as often as you like. This post compares the three, covers what changes when signatures are involved, and lists what to close when you're done.

## The three methods at a glance

| Method | Examples | Best for | URL | Signature |
|---|---|---|---|---|
| Tunnel | ngrok, cloudflared | End-to-end tests from the real sender | Public https URL | Signed with the sender's real secret |
| Provider CLI | `stripe listen` | Quick tests of one provider | None needed | Signed with a CLI-specific secret |
| Replay | curl, a small script | Repeatable input, automated tests | None needed | You re-sign locally |

If you only want to look at what a sender posts, without running your own server, request inspectors such as webhook.site do that; see [webhook inspection tools compared](https://webhookadmin.com/blog/webhook-inspection-tools/).

## Method 1: a tunnel

A tunnel exposes a local port through a public URL. Register that URL with the sender and real webhooks arrive at your local server.

### ngrok

Free plan limits, checked on 2026-09-27 on [ngrok's pricing page](https://ngrok.com/pricing) and [free plan limits](https://ngrok.com/docs/pricing-limits/free-plan-limits):

| Item | Free plan |
|---|---|
| Online endpoints | Up to 3 |
| Domain | One assigned dev domain (`…ngrok-free.app`) |
| Data transfer | 1 GB per month |
| HTTP requests | 20,000 per month |
| Interstitial page | Shown before HTML browser traffic; ngrok says it does not affect APIs or programmatic access |

Because the dev domain stays the same, you register it with the sender once and it keeps working across restarts. The first paid plan (Hobbyist) is $10 a month.

```bash
ngrok http 3000
```

### cloudflared Quick Tunnels

Cloudflare's Quick Tunnels need no account ([Cloudflare docs](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/), checked on 2026-09-27):

```bash
cloudflared tunnel --url http://localhost:3000
```

Each run gets a random `trycloudflare.com` subdomain, so you update the URL at the sender every time. The docs list a limit of 200 in-flight requests (beyond that you get 429s), no Server-Sent Events, and describe Quick Tunnels as intended for testing and development only.

## Method 2: the provider's CLI

With Stripe, `stripe listen` forwards your account's events to a local URL:

```bash
stripe listen --forward-to localhost:3000/webhooks/stripe
# in another terminal
stripe trigger payment_intent.succeeded
```

`stripe listen` prints a signing secret (`whsec_…`) when it starts, and the events it forwards are signed with that secret, not with the secret of any endpoint you created in the Dashboard. Stripe's troubleshooting page specifically warns about mixing the two up ([Stripe webhooks](https://docs.stripe.com/webhooks), [signature troubleshooting](https://docs.stripe.com/webhooks/signature), checked on 2026-09-27). Keep the CLI secret in your local environment and the endpoint secret in production.

A CLI works on networks where you can't open a tunnel. What it doesn't exercise is the real path from the sender to your server: TLS, your load balancer, reachability from the internet.

## Method 3: replay a saved payload

For repeatable tests, save one real payload and its headers, then send it to your local server again. If the scheme signs a timestamp, the saved signature stops verifying after the tolerance window (often five minutes), so re-sign with the current time.

For Standard Webhooks (which Webhook Admin uses), `Webhook.sign` from the `webhookadmin` npm package produces the header:

```ts
import { readFileSync } from 'node:fs';
import { Webhook } from 'webhookadmin';

const secret = process.env.WEBHOOK_SECRET!; // a local test whsec_…
const body = readFileSync('saved-body.json', 'utf8'); // exactly as received
const id = 'msg_local_test_1';
const ts = Math.floor(Date.now() / 1000);

const signature = await new Webhook(secret).sign(id, ts, body);
const res = await fetch('http://localhost:3000/webhooks', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'webhook-id': id,
    'webhook-timestamp': String(ts),
    'webhook-signature': signature,
  },
  body,
});
console.log(res.status);
```

Send it twice with the same `webhook-id` and you are also testing your duplicate handling. The same script drops straight into an automated test; [testing webhook handlers](https://webhookadmin.com/blog/test-webhook-handlers/) covers that in depth.

## Signatures through a tunnel

- **A tunnel doesn't change the body.** If verification fails through a tunnel, the cause is how your handler reads the body or which secret it uses. The [signature troubleshooting guide](https://webhookadmin.com/blog/webhook-signature-verification-failed/) walks through it.
- **URL-signing schemes sign the tunnel URL.** Twilio signs the URL configured in Twilio plus the parameters. If your handler validates against the URL it sees locally (`http://localhost:3000/…`), it won't match. Validate against the public tunnel URL.
- **Use separate secrets.** Different secrets and differently named environment variables for local, test and production cut down on mix-ups.

## The exposure risk, and how to close up

While a tunnel is open, anyone who knows the URL can send requests to your machine. With a stable URL such as an ngrok dev domain, whoever had it last time can reach you again next time you open it.

1. Keep signature verification on locally. Never open a tunnel with verification disabled.
2. Run a server that only serves the webhook route. Don't expose admin or debug routes on the same port.
3. Stop the tunnel process when you're done.
4. Remove the tunnel URL from the sender, or pause that endpoint. Otherwise the sender keeps retrying and recording failures.

## Testing with Webhook Admin as the sender

Webhook Admin gives every project a live and a test environment, and messages sent in the test environment don't count toward your monthly volume. Endpoint URLs must be https in both environments, so for local testing you register the tunnel's https URL.

1. With a test API key (`sk_test_…`), register the tunnel URL as an endpoint.
2. Send a test message to just that endpoint with `POST /v1/endpoints/{id}/test` (event type `webhook.test` by default). Test sends don't count toward usage.
3. Verify it locally with `verify` from the `webhookadmin` package.
4. If it fails, open the delivery log to see the status code and the first 1,024 bytes of your response, fix the handler, and replay the delivery (`POST /v1/deliveries/{id}/retry`).

When the tunnel URL changes, as it does with Quick Tunnels, [update the endpoint](https://webhookadmin.com/docs/api/#update-endpoint) with the new URL. A replayed delivery keeps the same `webhook-id`, so the same loop tests your deduplication. For keeping keys, endpoints and data apart between test and live, see [separating test and live webhooks](https://webhookadmin.com/blog/webhook-test-environments/).

If you're building the sending side and want to fire test webhooks at your local receiver, you can sign up for Webhook Admin for free (test-environment messages aren't counted): [https://app.webhookadmin.com/signup](https://app.webhookadmin.com/signup)

## FAQ

### Can I test webhooks without ngrok?

Yes. Cloudflare Quick Tunnels (cloudflared tunnel --url) give you a temporary https URL without an account. For Stripe, stripe listen forwards events without any public URL. And replaying a saved payload, re-signed locally, needs no external service at all.

### The free ngrok URL keeps changing. What can I do?

As of 2026-09-27, ngrok's free plan assigns each account one dev domain that stays the same, so you register it once. If you don't need a stable URL, cloudflared Quick Tunnels give you a new random one each run.

### How do I resend a webhook I already received?

Use the sender's replay feature if it has one (stripe events resend for Stripe, redelivery in the UI or API for GitHub). For repeated local runs, save the raw body, re-sign it with the current timestamp and POST it with curl or a script.
