Tutorial
15 min read
22 views

Stripe Thin Events on Localhost: When the CLI Is Enough and When You Need a Stable Public URL

Test Stripe thin events on localhost. Learn when the CLI's --forward-thin-to is enough and when v2 event destinations need a stable public HTTPS URL.

IT
InstaTunnel Team
Published by the InstaTunnel team | Editorial policy
Stripe Thin Events on Localhost: When the CLI Is Enough and When You Need a Stable Public URL

Quick answer

Stripe Thin Events on Localhost: CLI vs Public URL: quick comparison answer

Choose the tunnel tool based on the network model: public HTTPS URLs for webhooks and demos, private mesh access for internal apps, and managed infrastructure when policy controls matter most.

Which tunnel tool is best for public webhook testing?

Use a public HTTPS localhost tunnel with stable URLs. InstaTunnel focuses on webhook testing, demos, OAuth callbacks, and MCP endpoint workflows.

When should I choose a private network tool instead?

Choose a private mesh or Zero Trust tool when every user and service should stay inside a controlled private network.

If you wrote your Stripe webhook handler a few years ago, it probably assumes the event contains the whole object. Thin events break that assumption on purpose. A thin event is a small notification that says what happened and which object it happened to. Your code then calls the Stripe API to get the rest.

That design suits production, but it changes how you test on localhost. There are now two event formats, two sets of CLI flags, and two different answers to “do I need a public URL?”

This guide covers how thin events differ from snapshot events, how to receive them locally with the Stripe CLI, where the CLI stops being enough, and why a stable public HTTPS URL matters once teammates share an endpoint.

TL;DR

  • Thin events carry an event ID, type, timestamp, and a pointer to the related object. The notification payload is unversioned. Snapshot events carry the full object and are tied to an API version.
  • The CLI forwards snapshot events with --forward-to. Thin events use --forward-thin-to plus --thin-events.
  • For one developer building a handler, stripe listen is usually enough. It needs no public URL and no Dashboard setup.
  • A registered event destination that delivers to a webhook endpoint needs a publicly accessible HTTPS URL. That is where a stable tunnel or a deployed staging server comes in.
  • Fetch-after-notify handlers work on localhost, because the follow-up API call is an ordinary outbound request.

Thin events vs. snapshot events

Stripe delivers events to an event destination in one of two formats, and you choose the format when you create the destination.

A snapshot event arrives as the complete Event object, including a point-in-time copy of the related resource. Its structure follows the API version of the destination, so your code and your destination must stay compatible. Statically typed SDKs (.NET, Java, Go) need the destination’s API version to match the version the SDK was generated from, or deserialization fails.

A thin event arrives as a lightweight notification. Per Stripe’s documentation, it contains:

  • type, such as v2.core.account.updated
  • id, the event ID you use to retrieve the full event
  • created, a timestamp
  • related_object, with the resource’s ID, type, and API URL

Stripe’s example payload also shows livemode, context, and a reason hash that describes what triggered the event.

The notification does not include the changes or data hashes. Those exist only on the complete event, which you retrieve from the v2 Events API.

Snapshot events Thin events
Payload Large, includes a snapshot of the object Small, ID and pointer only
Versioning Versioned by API version Unversioned
SDK typing Untyped Typed
Events API v1 v2
Changed fields previous_attributes in the payload changes hash on the full event

Because the notification is unversioned, you can upgrade your Stripe SDK or API version without editing the event destination. The resources you fetch afterwards still follow normal API versioning. Stripe recommends thin events for new integrations. It keeps snapshot events for two cases: a third-party tool needs the complete Event payload, or you need the previous values of changed attributes without an extra API call.

One detail trips up older tutorials. Stripe’s current documentation says both API v1 and API v2 resources generate thin events, and the thin event catalog lists v1.* types such as v1.payment_intent.succeeded and v1.invoice.paid. Some versions of Stripe’s docs still describe thin events for v1 resources as a private preview. Check the catalog and your own account before you build on a specific type.

