Skip to main content

Test Stripe Webhooks Locally: CLI Forwarding, Triggers and Traps

Test a Stripe webhook locally with the Stripe CLI: stripe listen forwarding, stripe trigger with custom payloads, fixtures for seed data and silent failures.

Founder of IImagined.ai

Published
Oct 11, 2026
Reading time
11 min read
Quick answer

To test a Stripe webhook locally, run stripe listen --forward-to with your local route, put the whsec_ secret it prints in your environment, and fire events with stripe trigger or a real sandbox checkout. Customise payloads with --add and --override, seed realistic data with stripe fixtures, and replay events with stripe events resend. When nothing happens, check the sandbox, the event filter, the status code in the listen tab and whether the test event carries your IDs.

To test a Stripe webhook locally, run stripe listen --forward-to localhost:3000/api/webhooks/stripe, copy the whsec_ signing secret it prints into your app's environment, then fire events with stripe trigger or a real checkout in a sandbox. The Stripe CLI receives events over a direct connection to Stripe and replays each one to your local route, so you need no public URL, no tunnel and no endpoint registered in the Dashboard.

Commands and flags checked October 2026 against Stripe's docs for webhooks, stripe listen, stripe trigger, stripe fixtures, stripe events resend, signature errors and event formats, with Stripe CLI v1.53.1 (released October 7, 2026) as the current release. The route follows Stripe's own Next.js App Router example.

Webhooks are where a payment becomes a plan change in your database, which makes them the part of billing you least want to debug in production. This tutorial builds the whole local loop and then covers the four ways forwarding fails without telling you. It belongs to our hub on how to build an AI SaaS, where checkout and webhooks are week four of the build order.

What will you have at the end?

Three terminal tabs and a loop you can run in seconds: your app, the CLI forwarding events to it, and a third tab for firing them. Every event Stripe would send in production reaches your route with a valid signature.

The local webhook loop
  1. 01
    Something happens in your sandbox

    stripe trigger, a fixture, or a test payment through your own checkout page.

  2. 02
    Stripe creates events

    One action usually produces several, in no fixed order.

  3. 03
    stripe listen receives them

    Over the CLI's direct connection. Nothing needs to reach your machine from outside.

  4. 04
    The CLI posts to localhost

    With a Stripe-Signature header signed with the CLI's whsec_ secret.

  5. 05
    Your route verifies and handles

    It returns 200, and the status shows up in the listen tab.

What do you need before you start?

Pre-flight
  • A Stripe account. New accounts start in a sandbox, which is all you need for this
  • Node.js 18 or later, which the npm install of the CLI requires
  • A local app with a server route; the examples use Next.js on localhost:3000
  • The sandbox secret key (sk_test_...) your app uses, in .env.local
  • CLI access enabled for the account: Settings, Team and security, MCP and CLI access
  • A second and third terminal tab

The fifth item is new enough to catch people. Stripe's CLI reference says that for CLI versions newer than v1.50.0, an Administrator or IAM Admin must enable CLI access for the account before stripe login will authenticate. On your own account, that admin is you.

How do you test a Stripe webhook locally, step by step?

The loop in six steps
  1. 1
    Install the CLI and log in

    Expected: stripe login list shows the sandbox your app uses as active.

  2. 2
    Add a route that verifies the signature

    Expected: a POST without a valid signature gets a 400.

  3. 3
    Run stripe listen --forward-to

    Expected: "Ready!" and a whsec_ secret. Put it in your environment.

  4. 4
    Fire an event with stripe trigger

    Expected: events listed in the listen tab, each followed by a 200 from your route.

  5. 5
    Customise the payload

    Expected: your handler finds the user ID you added.

  6. 6
    Seed data and replay events

    Expected: the same event sent twice changes your data once.

Step 1: install the CLI and log in

npm install -g @stripe/cli
stripe login
stripe login list

Stripe's docs install the CLI with npm; the CLI readme also lists brew install stripe for macOS and winget install Stripe.StripeCLI for Windows. stripe login shows a pairing code and opens the Dashboard, where you approve access and choose which accounts and sandboxes the CLI may use. Expected result: stripe login list marks one account or sandbox as active. It must be the one your app's API key belongs to; if it is not, run stripe switch.

Step 2: add a route that verifies the signature

In the Next.js App Router, a webhook is a Route Handler. This one is Stripe's example with the handler trimmed to a single event type:

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

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string)

