The Stripe Dev Loop
Test keys vs live keys per environment, forwarding events with stripe listen, and driving subscription lifecycles with test clocks.
Stripe is the one integration you cannot fully exercise from your own test suite: the interesting states — a renewal, a failed card, a cancellation at period end — are produced by Stripe's clock, not yours. This page is how to drive them.
Test keys and live keys are the same code
Every deployment target runs the same compiled Worker. Nothing about test mode versus live mode is a compile-time branch — the key you set decides which Stripe you are talking to, and both Stripe keys and the webhook signing secret are values you never commit.
That means there is exactly one thing to get right: which secret value each target holds.
Named environments
Declare targets with the top-level environments key (see
Multi-Domain Architecture and
D1 environments).
Each target deploys as its own Wrangler Worker script — a project named
storefront with dev and prod targets deploys storefront-dev and
storefront-prod.
That separation is what makes test and live keys safe: Cloudflare scopes secrets to a script, and these are two different scripts. It is real isolation, not a naming convention.
# Test keys on the dev Worker
npx wrangler secret put STRIPE_SECRET_KEY --env dev # sk_test_…
npx wrangler secret put STRIPE_WEBHOOK_SECRET --env dev # whsec_… (dev endpoint)
# Live keys on the prod Worker
npx wrangler secret put STRIPE_SECRET_KEY --env prod # sk_live_…
npx wrangler secret put STRIPE_WEBHOOK_SECRET --env prod # whsec_… (prod endpoint)Point a separate Stripe webhook endpoint at each target's hostname. Each
endpoint has its own signing secret, so the two STRIPE_WEBHOOK_SECRET values
differ even though the two sk_ keys differ for a different reason.
A target's requiredSecrets list must equal the generated runtime secret
inventory — the compiler rejects a mismatch. It is a per-target copy of the
same list of names; it is not a way to require different secrets in dev than
in prod. Dev and prod differ only in the values you wrangler secret put.
Without named environments there is one Worker and one set of secrets, so switching to live keys means overwriting the test ones. Add the second target before you take real money.
Local development
wrangler dev reads .dev.vars, not .env. It is gitignored:
STRIPE_SECRET_KEY=sk_test_…
STRIPE_WEBHOOK_SECRET=whsec_…Use a test key here, always. .dev.vars is the file most likely to be read
aloud in a screenshare.
For a project with named environments, the generated npm run dev already
selects the dev target (wrangler dev --env dev).
Getting a legible failure instead of an opaque one
Neither Stripe secret is checked by default. A Worker with no STRIPE_SECRET_KEY
starts happily and throws on the first call that reaches services.stripe; a
missing STRIPE_WEBHOOK_SECRET makes the inbound route return 500.
Declaring the key under bindings.secrets with
required: true adds it to the generated env guard:
bindings: {
secrets: [
{ name: "STRIPE_SECRET_KEY", description: "Stripe API key", required: true },
],
}Be precise about what that buys, because it is less than it sounds:
- It is not a startup check. The guard runs inside
fetch, on the first request the Worker serves, and memoizes the result. A misconfigured Worker deploys successfully and looks healthy until someone calls it. - What you get is a legible failure:
503withcode: "MISSING_ENV"and amissingarray naming the variable, instead of an opaque500from wherever the unset key was first dereferenced. That is a real improvement — it is just a diagnostics improvement, not a deployment gate. - It wraps
fetchonly. Thequeue,scheduled, andemailhandlers are exported unguarded, so it does not cover the queue-dispatched Stripe path this page is about. Your fulfilment handler still fails at first use, exactly as it would without the declaration.
So declare it — a named 503 beats an anonymous 500 — but do not treat a green
deploy as evidence the key is set. Only a request proves that, and only for the
fetch path.
Only declare a name the compiler is not already emitting. There is no
deduplication, and a name declared twice lands in the generated Env
interface twice — the generated project then fails tsc with Duplicate
identifier.
STRIPE_WEBHOOK_SECRETis emitted whenever webhooks are on. Never declare it.STRIPE_SECRET_KEYis emitted only when the subscriptions plugin is enabled. Declare it only in a webhooks-only project.
Adding a required: true secret also grows the runtime inventory, so every
named environment's requiredSecrets must gain the same name in the same
change.
Forwarding events to localhost
Stripe cannot reach your laptop, so the Stripe CLI relays for it:
stripe listen --forward-to localhost:8787/webhooks/v1/inbound/stripe8787 is the default wrangler dev port; change it if you set dev.port. The
path is the standard inbound route — the same one a dashboard endpoint targets
in production, so local and deployed exercise identical code.
Trigger a specific event without touching the UI:
stripe trigger checkout.session.completedstripe trigger fabricates a plausible event, not one of yours — the
synthetic Checkout Session carries no metadata.orderId, so the
one-off checkout handler correctly
ignores it. That is the handler working, not failing. To exercise the real
path, create a session through your own action and pay it with card
4242 4242 4242 4242.
The two STRIPE_WEBHOOK_SECRETs
This is the single most common way to lose an afternoon.
| Source | Scope | Where it belongs |
|---|---|---|
Printed by stripe listen on startup | That CLI session only. New secret each time in some setups; it does not match any dashboard endpoint. | .dev.vars, while the CLI is running |
| Shown on a dashboard webhook endpoint | That endpoint, permanently | wrangler secret put --env <target> for the Worker that endpoint points at |
They are not interchangeable. Putting a dashboard endpoint's secret in
.dev.vars while forwarding with stripe listen produces 401 Unauthorized on
every delivery, from a correctly-signed request — the signature is valid, just
against a different secret. Restarting stripe listen without updating
.dev.vars fails the same way.
If local deliveries 401, check that pairing before anything else.
Test clocks
Test clocks are how you test the parts of a subscription that only happen with the passage of time — renewal, trial expiry, dunning after a failed payment, cancellation at period end. They are test mode only.
A customer created on a clock experiences time as the clock reports it. Advancing the clock makes Stripe generate the invoices and events it would have generated over that interval, delivered to your webhook endpoint exactly as real ones are.
# 1. A clock starting now
stripe test_helpers test_clocks create --frozen-time $(date +%s)
# 2. A customer bound to it
stripe customers create --test-clock clock_… --email dev@example.com
# 3. Subscribe them (or run your own checkout flow for that customer),
# then jump past the period end — 32 days here
stripe test_helpers test_clocks advance --id clock_… --frozen-time $(( $(date +%s) + 32*86400 ))With stripe listen running, invoice.paid and customer.subscription.updated
arrive at your local Worker and the
subscriptions plugin's
handlers extend the subscription — the renewal path, which is otherwise
untestable without waiting a month.
Advancing is asynchronous: the clock reports advancing before ready, and
events arrive over several seconds. If nothing shows up, confirm the clock
reached ready before concluding the handler is broken.
Useful things to drive this way:
| Scenario | How |
|---|---|
| Renewal | Advance past current_period_end → invoice.paid |
| Trial ending | Advance past the trial → subscription leaves trialing |
| Failed renewal | Attach card 4000 0000 0000 0341, then advance → invoice.payment_failed, status past_due |
| Cancel at period end | Cancel with cancel_at_period_end, advance → customer.subscription.deleted |
That last row is worth confirming by hand at least once: entitlements are
supposed to survive until the period actually ends, so a correct system keeps
the subscriber active right up until the deleted event.
A checklist before going live
- Live
STRIPE_SECRET_KEYset on the prod target — and it startssk_live_. - A dashboard webhook endpoint on the prod hostname, subscribed to every event your handlers register for.
- That endpoint's signing secret set as
STRIPE_WEBHOOK_SECRETon the prod target — not the dev endpoint's, not the CLI's. - Every tier's
priceIdpoints at a live-mode price. Test-mode price ids do not exist in live mode, so checkout fails and, on the inbound side, an unrecognized price is logged and not granted. - One real transaction, refunded afterwards. There is no test mode for the thing you skipped.
See also
- Payments overview — what is native, and what is not.
- One-off checkout — the recipe this loop tests.
- Subscriptions Plugin — tiers, portal, renewals.
- Inbound Webhooks — verification, dedup, retries.