Why fetch-after-notify fits localhost

A snapshot can be stale by the time you process it, and Stripe advises fetching the latest resource before acting on critical data. Thin events make that the default workflow:

  1. Verify the signature and parse the notification.
  2. Call fetchRelatedObject() to get the current state of the resource.
  3. Call fetchEvent() only if you need the data or changes hashes.
  4. Act on what you fetched.

If the event type and resource ID tell you enough, you can also process the notification without any extra call.

This helps local development. Your handler needs inbound delivery from Stripe and outbound calls to the Stripe API. Outbound calls work from any laptop. Only inbound delivery needs a route to your machine, and the CLI provides one without a public URL.

Your handler does need an API key for those outbound calls. A publishable key can’t authorize fetchRelatedObject() or fetchEvent(). Use a secret or restricted key, and Stripe suggests restricting it to read access on the resources you retrieve.

Receiving thin events with the Stripe CLI

Install the CLI and log in. Stripe’s current guide installs it with npm, and the login pairs the CLI to your account:

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

The API key that stripe login generates is valid for 90 days.

By default, stripe listen accepts snapshot events only. To get thin events, name them with --thin-events and give them their own forwarding target:

stripe listen --forward-thin-to localhost:4242/webhook --thin-events "*"

This is the form used in Stripe’s webhook guide. The CLI prints a signing secret beginning with whsec_. Stripe states that this secret does not change between restarts of stripe listen, so you can store it in a local .env file once.

A few details worth knowing:

  • Two formats, two flags. --forward-to handles snapshot events and --forward-thin-to handles thin events. Use both in one command if your app consumes both.
  • Connect has its own flag. --forward-thin-connect-to forwards thin events from connected accounts. By default it reuses your thin events URL. The snapshot equivalent is --forward-connect-to.
  • Sandbox by default. Add --live only when you deliberately want live-mode events.
  • No Dashboard setup. You don’t need to configure any webhook endpoint in the Dashboard to receive events through the CLI.
  • Terminal only. You can’t forward events from the Workbench Shell to your local endpoint. Run the CLI in a real terminal.
  • Self-signed HTTPS. --skip-verify skips certificate verification when forwarding to an HTTPS target. Use it for local development only.
  • Mirror a real endpoint. --load-from-webhooks-api loads the paths and event types of your registered Dashboard endpoints and appends the path to your local --forward-to target. Stripe documents this for snapshot forwarding, so check the CLI reference before relying on it for thin destinations.

To generate a test event, Stripe’s documentation uses this thin example:

stripe trigger v1.billing.meter.error_report_triggered

When forwarding succeeds, the listen tab prints a line like [200 POST] OK v1.billing.meter.error_report_triggered.

A minimal Node.js handler

Stripe’s SDKs provide parseEventNotification(), which verifies the Stripe-Signature header and returns a typed notification. Stripe’s documentation labels the thin event handler “Clover+”, so use an SDK version released with the Clover API version or later. This Express handler follows the shape in Stripe’s docs, and you should confirm method names against the SDK version you install:

const express = require('express');
const Stripe = require('stripe');

const client = new Stripe(process.env.STRIPE_API_KEY);
const endpointSecret = process.env.WEBHOOK_SECRET; // whsec_... from `stripe listen`
const app = express();

// Register this route BEFORE any express.json(): signature checks need the raw body.
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  let notification;
  try {
    notification = client.parseEventNotification(
      req.body,
      req.headers['stripe-signature'],
      endpointSecret
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  res.sendStatus(200); // acknowledge first, work afterwards

  try {
    if (notification.type === 'v1.billing.meter.error_report_triggered') {
      const meter = await notification.fetchRelatedObject(); // latest state
      const event = await notification.fetchEvent();         // adds the `data` hash
      console.log(meter.id, event.data);
    } else {
      console.log(`Unhandled event type: ${notification.type}`);
    }
  } catch (err) {
    console.error('Processing failed', notification.id, err);
  }
});

