DEV Community

Webappers
Webappers

Posted on

Testing Stripe webhooks locally without ngrok

The standard advice for testing webhooks locally is "use ngrok". It works, but it's the wrong first answer for Stripe specifically, because Stripe ships a free tool that does the job better and doesn't expose your laptop to the internet.

Here's the whole thing, plus the two situations where you do need something else.

**

The Stripe CLI does this for free

**
Install it, log in, and forward events straight to your local server:

stripe login
stripe listen --forward-to localhost:3000/webhooks/stripe
Enter fullscreen mode Exit fullscreen mode

That prints a signing secret on startup:

> Ready! Your webhook signing secret is whsec_1234abcd... (^C to quit)
Enter fullscreen mode Exit fullscreen mode

This secret is different from the one in your Dashboard. It's specific to this listen session. Put it in your local env, not your production one — using the Dashboard's secret while running stripe listen is one of the most common causes of signature verification failures:

STRIPE_WEBHOOK_SECRET=whsec_1234abcd...
Enter fullscreen mode Exit fullscreen mode

Then in a second terminal, fire an event:

stripe trigger payment_intent.succeeded
Enter fullscreen mode Exit fullscreen mode

No tunnel, no public URL, no firewall exposure. It also survives restarts of your dev server, which ngrok URLs on the free tier famously do not.

Filter to the events you care about

stripe listen forwards everything by default, which gets noisy fast:

stripe listen \
  --events payment_intent.succeeded,customer.subscription.updated \
  --forward-to localhost:3000/webhooks/stripe
Enter fullscreen mode Exit fullscreen mode

The raw body trap, since you'll hit it anyway

Signature verification hashes the exact bytes Stripe sent. If a body parser turned them into an object first, verification cannot succeed. In Express, order matters:

// This route must be registered BEFORE express.json()
app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const event = stripe.webhooks.constructEvent(
      req.body,                                  // Buffer, untouched
      req.headers['stripe-signature'],
      process.env.STRIPE_WEBHOOK_SECRET
    );
    res.json({ received: true });
  }
);

app.use(express.json());                          // everything else after
Enter fullscreen mode Exit fullscreen mode

n the Next.js App Router, use await req.text() — never await req.json().

Where the CLI runs out

Two cases, and they're both about the same thing: stripe trigger sends a synthetic event. It's generated by Stripe to be well-formed, not drawn from your account.

  1. Your real events don't look like the synthetic ones Synthetic events have empty metadata, no connected-account fields, no expansions, and none of the quirks your actual integration produces. If your handler reads metadata.orderId that you set at checkout, stripe trigger will never populate it, and the handler will look broken locally while working in production — or, worse, the reverse.

To see what your account actually sends, you need to capture a real event. Add a second webhook endpoint in the Dashboard pointing at a request-capturing URL. Stripe delivers to every configured endpoint, so this sits alongside your real one and breaks nothing.

  1. You need the same event more than once Some events are genuinely hard to produce on demand — a subscription renewal, a dispute, a failed payment retry. stripe trigger can fake the shape but not your specific instance of it.

The Dashboard has a Resend button on each event under Developers → Events, which re-delivers that exact event to your endpoints. That's free and it's often all you need. Its limits: it fires at your registered endpoint, so it can't target localhost unless stripe listen is running, and you can't modify the payload before resending — which you often want to do while narrowing down which field breaks your handler.

For that loop — capture once, edit, fire repeatedly at your own endpoint — you need a dedicated tool. Hookdeck has a free tier and does it well. webhook.site captures free with no signup but is oriented at inspection rather than replay. I build Eventfy, which is a paid product built around exactly that edit-and-refire loop — mentioned for completeness, and the free options above genuinely cover most of what's in this post.

The short version

Local development: stripe listen --forward-to. Free, no tunnel, use the secret it prints.
Seeing what your account really sends: a second endpoint pointing at a capture URL.
Replaying one event repeatedly: Dashboard Resend first; a replay tool if you need to modify the payload between attempts.
ngrok is a good tool that you mostly don't need here.

Top comments (1)

Collapse
 
crdtcto profile image
Kane Lim

Hello Webappers, I am Kane Lim from Hong Kong. I have over 10 years of development experience. I am writing this because your post was interesting.

I strongly agree that Stripe CLI should be the default choice for local webhook development. The raw body issue is particularly important because webhook authentication is effectively an integrity boundary. I would also add idempotency and event ordering to the local test strategy. A production handler should persist the Stripe event ID before executing business side effects, then safely acknowledge duplicate deliveries.

For more realistic testing, I usually separate contract testing from integration testing. Capture real signed events, store sanitized fixtures, and replay them against the handler while preserving the original payload structure. This makes metadata, API version differences, expanded resources, connected account context, and nullable fields testable without depending on synthetic fixtures.

Another useful layer is failure injection. Simulate delayed processing, duplicate delivery, malformed payloads, signature mismatch, transient database failures, and out of order events. Then verify transactional boundaries and retry behavior.

That combination gives you deterministic local development while still exercising the failure modes that matter in production.

I would like to get to know you better and discuss about your post. Would you please contact me? t_g_@kanelim1997