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.

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-toplus--thin-events. - For one developer building a handler,
stripe listenis 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 asv2.core.account.updatedid, the event ID you use to retrieve the full eventcreated, a timestamprelated_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:
- Verify the signature and parse the notification.
- Call
fetchRelatedObject()to get the current state of the resource. - Call
fetchEvent()only if you need thedataorchangeshashes. - 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-tohandles snapshot events and--forward-thin-tohandles thin events. Use both in one command if your app consumes both. - Connect has its own flag.
--forward-thin-connect-toforwards thin events from connected accounts. By default it reuses your thin events URL. The snapshot equivalent is--forward-connect-to. - Sandbox by default. Add
--liveonly 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-verifyskips certificate verification when forwarding to an HTTPS target. Use it for local development only. - Mirror a real endpoint.
--load-from-webhooks-apiloads the paths and event types of your registered Dashboard endpoints and appends the path to your local--forward-totarget. 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
2xxbefore slow work. Process events through an asynchronous queue so delivery spikes don’t overwhelm your hosts. Thetry/catchabove 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
createdto 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, orFailed, 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:
- Choose Your account.
- Select the Thin payload format (Stripe marks it as recommended).
- Pick your event types.
- Choose Webhook endpoint as the destination type.
- 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 a2xx. - Missing event types: if you add a type to
enabled_eventslater, Stripe doesn’t backfill earlier events. You can list them through the v2 Events API (using thetypesfilter) 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.
Related InstaTunnel pages
Continue from this article into the most relevant product guides and workflows.
Related Topics
Keep building with InstaTunnel
Read the docs for implementation details or compare plans before you ship.