app.listen(4242);

Stripe’s SDKs also include an UnknownEventNotification class for event types released after your SDK version. Match on type for those instead of expecting a generated class.

Several habits from Stripe’s guidance are worth adopting immediately:

  • Leave the raw body alone. Any manipulation before verification breaks the signature check.
  • Return a 2xx before slow work. Process events through an asynchronous queue so delivery spikes don’t overwhelm your hosts. The try/catch above is a stand-in for that queue.
  • Deduplicate by event ID. Stripe may deliver an event more than once and doesn’t guarantee ordering. Don’t use created to sequence events or detect repeats.
  • Subscribe only to the types you need. Stripe advises against listening to everything on a registered destination.
  • Exempt the route from CSRF protection if your framework (Rails, Django, and similar) enforces it on POST requests.

When the Stripe CLI is enough

For everyday work, stripe listen beats a tunnel. Use it when:

  • You are the only developer on the handler. One person, one machine, one process.
  • You are writing handler logic. Parsing, routing by event type, and fetch-after-notify calls behave the same whether the notification came through the CLI or a registered endpoint.
  • You don’t want to register anything. Nothing is created in the Dashboard, so nothing needs cleanup.
  • You want a stable local secret. The signing secret survives restarts.

You also don’t have to expose a port or register a URL. Treat the CLI as the inner-loop tool: edit code, trigger an event, read the result.

When you need a stable public URL

The CLI doesn’t replace a real event destination. Stripe’s documentation states that registered webhook endpoints must be publicly accessible HTTPS URLs. Its own guide suggests a tunneling tool such as ngrok for temporary testing when you have a local server and no public URL. A handler you’re still developing locally can use HTTP, but once it is publicly accessible it must use HTTPS. HTTPS is required in live mode.

1. You are testing real delivery behavior

CLI forwarding skips parts of the production path. A registered destination adds rules the CLI never exercises:

  • Redirect responses (3xx) count as failures. Point the destination at the final URL.
  • Stripe requires TLS 1.2 or 1.3 and a valid certificate chain. A TLS error shows up on the delivery.
  • Live-mode deliveries retry with exponential backoff for up to three days. Sandbox deliveries retry three times over a few hours, so a sandbox can’t reproduce multi-day retry behavior.
  • Slow responses time out and show up as failed deliveries.
  • Workbench’s Event deliveries tab lists each event as Delivered, Pending, or Failed, with the HTTP status code and the time of the next attempt.

To watch your handler survive a retry, you need a real destination pointed at a reachable URL.

2. Teammates share one endpoint

This is where a tunnel pays off. You can register up to 16 event destinations per livemode or sandbox account, and every registered destination has its own signing secret. Having each developer register a personal destination doesn’t scale.

Separate stripe listen sessions work for isolated development, but they don’t give the team one place where a webhook lands, gets logged, and can be inspected by everyone. Point a single thin event destination at a persistent HTTPS address in front of a shared dev or staging service, and everyone sees the same deliveries and uses the same secret. For anything longer-lived, a deployed staging server beats a tunnel.

The word that matters is stable. If the tunnel URL changes every session, you must update the destination in Workbench or through the /v2/core/event_destinations API each time, then copy the new signing secret into your config. A reserved or persistent subdomain on a tunnel service removes that chore. Several services offer one, such as InstaTunnel’s --subdomain flag, but plan limits differ, so check which tier includes reserved subdomains before you commit.

3. You run thin and snapshot endpoints side by side

Stripe says that if you use thin events, you need to register a separate webhook endpoint. During a migration you may run both formats, which means two destinations and two secrets. One stable host with two paths, such as /webhooks/snapshot and /webhooks/thin, keeps that manageable. If you use Accounts v2, you might listen to both formats at once.

4. You test Connect or organization destinations

Connect platforms choose whether a destination listens to their own account (events_from: ["@self"]) or to connected accounts (["@accounts"]). Depending on your integration, you may need both. For example, thin events about Accounts v2 objects that represent connected accounts go to a destination on Your account.