export async function POST(req: Request) {
  let event: Stripe.Event

  try {
    const signature = (await headers()).get('stripe-signature')
    const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET
    if (!webhookSecret) throw new Error('STRIPE_WEBHOOK_SECRET is not set')

    // The raw body, exactly as Stripe sent it. Not req.json().
    event = stripe.webhooks.constructEvent(
      await req.text(),
      signature as string,
      webhookSecret
    )
  } catch (err) {
    const message = err instanceof Error ? err.message : 'Unknown error'
    console.error('Webhook signature check failed: ' + message)
    return NextResponse.json({ message: 'Webhook Error: ' + message }, { status: 400 })
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session
      console.log('Checkout completed', session.id, session.client_reference_id)
      // Look up your user and update their plan here.
      break
    }
    default:
      console.log('Unhandled event type ' + event.type)
  }

  return NextResponse.json({ message: 'Received' }, { status: 200 })
}

constructEvent takes three things: the raw request body, the Stripe-Signature header and the endpoint secret. If any of the three is wrong it throws, and the route answers 400. What this protects: nobody can post a fake "payment succeeded" to your route without the secret. What it does not: it does not stop duplicates or out-of-order delivery, and it says nothing about whether your handler did the right thing with a real event.

Step 3: run stripe listen and store the secret

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

> Ready! Your webhook signing secret is whsec_... (^C to quit)

Leave it running. Copy the secret into the file your app reads its environment from:

# .env.local
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

Expected result: the "Ready!" line, and no error from your route about a missing secret. Two facts save repeated copying: Stripe's reference says this secret does not change between restarts of stripe listen, and stripe listen --print-secret prints it without starting a session. It is not the secret of any endpoint in your Dashboard, so do not reuse it in production.

Step 4: fire an event

stripe trigger checkout.session.completed

Expected result: the trigger tab prints a "Setting up fixture for" line per step and ends with "Trigger succeeded!". The listen tab shows several events, not one, because the fixture creates a product, a price, a Checkout Session and a payment on the way; each line is followed by the status code your route returned. Your server log shows the checkout.session.completed branch running.

Stripe's docs are explicit that this is not a simulation: triggers issue real API requests in your sandbox and create every object along the way. That is useful, because the events are genuine, and it is why your sandbox fills with products called "myproduct".

stripe listen local webhook: which flags matter?

stripe listen with only --forward-to sends your route every snapshot event in the sandbox. That is fine on day one and noisy by day three. These are the flags worth knowing:

FlagWhat it doesWhen to use it
--forward-to <url>Forwards snapshot events to your local route and prints the signing secretAlways
--events <types>Comma-separated list of snapshot event types to listen for; the default is all of themTo match the list your production endpoint subscribes to
--latestReceives events in the latest API version instead of your account defaultWhen your SDK is pinned to the newest API version
--thin-events / --forward-thin-toListens for thin events and forwards them; none are forwarded by defaultWhen your handler uses thin event notifications
--forward-connect-to <url>Forwards Connect events to a separate URLConnect platforms
--load-from-webhooks-apiListens for the events your registered endpoints are configured forTo mirror production without retyping the list
--skip-verifySkips certificate verification when forwarding to an HTTPS URLLocal HTTPS with a self-signed certificate
--print-secretPrints the signing secret and exitsScripts and setup docs

Filtering to the events your production endpoint will subscribe to keeps local behaviour honest. Stripe recommends listening only to the event types an integration needs, and a typical subscription product needs about five:

stripe listen \
  --events checkout.session.completed,customer.subscription.updated,customer.subscription.deleted,invoice.paid,invoice.payment_failed \
  --forward-to localhost:3000/api/webhooks/stripe

stripe trigger webhook: how do you send custom payloads?

A stock trigger proves the plumbing. It does not prove your logic, because the objects it creates know nothing about your users. The built-in checkout.session.completed fixture, for instance, creates a one-off payment session with no client_reference_id and no metadata, so a handler that looks up a user by either finds nobody.

Three flags change what a trigger sends. They address a step in the fixture by name, then a parameter path:

# Attach your own user ID so the handler has something to look up
stripe trigger checkout.session.completed \
  --add checkout_session:client_reference_id=user_123

# Change a value the fixture already sets
stripe trigger checkout.session.completed \
  --override "checkout_session:line_items[0].quantity=10"

# Open the fixture in your editor before it runs
stripe trigger customer.subscription.created --edit
  • --add sets a parameter the fixture does not have. This is the one you will use most.
  • --override replaces a value the fixture sets; quote it when the path contains brackets.
  • --remove drops a parameter, and --skip skips a whole step.
  • --edit opens the fixture in your editor instead, and cannot be combined with the others.

stripe trigger --help lists every supported event. For flows a fixture cannot express well, such as a subscription started through your own pricing page, skip the trigger and run the real thing: open your checkout in the browser and pay with Stripe's test card 4242 4242 4242 4242, any future expiry date and any three-digit CVC. With stripe listen running, the events arrive exactly as they will in production, carrying the IDs your own code attached.

