Skip to content
Back to blog
Local DevelopmentngrokCLI tunnelLocal Development

Why We Built a Webhook Testing Tool (Not Another Tunnel)

ngrok is a great tunnel. But webhook testing needs signed payloads, provider templates, mock responses, and test assertions — things a tunnel can't do.

Mar 16, 20267 min read

ngrok is good at what it does. It gives localhost a public URL, it has a stable free domain, a local request inspector with replay, and it handles 20,000 requests a month on the free tier. If you need a general-purpose tunnel, ngrok works.

But tunneling is one step in webhook testing, not the whole workflow. When you're building a Stripe integration, you don't just need traffic to reach localhost — you need to send realistic payloads, verify signatures, test error handling, and run assertions in CI. A tunnel can't do any of that.

That's why we built webhooks.cc as a webhook testing tool, not a tunnel replacement.

What a tunnel does and doesn't do

ngrok (and Cloudflare Tunnel, and localtunnel, and every other tunnel tool) solves the connectivity problem: your provider can't reach localhost:3000, so you put a public URL in front of it.

Here's what's left after the tunnel is running:

  • How do you send a test webhook without triggering a real event? You'd need to create a test payment in Stripe, push a commit to GitHub, or place a test order in Shopify — every time.
  • How do you verify your signature validation works? You need a request signed with the correct algorithm and secret for each provider.
  • How do you test error handling? ngrok has a custom-response traffic policy, but it requires writing YAML config. It's not a one-click workflow.
  • How do you run webhook assertions in CI? CI doesn't have a tunnel. Your test needs a different approach entirely.
  • How do you replay a webhook from last week? ngrok's inspector replays during the active session. Close it and the history is gone.

These aren't tunnel problems. They're webhook testing problems.

What a webhook testing tool adds

webhooks.cc includes a tunnel (whk tunnel), but the tunnel is the entry point, not the product. Here's what sits on top of it.

Send signed provider payloads

Don't wait for a real Stripe event. Send one yourself with the correct signature:

import { WebhooksCC } from "@webhooks-cc/sdk";
 
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
 
await client.sendTo("http://localhost:3000/webhooks/stripe", {
  provider: "stripe",
  template: "checkout.session.completed",
  secret: process.env.STRIPE_WEBHOOK_SECRET!,
});

The SDK generates a realistic payload and signs it with your webhook secret. Your handler sees a request identical to what Stripe would send. Templates exist for Stripe, GitHub, Shopify, Twilio, Slack, Paddle, and Linear.

With a tunnel alone, you'd need to trigger the real event or manually construct the request and reimplement each provider's signature algorithm.

Mock responses from the SDK

ngrok's custom-response traffic policy can return arbitrary status codes, but it requires YAML configuration in your tunnel setup. webhooks.cc lets you set mock responses per-endpoint through the SDK:

await client.endpoints.update(endpoint.slug, {
  mockResponse: {
    status: 429,
    body: '{"error": "rate limited"}',
    headers: { "retry-after": "60" },
  },
});

The endpoint captures the full request for inspection and returns your mock response to the sender. Change it on the fly without restarting your tunnel or editing config files.

Persistent cloud inspection

ngrok's local inspector at localhost:4040 works while your tunnel is running. Close the session and the history is gone.

webhooks.cc stores captured requests in the cloud. Open the dashboard to see every webhook that hit your endpoint — headers, body, method, path, IP, and timing — whether your tunnel is running or not. Pro accounts keep 30 days of history.

You can also stream requests in the terminal:

whk listen abc123

Replay from stored history

ngrok's inspector supports replay during an active session. webhooks.cc stores requests persistently, so you can replay a webhook from last week:

whk replay req_abc123

Or from code:

await client.requests.replay(requestId, "http://localhost:3000/webhooks/stripe");

Same headers, same body, same signature. No need to re-trigger the original event.

Write webhook tests with the SDK

