Skip to content
Back to blog
Local DevelopmentStripeCLI tunnelSignature verification

How to Test Stripe Webhooks Locally in 2026

Set up a public tunnel to localhost, verify Stripe signatures, and write automated assertions with the webhooks.cc CLI and TypeScript SDK.

Mar 10, 20266 min read

Stripe sends webhook events — checkout.session.completed, invoice.paid, payment_intent.succeeded — to a URL you control. During development, that URL is localhost. Stripe can't reach it.

Most developers reach for stripe listen --forward-to, but the Stripe CLI only works for Stripe, doesn't support programmatic assertions, and doesn't give you a persistent URL to share with your team.

webhooks.cc gives you a public URL that tunnels to localhost, a dashboard for inspecting every payload, and a TypeScript SDK for writing assertions against captured requests.

What you'll build

By the end of this guide you'll have:

  • A public tunnel URL forwarding Stripe events to your local server
  • Signature verification on captured requests
  • Automated test assertions using the SDK
1

Install the CLI and authenticate

Install the webhooks.cc CLI:

brew install webhooks-cc/tap/whk

Then log in:

whk auth login

This opens your browser for OAuth. The CLI stores a 90-day API key at ~/.config/whk/token.json.

2

Start a tunnel

Point the tunnel at the port your app runs on:

whk tunnel 3000

Output:

✓ Endpoint created: https://go.webhooks.cc/w/abc123
✓ Forwarding to http://localhost:3000
✓ Dashboard: https://webhooks.cc/dashboard

Every request that hits https://go.webhooks.cc/w/abc123/... gets forwarded to localhost:3000 with the original method, headers, path, and body intact.

3

Configure Stripe

In the Stripe Dashboard, go to Developers → Webhooks → Add endpoint. Set the endpoint URL to your tunnel URL — for example, https://go.webhooks.cc/w/abc123/stripe. Select the events you need (checkout.session.completed, invoice.paid, etc.).

Stripe sends a test event when you save. You'll see it appear in both the webhooks.cc dashboard and your terminal.

Copy the signing secret from Stripe (starts with whsec_). You'll use it for signature verification.

4

Write your webhook handler

A minimal Express handler that receives and verifies Stripe events:

import express from "express";
import Stripe from "stripe";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const app = express();
 
app.post(
  "/stripe",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.headers["stripe-signature"] as string;
 
    const event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
 
    switch (event.type) {
      case "checkout.session.completed":
        console.log("Checkout completed:", event.data.object.id);
        break;
      case "invoice.paid":
        console.log("Invoice paid:", event.data.object.id);
        break;
    }
 
    res.json({ received: true });
  }
);
 
app.listen(3000);
5

Verify signatures on captured requests

The SDK can verify Stripe signatures on any captured request — useful for automated testing outside your handler:

import {
  WebhooksCC,
  matchHeader,
  verifySignature,
  isStripeWebhook,
} from "@webhooks-cc/sdk";
 
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
 
const request = await client.requests.waitFor("abc123", {
  timeout: "30s",
  match: matchHeader("stripe-signature"),
});
 
if (isStripeWebhook(request)) {
  const result = await verifySignature(request, {
    provider: "stripe",
    secret: process.env.STRIPE_WEBHOOK_SECRET!,
  });
 
  console.log("Signature valid:", result.valid);
}

waitFor polls until a matching request arrives or the timeout expires. The matchHeader matcher filters for requests that carry a stripe-signature header.

6

Debug with the dashboard

Open https://webhooks.cc/dashboard to see every captured request in a split-pane viewer:

  • Method, path, and timestamp
  • Full headers (including stripe-signature)
  • Parsed JSON body

You can replay any request from the dashboard to re-test your handler without retriggering the event in Stripe. Or replay from code:

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

Automate the full cycle with the flow builder

The SDK's flow builder chains the complete sequence — create endpoint, send a signed Stripe payload, wait for capture, verify the signature, and clean up — into a single call:

const result = await client
  .flow()
  .createEndpoint({ expiresIn: "1h" })
  .sendTemplate({
    provider: "stripe",
    template: "checkout.session.completed",
    secret: "whsec_test_123",
  })
  .waitForCapture({ timeout: "15s" })
  .verifySignature({
    provider: "stripe",
    secret: "whsec_test_123",
  })
  .cleanup()
  .run();
 
console.log(result.verification?.valid); // true

sendTemplate generates a properly signed Stripe payload. You don't need to construct the webhook body or compute the t=... signature yourself.

FAQ

CLI Reference

All CLI commands including tunnel, listen, create, and replay.

SDK Reference

Full API reference for endpoints, requests, matchers, and signature verification.