The CLI has thin-specific Connect forwarding, but a registered destination is the realistic test of these scopes. Organization destinations (@organization_members) also have documented limits for event types that need a synchronous response:

  • You can’t subscribe an organization destination to issuing_authorization.request.
  • An organization destination can’t influence Checkout redirect behavior on checkout_sessions.completed.
  • An organization destination can’t influence automatic invoice finalization when it responds unsuccessfully to invoice.created.

For those event types, Stripe says to use a webhook endpoint in a Stripe account within the organization. You only see these limits against a real endpoint.

5. Something off your laptop needs the URL

A staging environment, a mobile build, a partner integration, or a stakeholder demo can’t use a CLI session on your machine.

Quick decision guide

Situation Best tool
Writing handler logic alone stripe listen --forward-thin-to
Testing retries and delivery failures Registered endpoint on a stable URL
Several developers, one shared endpoint Stable tunnel or deployed staging
Thin and snapshot side by side Stable URL with two paths
Connect or organization destinations Registered endpoint
Demos and QA outside your machine Stable tunnel or staging

A workable pattern is to use the CLI for most of development, then switch to a registered destination on a stable URL for integration testing and sign-off.

Creating a thin destination for your tunnel

In Workbench, open the Webhooks tab and create an event destination. Then:

  1. Choose Your account.
  2. Select the Thin payload format (Stripe marks it as recommended).
  3. Pick your event types.
  4. Choose Webhook endpoint as the destination type.
  5. Enter your endpoint URL.

Stripe then shows a whsec_ signing secret on the settings page, behind Reveal secret.

Through the API, call /v2/core/event_destinations. Stripe’s example looks like this:

curl -X POST https://api.stripe.com/v2/core/event_destinations \
  -H "Authorization: Bearer $STRIPE_API_KEY" \
  -H "Stripe-Version: <version from Stripe's current docs>" \
  --json '{
    "name": "Thin events (dev tunnel)",
    "type": "webhook_endpoint",
    "events_from": ["@self"],
    "event_payload": "thin",
    "enabled_events": ["v1.billing.meter.error_report_triggered"],
    "webhook_endpoint": { "url": "https://your-stable-subdomain.example.com/webhooks/thin" },
    "include": ["webhook_endpoint.signing_secret"]
  }'

Stripe’s sample pins a preview API version in the Stripe-Version header, so copy the current value from the docs instead of hard-coding an old one. The include array returns webhook_endpoint.signing_secret in the response.

Stripe lists a v2.core.event_destination.ping event type, described as a ping used to test the connection to an event destination. It’s a useful first check that your tunnel, TLS, and signature verification work before you wait for a real event.

Stripe generates a unique secret for each endpoint, and an endpoint used with both test and live keys has a different secret for each. In practice, a destination’s secret differs from the one the CLI prints, so keep the two in separate environment variables.

Recovery is easier with a real destination:

  • Workbench resend: you can resend an event for up to 15 days after it was created.
  • CLI resend: stripe events resend <event_id> --webhook-endpoint=<endpoint_id> works for up to 30 days. Resending doesn’t cancel Stripe’s automatic retries, even if the resend returns a 2xx.
  • Missing event types: if you add a type to enabled_events later, Stripe doesn’t backfill earlier events. You can list them through the v2 Events API (using the types filter) and retrieve each complete event, for up to 30 days.
  • Retention: Workbench keeps event summaries for 13 months, but full payloads are limited to the last 30 days.

Security checklist for a public dev endpoint

