Testing Stripe Webhooks Locally: A Complete Guide with LocalCan
Published on February 7, 2026   •   10 min read

Testing Stripe Webhooks Locally: A Complete Guide with LocalCan

Jarek CeborskiJarek Ceborski

If you've integrated Stripe, you know the drill. You write your checkout flow, set up a webhook endpoint, deploy to staging... and then spend 20 minutes debugging why your checkout.session.completed event never arrives. Or worse, it arrives but your handler crashes, and you have no idea what the payload looked like.

The problem isn't Stripe. It's the feedback loop. Every time you change your webhook handler, you redeploy, trigger a test event, check the logs, and repeat. It's slow, painful, and unnecessary.

There's a better way: handle Stripe webhooks on your local machine, where you can set breakpoints, see logs instantly, and iterate in seconds. In this post, I'll walk you through three approaches to testing Stripe webhooks locally — the Stripe CLI, LocalCan tunnels, and LocalCan's .local domains — and show you when each one makes sense.

Why Local Webhook Testing Matters

Stripe communicates with your application through webhooks. When a customer completes a payment, updates their subscription, or disputes a charge, Stripe sends an HTTP POST to your endpoint with event data. Your server needs to receive, verify, and process these events.

The challenge: Stripe needs a publicly accessible URL to deliver these events. Your localhost:3000 isn't reachable from the internet.

Developers typically solve this in one of three ways:

  1. Deploy to staging — slow iteration, shared environment, noisy logs
  2. Use the Stripe CLI — forwards events locally, but with limitations
  3. Use a tunnel — expose your local server with a public URL

Each has trade-offs. Let's look at all three.

Approach 1: Stripe CLI (Quick Testing)

The Stripe CLI is Stripe's official tool for local development. It can forward webhook events directly to your local server without a public URL.

Setup

Install the CLI and log in:

Bash
# macOS
brew install stripe/stripe-cli/stripe

# Login to your Stripe account
stripe login

Start forwarding events to your local endpoint:

Bash
stripe listen --forward-to localhost:3000/api/webhooks/stripe

You'll see output like:

Bash
> Ready! Your webhook signing secret is whsec_1234567890abcdef...

Important: Copy that whsec_ signing secret. It's different from your dashboard webhook secret — the CLI uses its own for local forwarding.

Triggering Test Events

In another terminal, trigger specific events:

Bash
# Simulate a successful checkout
stripe trigger checkout.session.completed

# Simulate a subscription cycle
stripe trigger customer.subscription.created

# Simulate a payment failure
stripe trigger invoice.payment_failed

A Minimal Webhook Handler

Here's a basic Node.js/Express handler to get started:

api/webhooks/stripe.js
JS api/webhooks/stripe.js
import Stripe from 'stripe';
import express from 'express';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const router = express.Router();

// Stripe needs the raw body for signature verification
router.post('/api/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['stripe-signature'];
    let event;

    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        sig,
        process.env.STRIPE_WEBHOOK_SECRET // whsec_... from CLI or dashboard
      );
    } catch (err) {
      console.error(`Signature verification failed: ${err.message}`);
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    switch (event.type) {
      case 'checkout.session.completed':
        const session = event.data.object;
        console.log(`Payment succeeded for session: ${session.id}`);
        // Fulfill the order, activate license, send email, etc.
        break;

      case 'customer.subscription.updated':
        const subscription = event.data.object;
        console.log(`Subscription ${subscription.id} updated to ${subscription.status}`);
        break;

      case 'invoice.payment_failed':
        const invoice = event.data.object;
        console.log(`Payment failed for invoice: ${invoice.id}`);
        // Notify customer, retry logic, etc.
        break;

      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    res.json({ received: true });
  }
);

export default router;

When to Use the Stripe CLI

The CLI is great for quick, isolated testing. You can trigger specific events and see how your handler responds. But it has limitations:

  • Synthetic events only. stripe trigger sends pre-built fixtures, not real data from your Stripe account. The customer IDs, prices, and metadata won't match your actual setup.
  • No real payment flow. You can't test your actual checkout page → webhook → fulfillment pipeline end-to-end.
  • Temporary signing secret. The whsec_ secret changes every session. You need to update your env vars each time.

For full end-to-end testing with real Stripe data, you need a tunnel.

Approach 2: LocalCan Public Tunnels (End-to-End Testing)

A tunnel exposes your local server to the internet through a public URL. This lets Stripe deliver real webhook events to your machine — from actual checkouts, not synthetic fixtures.

