Skip to main content

Trends, tech news, and tool comparisons.

How to test Stripe webhooks locally with the Stripe CLI

· 6 min read
Dealpuffin
Proof points and live coupons for early adopters

To test Stripe webhooks locally, install the Stripe CLI, run stripe login, then run stripe listen --forward-to localhost:4242/webhook. The CLI prints a whsec_ signing secret; put it in your server's environment and fire test events with stripe trigger. You do not need a public URL or a tunnel for this.

This guide walks through the full loop: forwarding, triggering, verifying the signature, and the mistakes that make verification fail. The commands and behavior come from Stripe's documentation, read on 7 November 2026.

What you need first​

A server with a POST /webhook route on a local port (the examples use 4242), the Stripe CLI installed, and a Stripe account in a sandbox. Stripe's docs say the CLI forwards events from a sandbox to your local endpoint, so you test without touching live data.

If you would rather give Stripe a real public HTTPS address, Stripe's webhook docs suggest a tunneling tool such as ngrok for a temporary URL. The CLI route is simpler for day-to-day work because it needs nothing registered in the Dashboard.

The local testing loop​

  1. Run stripe login and follow the prompt to link the CLI to your account.

  2. Start your server, with the route that will receive events.

  3. In a second terminal, run stripe listen --forward-to localhost:4242/webhook.

  4. Copy the signing secret from the output. It starts with whsec_.

  5. Export it as an environment variable and restart your server so it picks the value up:

    export STRIPE_WEBHOOK_SECRET=whsec_...
  6. In a third terminal, send an event: stripe trigger checkout.session.completed.

  7. Watch the stripe listen terminal. It shows each event and the status code your endpoint returned.

To forward only some events, add the --events flag with a comma-separated list. Stripe's CLI guide documents both flags. If you use thin events, the forwarding flag is --forward-thin-to with --thin-events, as shown in Stripe's webhook quickstart.

Verify the signature on the raw body​

Every request carries a Stripe-Signature header. Stripe's docs say verification needs three things: the raw request body, that header, and the endpoint's signing secret. Their official libraries do this for you.

The part that breaks most local setups is the raw body. Stripe states that any manipulation of the raw body makes verification fail. In Express, that means mounting express.raw({type: 'application/json'}) on the webhook route instead of letting a global JSON parser run first. Frameworks that parse JSON before your handler runs are the usual cause.

Two other details from the docs are worth knowing. The libraries reject a request whose timestamp is more than 5 minutes off by default, so keep your machine clock synced. And the secret differs per endpoint, and between test and live mode, so the whsec_ value from stripe listen is not the one for a Dashboard endpoint.

Return 2xx fast, do the work later​

Stripe wants a successful status code quickly, before any slow logic. If it does not get one, it retries. In live mode it keeps retrying for up to three days with exponential backoff; in a sandbox it retries three times over a few hours.

Acknowledge the event, write it to a queue or a table, and process it afterwards. Retries also mean the same event can arrive twice, so store the event ID and skip ones you have handled. If you are billing by usage, the same discipline applies to meter events, which the post on usage-based billing after Metronome and Orb covers.

Replaying a missed event​

If an event failed while you were debugging, you can resend it. The Dashboard has a Resend button that works for up to 15 days after the event. The CLI command stripe events resend <event_id> --webhook-endpoint=<endpoint_id> works for up to 30 days. Manually resending does not cancel Stripe's automatic retries, so fix the handler first.

Troubleshooting checklist​

  • Signature verification fails: check that the route receives the raw body, and that the secret is the one printed by the current stripe listen session.
  • Nothing arrives: confirm the CLI is logged into the same account as your server's API key, and that the port in --forward-to matches your server.
  • Works locally, fails in production: the production endpoint has its own secret. Use the one from the Dashboard's webhook settings.
  • Timestamp errors: sync your clock with NTP.
  • Duplicate side effects: deduplicate by event ID.

Where this fits in a billing setup​

Webhooks tell your app that a payment succeeded or a subscription changed, so they sit at the center of any Stripe integration. When you compare processors, the integration cost matters next to the fee. The post on Stripe vs Paddle vs Polar fees covers the price side. If your product uses Stripe, Paddle or Polar, you can also list coupons through Dealpuffin after the billing side is working.

Frequently asked questions​

Do I need ngrok to test Stripe webhooks locally?​

No. The Stripe CLI forwards sandbox events straight to a local port with stripe listen --forward-to. A tunnel is an alternative when you want a public HTTPS URL registered in the Dashboard.

Where do I get the webhook signing secret for local testing?​

Run stripe listen. It prints a whsec_ signing secret in its first line of output. That secret belongs to the CLI session, not to a Dashboard endpoint.

Why does signature verification fail even though the secret is right?​

The most common cause is a changed request body. Stripe needs the raw body, so a JSON parser that runs before your handler will break verification. Mount a raw body parser on the webhook route.

How do I send a test event without making a payment?​

Run stripe trigger with an event name, for example stripe trigger checkout.session.completed. The CLI creates the related objects in your sandbox and your listener receives the event.