Guides

Test Stripe webhooks on localhost without ngrok

You do not need to expose your machine to the internet to test Stripe webhooks: run a deterministic local sandbox that delivers a signed, provider-shaped event straight to localhost, or use the Stripe CLI to forward real sandbox events over an outbound connection it opens for you. Both avoid the inbound tunnel a tool like ngrok requires.

Add to Chrome Run it locally ↓

The problem: a provider can’t call a URL that doesn’t exist

Stripe (and every provider webhook system built the same way) delivers events by making an outbound HTTP request to a URL you registered. localhost is not a URL the public internet — or Stripe’s delivery infrastructure — can reach, so the direct path is closed by construction, not by a missing setting.

Every approach below solves that same problem differently: some make your machine temporarily reachable from the outside, some avoid needing that entirely, and some skip the provider altogether and construct the event by hand. Which one is right depends on what you’re actually trying to prove.

The complete workflow with a local sandbox

This is the path that needs no inbound exposure and no provider account.

  1. Install the extension and open its side panel. No sign-in and no cloud connection are involved.
  2. Point it at your app. Enter the localhost or 127.0.0.1 origin your app listens on, with the exact port and path your webhook route expects. The extension requests a Chrome permission scoped to only that origin, and runs a connectivity check against it before anything else happens.
  3. Set a test-only signing secret. This is the same signing-secret concept Stripe’s own webhook endpoints use (see the provider notes below) — pick any value for local testing, use it to verify signatures in your handler, and never reuse a real endpoint’s live secret here.
  4. Configure the payment, or don’t. You can set an amount, currency, merchant reference, and safe metadata for the run, or leave every field blank to get the scenario’s unmodified default payment.
  5. Run “Payment succeeds.” The extension delivers two ordered, correctly signed events to your endpoint — an authorization success followed by a payment success — the same event pair a real successful Stripe payment produces. The generated section below this guide shows the exact event timeline, a signature-verification snippet, and a handler skeleton for this run, generated from the same scenario definition the extension executes, not written by hand for this page.
  6. Read the timeline, or export it. The run’s timeline survives even if the extension’s background worker restarts mid-run, and you can export a full or redacted record of what was sent and received.

Nothing here opens a port, registers a public endpoint, or asks Stripe for anything: the events are generated locally and delivered directly to the loopback address you configured.

Troubleshooting an unreachable endpoint

Most first-run failures are one of these:

Comparing the ways to get a webhook onto localhost

Approach Setup cost Inbound exposure Account/credentials needed Deterministic & repeatable Reproduces duplicates/reordering Evidence of a run CI-friendly
Inbound tunnel (e.g. ngrok) Install an agent, authenticate it, start a tunnel per session Yes — your machine becomes reachable from the public internet for as long as the tunnel is open Tunnel-provider account and an authtoken No — you get whatever event a real account fires, whenever it fires Only by coincidence, not on demand Whatever the provider’s own dashboard shows you Awkward — a long-lived public tunnel in CI is a real exposure, not a convenience
Provider CLI listener (e.g. Stripe CLI) Install the CLI, log in once No — it opens an outbound connection from your machine and forwards events over that; no public URL is created A provider account, via CLI login No — trigger fires the provider’s real fixture logic against your live sandbox account, not a fixed payload Not directly — you’d need to trigger the same event twice yourself Provider dashboard plus your own logs Workable in CI with stored credentials, but ties every run to one shared sandbox account
Hand-rolled fixtures and curl Write and sign a payload yourself None None Only as deterministic as you make it — easy to sign correctly, easy to get subtly wrong Whatever you script — but nothing checks that it matches what the provider would actually send Only what you log yourself Fully scriptable, but the fixture is only as trustworthy as its author
Hosted request inspector (e.g. Webhook.site) Open a URL, sometimes register for a longer-lived one Yes for the inspector’s own hosted endpoint, though not for your machine directly — you then still have to relay the request onward yourself None for casual use; an account for durable URLs Shows you whatever arrives, unmodified No — it inspects, it does not generate or replay a provider’s delivery behavior Excellent for eyeballing one request; not built for asserting on a sequence Not designed for this — it’s an interactive inspection tool
Finxture (deterministic local sandbox) Install the extension, approve one loopback origin None — delivery goes straight from the extension to your configured loopback address None — no account, no cloud connection Yes — a reviewed scenario definition, not whatever an account happens to produce Yes — this is what the scenario catalog is for; a duplicate-delivery scenario exists for exactly this A durable, exportable run timeline that survives a service-worker restart Not yet — it’s a Chrome side-panel UI today, with no headless or CLI entry point