Setting Up a Tunnel with LocalCan

In LocalCan, create a new domain pointing to your local server:

  1. Open LocalCan and click Add Domain
  2. Set the target to localhost:3000 (or whatever port your app runs on)
  3. Enable Public URL

LocalCan generates a persistent public URL like https://abc123.localcan.dev. This URL stays the same across restarts, so you don't have to reconfigure Stripe every session.

Configuring Stripe to Use Your Tunnel

Go to your Stripe Dashboard → Webhooks and add a new endpoint:

  • Endpoint URL: https://abc123.localcan.dev/api/webhooks/stripe
  • Events to listen to: Select the events your app handles (e.g., checkout.session.completed, customer.subscription.updated, invoice.payment_failed)

Copy the signing secret from the webhook details page and set it in your environment:

Bash
STRIPE_WEBHOOK_SECRET=whsec_your_dashboard_secret_here

The Full Flow

Now you can test the complete pipeline:

  1. Open your app's checkout page in the browser
  2. Complete a purchase using a Stripe test card (4242 4242 4242 4242)
  3. Stripe processes the payment and sends checkout.session.completed to your tunnel URL
  4. The event arrives at your local server with real data — your actual customer, price, and metadata
  5. Your handler processes it, you see logs in real-time, and you can set breakpoints

This is the closest you can get to production without deploying. You're testing with real Stripe events, real data, and your actual handler code.

Inspecting Traffic

One major advantage of LocalCan over the Stripe CLI: traffic inspection. LocalCan's built-in inspector shows every request hitting your tunnel — headers, body, response, and timing. When a webhook fails, you can see exactly what Stripe sent and what your server returned.

This is invaluable when debugging signature verification issues. You can inspect the raw request body, compare headers, and verify that your middleware isn't modifying the body before it reaches the signature check.

Approach 3: LocalCan .local Domains (Offline Development)

Sometimes you don't need Stripe to send events at all. Maybe you're building the UI for post-payment screens, testing error handling, or working on a plane. LocalCan's .local domains let you develop with HTTPS locally — no internet required.

Setting Up a .local Domain

In LocalCan:

  1. Click Add Domain
  2. Set the domain to something like myapp.local
  3. Point it to localhost:3000
  4. HTTPS is enabled automatically

Now https://myapp.local serves your local app with a valid certificate. No tunnel, no internet, no Stripe webhook configuration needed.

Replaying Events Locally

Combine .local domains with saved webhook payloads for fully offline testing. First, capture a real webhook payload using the Stripe CLI or your tunnel logs. Save it to a file:

test/fixtures/checkout-completed.json
JSON test/fixtures/checkout-completed.json
{
  "id": "evt_1234567890",
  "type": "checkout.session.completed",
  "data": {
    "object": {
      "id": "cs_test_abc123",
      "payment_status": "paid",
      "customer": "cus_xyz789",
      "metadata": {
        "plan": "pro",
        "user_id": "user_abc"
      }
    }
  }
}

Then replay it against your local server:

Bash
curl -X POST https://myapp.local/api/webhooks/stripe \
  -H "Content-Type: application/json" \
  -d @test/fixtures/checkout-completed.json

Note: This skips signature verification. For local replay testing, you can conditionally disable it in development:

api/webhooks/stripe.js
JS api/webhooks/stripe.js
let event;

if (process.env.NODE_ENV === 'development' && !req.headers['stripe-signature']) {
  // Local replay — skip verification
  event = JSON.parse(req.body);
  console.warn('⚠️  Skipping signature verification (dev mode)');
} else {
  // Production / tunnel — verify signature
  const sig = req.headers['stripe-signature'];
  event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
}

This gives you a fast local loop for handler logic without touching Stripe at all.

Webhook Signature Verification: Getting It Right

Signature verification is the #1 source of webhook bugs. Stripe signs every event with your endpoint's secret, and your server must verify that signature before processing the event. If verification fails, you should reject the event.

The most common mistake: your framework parses the request body before you can verify the signature.

Stripe's signature is computed over the raw request body. If your middleware parses it as JSON first, the re-serialized body won't match, and verification fails silently.

Express

JS
// WRONG — body-parser modifies the raw body
app.use(express.json());
app.post('/webhooks/stripe', handler);

// RIGHT — use express.raw() for the webhook route
app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  handler
);

Next.js (App Router)