How do you seed data and replay events?

stripe fixtures runs a JSON file of API requests in order, and lets later requests reference earlier responses. It is the tool for repeatable seed data: a customer that maps to a user in your local database, on a plan that looks like yours. This file adapts the CLI's own subscription fixture and adds the metadata a handler would read:

{
  "_meta": { "template_version": 0 },
  "fixtures": [
    {
      "name": "customer",
      "path": "/v1/customers",
      "method": "post",
      "params": {
        "email": "seed-user@example.com",
        "metadata": { "user_id": "user_123" },
        "payment_method": "pm_card_visa",
        "invoice_settings": { "default_payment_method": "pm_card_visa" }
      }
    },
    {
      "name": "product",
      "path": "/v1/products",
      "method": "post",
      "params": { "name": "Pro plan (seed)" }
    },
    {
      "name": "price",
      "path": "/v1/prices",
      "method": "post",
      "params": {
        "product": "${product:id}",
        "unit_amount": "1900",
        "currency": "usd",
        "recurring[interval]": "month"
      }
    },
    {
      "name": "subscription",
      "path": "/v1/subscriptions",
      "method": "post",
      "params": {
        "customer": "${customer:id}",
        "items": [{ "price": "${price:id}" }]
      }
    }
  ]
}
stripe fixtures ./seed-subscription.json

# Replay one of the events it produced to your local route
stripe events resend evt_...

Running it produces the real sequence a new subscriber generates, including customer.subscription.created and invoice.paid. Then use stripe events resend with an event ID from the listen tab to deliver the same event again. Stripe says endpoints will occasionally receive duplicates and that events are not delivered in a fixed order, so a handler that passes this replay test is one you can trust.

A 200 is not a test
Proves the plumbing
  • stripe trigger prints "Trigger succeeded!"
  • The listen tab shows 200 for every event
  • One event type, sent once, with stock data
Proves the handler
  • The right user's row changed in your database
  • The same event resent changes nothing the second time
  • A subscription event arriving before the checkout event still ends in the right state
  • A request with a bad signature gets a 400

The four failure modes that make forwarding silently fail

Signature errors are loud: you get a 400 and a message. These four are quiet. Nothing errors, and nothing happens.

FailureWhat you seeFix
1. The CLI and your app are in different sandboxesstripe trigger works, but paying through your own checkout page shows nothing in the listen tabRun stripe login list and stripe switch so the CLI uses the sandbox your sk_test_ key belongs to
2. The event type is not being forwardedOther events arrive; the one you need never doesCheck the --events list. For thin events, add --thin-events and --forward-thin-to
3. The request never reaches your handlerThe listen tab shows 404, 307 or 401 instead of 200, and your handler logs nothingMatch the path and port to the route file, and exclude the webhook path from your auth proxy or middleware
4. The handler returns 200 and does nothingEverything looks green, but no row changesThe fixture carries no IDs of yours. Add them with --add, or run a real checkout from your own page

A few details on each. Sandboxes: objects in one sandbox are invisible to another, and Stripe now recommends general sandboxes over the older test mode sandbox for new integrations, so it is easy to have a key from one and a CLI session in the other. Filters: thin events are never forwarded unless you ask for them, which matters if you follow Stripe's current advice for new handlers.

Reachability: in a Next.js app the usual culprit is auth. A proxy file (proxy.ts in Next.js 16, middleware.ts before it) that protects everything will redirect the CLI's POST to a sign-in page. Stripe treats a redirect as a failed delivery, so exclude the webhook path from the matcher; the signature check is the route's authentication. Empty handlers: in development, log loudly when a handler cannot find the user an event refers to, instead of returning quietly.

Troubleshooting: when it fails loudly

SymptomCauseFix
No signatures found matching the expected signatureWrong secret: a Dashboard endpoint secret in local dev, or the CLI secret in productionUse the whsec_ value stripe listen printed, and confirm the app has loaded it
Same error, secret is rightThe body was parsed or re-serialised before verificationPass await req.text() to constructEvent. In Express, register the webhook route before express.json()
Verification fails on the timestamp checkYour machine clock is off; Stripe's libraries allow five minutes by defaultSync the clock. Do not set the tolerance to 0, which disables the check
Connection refused in the listen tabNothing is listening on that port; a dev server may have moved to another oneCheck the port your dev server printed and restart stripe listen with it
TLS or certificate errorYou forwarded to https://localhost with a self-signed certificateAdd --skip-verify, or forward to http
500 from your routeThe handler threw after the signature passedRead your server log; the listen tab only shows the status code