A public URL is an attack surface even in a sandbox. Stripe’s guidance is to use two protections together:

  • Verify every signature. parseEventNotification() handles this for thin events. Stripe needs the raw request body for the check.
  • Reject stale timestamps. Stripe’s libraries default to a five-minute tolerance. Never set it to 0, which disables the recency check entirely.
  • Allowlist Stripe’s IP addresses. Stripe publishes the addresses it sends webhooks from. With a tunnel in front of your laptop, apply the allowlist at the tunnel or firewall layer if your provider supports it.
  • Keep secrets out of code. Use environment variables or a secrets vault, and never put an API key or endpoint secret in source code, screenshots, or client-side apps.
  • Roll leaked secrets. In Workbench, Roll secret lets you expire the old secret immediately or keep it active for up to 24 hours. During that window Stripe sends one signature per active secret.
  • Close the tunnel when you finish, or restrict non-webhook routes.

Frequently asked questions

Can I receive Stripe thin events on localhost without a tunnel?

Yes. Run stripe listen --forward-thin-to localhost:<port>/<path> --thin-events "*". The CLI forwards events from your account to your handler without a registered public URL.

What is the difference between --forward-to and --forward-thin-to?

--forward-to forwards snapshot events. --forward-thin-to forwards thin events, and you also need --thin-events to say which ones. Use * for all thin events.

Do thin event destinations need HTTPS?

A registered webhook endpoint must be a publicly accessible HTTPS URL, and HTTPS is required in live mode. A handler you’re developing locally can use plain HTTP behind the CLI.

Can one endpoint receive both thin and snapshot events?

Stripe’s guidance is to register a separate endpoint for thin events rather than reuse a snapshot destination. You can host both on one domain with two paths.

Does the CLI signing secret work with a Dashboard destination?

No. Each registered destination has its own signing secret, distinct from the one the CLI prints.

Do I need to handle events in order?

No, and you shouldn’t try. Stripe doesn’t guarantee delivery order. Track event IDs to detect duplicates, and fetch the related object when you need current state.

Final thoughts

Thin events move complexity out of your webhook configuration and into handler code, where it is easier to test and version. On localhost, the Stripe CLI covers most of the inner loop with one flag pair. When you need to prove delivery behavior, share an endpoint across a team, or reach the handler from outside your machine, you are working with a real event destination. That means a stable, public HTTPS URL.

Start with the CLI. Move to a persistent URL when the work stops being a solo experiment.


Sources: Stripe documentation on how events work, webhook endpoints, setting up events, the thin event type catalog, and the Stripe CLI listen reference. InstaTunnel’s subdomain flag is from its npm README.

Continue from this article into the most relevant product guides and workflows.

Related Topics

#stripe thin events#stripe thin events localhost#stripe snapshot events vs thin events#stripe thin events vs snapshot events#stripe event destinations#stripe v2 event destinations#stripe v2 events#stripe webhooks localhost#stripe webhook testing#stripe webhook local development#stripe cli#stripe cli forward-thin-to#stripe listen forward-thin-to#stripe listen#stripe cli webhooks#stripe cli listen#stripe cli trigger#stripe webhook endpoint#stripe webhook handler#stripe webhook signature verification#stripe unversioned payloads#stripe fetch after notify#fetch-after-notify webhook pattern#stripe event notifications#stripe api versioning webhooks#stripe webhooks api version mismatch#stripe thin event payload#stripe webhook best practices#stripe webhook retries#stripe webhook idempotency#stripe localhost tunnel#stripe webhook tunnel#stripe webhook public url#stripe webhook https url#stable public url for webhooks#static webhook url#persistent webhook url#localhost tunneling#localhost tunnel for webhooks#expose localhost to internet#ngrok alternative#ngrok alternative for stripe#cloudflare tunnel stripe webhooks#localtunnel alternative#webhook testing tools#webhook development tools#test webhooks locally#share webhook endpoint with team#shared webhook endpoint teammates#team webhook development#stripe test mode webhooks#stripe sandbox webhooks#stripe developer tools#stripe integration tutorial#stripe api tutorial#payment webhooks#payment api integration#developer infrastructure#devops webhooks#webhook security

Keep building with InstaTunnel

Read the docs for implementation details or compare plans before you ship.

Share this article

More InstaTunnel Insights

Discover more tutorials, tips, and updates to help you build better with localhost tunneling.

Browse All Articles