app/api/webhooks/stripe/route.ts
TS app/api/webhooks/stripe/route.ts
import { NextResponse } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text(); // Raw body, not .json()
  const sig = req.headers.get('stripe-signature')!;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return NextResponse.json(
      { error: 'Invalid signature' },
      { status: 400 }
    );
  }

  // Handle event...

  return NextResponse.json({ received: true });
}

Common Pitfalls

Using the wrong secret. The Stripe CLI generates its own whsec_ secret. Your dashboard webhook has a different one. Make sure you're using the right secret for the right context.

Body parsing middleware. Any middleware that touches req.body before signature verification will break it. This includes express.json(), body-parser, and some framework-level parsers.

Proxy headers. If you're behind a reverse proxy (nginx, Cloudflare), make sure it's not modifying the request body or stripping headers. LocalCan preserves headers and body content by default.

Which Approach Should You Use?

Here's how I think about it:

ScenarioApproachWhy
Quick handler testingStripe CLIFast, no config, synthetic events
End-to-end checkout flowLocalCan tunnelReal Stripe events, real data
UI/error handling workLocalCan .local domainOffline, fast, no Stripe needed
CI/CD pipelineSaved fixtures + mocksReproducible, no external deps
Team demo to stakeholdersLocalCan tunnelShareable URL, real payment flow

In practice, I use all three depending on the task. The Stripe CLI for quick iteration on handler logic, tunnels for end-to-end testing before deploying, and .local domains for everything else.

Debugging Checklist

When your webhook isn't working, check these in order:

  1. Is your server running? Hit the endpoint directly with curl.
  2. Is the tunnel active? Check LocalCan's status or try the public URL in a browser.
  3. Is Stripe sending events? Check the Stripe Dashboard → Events log.
  4. Is signature verification passing? Log the error from constructEvent. It's usually a wrong secret or parsed body.
  5. Is your handler crashing? Check your server logs. An unhandled exception returns a 500, and Stripe will retry.
  6. Is Stripe retrying? Stripe retries failed deliveries for up to 3 days. Check the webhook attempt logs in the dashboard.

Handling Retries and Idempotency

Stripe retries failed webhook deliveries. Your handler might receive the same event multiple times. This is by design — it ensures events aren't lost due to temporary failures.

Make your handlers idempotent:

JS
case 'checkout.session.completed': {
  const session = event.data.object;

  // Check if we already processed this event
  const existing = await db.orders.findOne({
    stripeSessionId: session.id
  });

  if (existing) {
    console.log(`Already processed session ${session.id}, skipping`);
    break;
  }

  // Process the order
  await db.orders.create({
    stripeSessionId: session.id,
    customerId: session.customer,
    status: 'completed',
    // ...
  });

  break;
}

Use the Stripe event ID or session ID as a deduplication key. This way, processing the same event twice is harmless.

Conclusion

Testing Stripe webhooks locally doesn't have to be painful. The Stripe CLI gives you quick, synthetic event testing. LocalCan tunnels give you real end-to-end flows with actual Stripe events. And .local domains let you build and debug offline with saved payloads.

The key insight: match your testing approach to what you're actually working on. Don't spin up a full tunnel when you just need to test a switch statement. Don't rely on synthetic events when you need to verify the real checkout-to-fulfillment pipeline.

Pick the right tool for the moment, and your webhook development goes from 20-minute deploy cycles to instant local feedback.

FAQ

How do I test webhook failures locally?

Use Stripe test cards that trigger specific outcomes. 4000 0000 0000 0341 triggers a card decline, which generates invoice.payment_failed. With a tunnel, you'll receive the real failure event on your local machine.

Can I test multiple webhook endpoints at once?

Yes. The Stripe CLI supports multiple --forward-to flags. With LocalCan, create separate domains or use path-based routing to direct events to different local services.

What about webhook event ordering?

Stripe doesn't guarantee event ordering. invoice.paid might arrive before checkout.session.completed. Design your handlers to be order-independent — check the current state rather than assuming a sequence.

Do I need to handle every Stripe event type?

No. Only subscribe to events your application needs. Return a 200 response for any event you receive, even unhandled ones, to prevent Stripe from retrying them unnecessarily.

How do I switch between test mode and live mode webhooks?

Stripe test mode and live mode have separate webhook endpoints and signing secrets. Use environment variables to switch between them. Your tunnel URL can stay the same — just update which Stripe mode points to it.


For local domains and public URLs for your dev projects, check out LocalCan — test webhooks, OAuth flows, and share projects with persistent URLs. No CLI needed.