Stripe's troubleshooting guide names the wrong endpoint secret as the most common cause of signature errors, and a modified request body as the next thing to check. Print the first few characters of the secret your code is using and compare them with what the CLI printed before you change anything else.

Stripe CLI webhook testing vs production: what changes?

The handler code does not change when you deploy. Five things around it do:

Local with the CLIDeployed endpoint
Signing secretPrinted by stripe listen; stays the same between restartsOne per registered endpoint, shown in Workbench under Webhooks; sandbox and live differ
URLhttp://localhost is fineA publicly accessible HTTPS URL
Events deliveredAll snapshot events unless you pass --eventsOnly the types the destination subscribes to
API version of payloadsYour account default, or the latest with --latestFixed when a snapshot destination is created
Retries on failureFix the code and resend the event yourselfSandbox: three times over a few hours. Live: up to three days with backoff

One decision is worth making before you write much handler code. Stripe's docs now recommend thin events for new integrations: the notification carries only the event type and the related object's ID, you verify it with parseEventNotification() and fetch the current object, and there is no payload version to keep aligned with your SDK. Snapshot events, used in this tutorial because they are what stripe listen forwards by default and what most existing code handles, carry the object as it was when the event fired. To develop against thin events, forward them explicitly:

stripe listen --forward-thin-to localhost:3000/api/webhooks/stripe-thin --thin-events "*"

If your handler writes to a database with a service key, remember that key skips row-level rules; our guide to Supabase RLS covers what that means, and the multi-tenant guide shows how to map a Stripe customer to the right organisation. If you route payment events into automations rather than code, the n8n webhook guide covers the same signature check there.

A working local loop is the start of billing, not the end: plans, the customer portal, tax and failed-payment handling all hang off these events. Our AI SaaS Builder program wires Stripe Checkout, Billing and webhooks into one product, after the build and before the launch.

Testing Stripe webhooks locally: FAQ

How do I test a Stripe webhook on localhost?

Install the Stripe CLI, run stripe login, then run stripe listen --forward-to with your local route, for example localhost:3000/api/webhooks/stripe. The CLI prints a signing secret that starts with whsec_; set it as your webhook secret and keep the command running. In another terminal, run stripe trigger checkout.session.completed, or pay through your own checkout page with a test card, and watch the event arrive.

Do I need ngrok to test Stripe webhooks locally?

No. stripe listen receives events over a direct connection to Stripe and forwards them to localhost, so you do not need a public URL or an endpoint registered in the Dashboard. Stripe's docs mention a tunnelling tool such as ngrok as the alternative when you want to register a temporary public HTTPS URL, for example to test a real event destination and its own signing secret.

Why is my local Stripe webhook not working?

Check four things in order. Is the CLI logged in to the same sandbox as your app's API key? Is the event type included in what stripe listen forwards? Does the listen tab show a 200 from your route, or a 404, 307 or 401? And does the test event contain the IDs your handler looks up? A signature error is a fifth, louder cause: wrong whsec_ secret or a parsed request body.

Where do I find the webhook signing secret for local testing?

stripe listen prints it when it starts, and stripe listen --print-secret prints it and exits. Stripe's CLI reference says this secret does not change between restarts of the command. It is different from the secret of any endpoint registered in the Dashboard, so use the CLI secret in local development and each endpoint's own secret, shown in Workbench under Webhooks, in deployed environments.

What does stripe trigger actually do?

It runs a fixture: a short series of real API requests in your sandbox that create the objects needed to produce the event. Triggering checkout.session.completed, for example, creates a product, a price and a Checkout Session and completes it, so several related events arrive as well. Because the objects are generic, add your own fields with --add or --override, or use --edit to change the fixture first.

Can I replay a Stripe event to my local endpoint?

Yes. Run stripe events resend followed by the event ID while stripe listen is running, and the CLI resends that event to your local route. Stripe allows resending events created within the last 30 days. Replaying the same event twice is the quickest way to check that your handler ignores duplicates, which Stripe says endpoints will occasionally receive.

Should I use thin events or snapshot events?

Stripe now recommends thin events for new integrations: the notification carries the event type and the related object's ID, and your code fetches the current object. Snapshot events carry the full object as it was when the event was generated and are versioned by API version. stripe listen forwards snapshot events by default; thin events need the --thin-events and --forward-thin-to flags.

All Access · all four programs · $99/mo

The webhook works. Now build what it bills for.

AI SaaS Builder, included in All Access, takes one product from validation to Stripe Checkout, Billing and webhooks on Next.js and Supabase, with pricing built from your unit costs. All Access adds the other three programs, live coaching and the private community.

Start All Access — $99/mo →30-day money-back guarantee
Free · no signup

Webhook still not firing?

Paste your listen output and your route into the free Discord and ask other builders for a second pair of eyes.