This is the gap no tunnel can fill. A tunnel forwards bytes — it can't tell you whether your handler processed the webhook correctly. It can't create an endpoint in CI, send a signed payload, capture the result, and assert on it.

The webhooks.cc SDK does all of that:

import { WebhooksCC, matchHeader } from "@webhooks-cc/sdk";
import { captureDuring, assertRequest } from "@webhooks-cc/sdk/testing";
import { describe, it, expect } from "vitest";
 
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
 
describe("Stripe webhook handler", () => {
  it("receives and verifies a checkout event", async () => {
    const [request] = await captureDuring(
      client,
      async (endpoint) => {
        await client.sendTo("http://localhost:3000/webhooks/stripe", {
          provider: "stripe",
          template: "checkout.session.completed",
          secret: process.env.STRIPE_WEBHOOK_SECRET!,
        });
      },
      { expiresIn: "5m", timeout: "10s" }
    );
 
    assertRequest(
      request,
      {
        method: "POST",
        bodyJson: { type: "checkout.session.completed" },
      },
      { throwOnFailure: true }
    );
  });
});

This test creates an ephemeral endpoint, sends a signed Stripe webhook, captures it, and asserts on the payload. It runs in CI without a tunnel — the SDK talks directly to the webhooks.cc API.

Test the full round trip

The flow() builder chains the entire sequence: create endpoint, send signed payload, wait for capture, verify the signature, and clean up.

it("handles a GitHub push webhook", async () => {
  const result = await client
    .flow()
    .createEndpoint({ expiresIn: "5m" })
    .sendTemplate({
      provider: "github",
      template: "push",
      secret: process.env.GITHUB_WEBHOOK_SECRET!,
    })
    .waitForCapture({ timeout: "10s" })
    .verifySignature({
      provider: "github",
      secret: process.env.GITHUB_WEBHOOK_SECRET!,
    })
    .cleanup()
    .run();
 
  expect(result.verification?.valid).toBe(true);
  expect(result.cleanedUp).toBe(true);
});

Five chained calls replace what would otherwise be 30+ lines of setup, polling, and teardown.

Test across multiple providers

If your app receives webhooks from several providers, test them all:

const providers = [
  { provider: "stripe", template: "invoice.paid", secret: STRIPE_SECRET },
  { provider: "github", template: "push", secret: GITHUB_SECRET },
  { provider: "shopify", template: "orders/create", secret: SHOPIFY_SECRET },
] as const;
 
for (const { provider, template, secret } of providers) {
  it(`verifies ${provider} ${template} signature`, async () => {
    const result = await client
      .flow()
      .createEndpoint({ expiresIn: "5m" })
      .sendTemplate({ provider, template, secret })
      .waitForCapture({ timeout: "10s" })
      .verifySignature({ provider, secret })
      .cleanup()
      .run();
 
    expect(result.verification?.valid).toBe(true);
  });
}

Getting started

Install the CLI:

brew install kroqdotdev/tap/whk

Log in and start the tunnel:

whk auth login
whk tunnel 3000

You get a public URL like https://go.webhooks.cc/w/abc123 that forwards to localhost. Set it in your provider's webhook settings.

For programmatic testing, install the SDK:

npm install @webhooks-cc/sdk

When to use ngrok instead

ngrok is the better choice when:

  • You need TCP or TLS tunneling, not just HTTP. ngrok supports arbitrary TCP connections.
  • You need a custom domain on the tunnel. ngrok lets you bring your own domain on paid plans.
  • You're not testing webhooks. Exposing a local API for a demo, sharing a dev server, or testing OAuth callbacks — ngrok's general-purpose tunnel is the right tool.
  • You need edge middleware. ngrok's traffic policies, OAuth enforcement, and IP restrictions are features webhooks.cc doesn't replicate.

But if your day is spent receiving webhooks from Stripe, GitHub, or Shopify and testing that your handlers process them correctly — you need more than a tunnel.

CLI Documentation

Full reference for whk tunnel, whk listen, and other CLI commands.