Two of these rows deserve a direct statement instead of a table cell: a provider CLI listener is the right tool when you specifically need a real account’s real test-mode event — verifying against actual provider behavior is not something a fixed scenario can substitute for. An inbound tunnel is the right tool when the real provider genuinely needs to reach your machine, for example while working through a live integration issue with the provider’s own support. Neither is worse than the other; they answer different questions than “does my handler correctly process the events my own tests need to prove.”

When you should not use Finxture

This is deliberately not a soft caveat. Finxture is the wrong tool for:

If any of those is what you actually need, use the Stripe CLI or a real test account instead, and come back to a deterministic sandbox for the repeatable regression tests those tools are a poor fit for.

Why “no ngrok” is the point, not a slogan

An inbound tunnel is not unsafe by accident — it is unsafe by design, in the specific sense that its entire job is to make a process on your laptop reachable from the public internet. That is the correct trade for some tasks. It is a strange trade for the very common task of proving “when my endpoint receives this exact event, my handler does the right thing,” which needs nothing from the internet at all — only a source of correctly shaped, validly signed events and a place to send them. A local sandbox that never opens your machine to inbound traffic answers that question with a strictly smaller attack surface, no account, and no per-session setup step to forget.

What goes wrong

A handler that was only ever exercised against a hand-copied payload or a byte-for-byte replay of a recorded request has never verified a real signature and has never survived a retry, so it passes every local test and then rejects or mishandles the first webhook a real endpoint delivery produces.

Provider notes

Stripe

  • Stripe generates a unique signing secret for each webhook endpoint; if the same endpoint is used for both test and live API keys, the secret differs for each mode. — https://docs.stripe.com/webhooks#verify-official-libraries
  • Stripe recommends verifying a webhook's signature with an official library before trusting its payload, because without verification an attacker could send fake events to an endpoint to trigger actions such as fulfilling orders or granting account access. — https://docs.stripe.com/webhooks#verify-official-libraries
  • Event deliveries created in a sandbox are retried three times over a few hours, separately from the longer multi-day retry schedule Stripe uses in live mode. — https://docs.stripe.com/webhooks#automatic-retries

Stripe CLI

  • The Stripe CLI's stripe listen --forward-to command sends events in a sandbox to a local webhook endpoint given as a plain local address, for example stripe listen --forward-to localhost:4242/webhook — no publicly reachable URL is involved. — https://docs.stripe.com/webhooks#test-webhook
  • Using the Stripe CLI requires connecting a Stripe account, since running stripe login generates a restricted API key for a sandbox and another for live mode, each valid for 90 days. — https://docs.stripe.com/stripe-cli/keys

ngrok

  • ngrok describes itself as a globally distributed reverse proxy that creates tunnels from local applications to the internet, and names receiving webhooks in an app under local development as a use case for doing so. — https://ngrok.com/docs/what-is-ngrok/
  • Using ngrok to expose a local server requires an ngrok account, the ngrok agent installed on the machine, and an authtoken to authorize that agent. — https://ngrok.com/docs/what-is-ngrok/

Webhook.site

  • Webhook.site generates free, unique URLs and lets a developer see everything sent to them instantly, without requiring an account for the free tier; it is a hosted service, not something that runs on the developer's own machine. — https://webhook.site/

See it running

Deliver the successful-payment canonical events once in their default order. Generated from the same scenario definition the extension runs — one panel per supported provider.

What this proves

Event timeline

TimeEventDeliveredExpected receiver behavior
+1000mspayment_intent.amount_capturable_updatedVerify the signature, then acknowledge with a 2xx response.
+2000mspayment_intent.succeededVerify the signature, then acknowledge with a 2xx response.

Verify the signature

const event = stripe.webhooks.constructEvent(
  requestBody, // the raw, unparsed request body
  request.headers['stripe-signature'],
  endpointSecret,
);
// Verified against the Stripe API version this scenario targets: 2026-04-22.dahlia.

Handle the webhook

switch (event.type) {
  case 'payment_intent.amount_capturable_updated':
    // Handle payment_intent.amount_capturable_updated.
    break;
  case 'payment_intent.succeeded':
    // Handle payment_intent.succeeded.
    break;
  default:
    // Unhandled event type: acknowledge it anyway to stop redelivery.
    break;
}
response.sendStatus(200);

Download the exact payloads for this run: JSON · YAML

Updated 2026-08-02