Quickback Docs

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:

.dev.vars
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: 503 with code: "MISSING_ENV" and a missing array naming the variable, instead of an opaque 500 from wherever the unset key was first dereferenced. That is a real improvement — it is just a diagnostics improvement, not a deployment gate.
  • It wraps fetch only. The queue, scheduled, and email handlers 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_SECRET is emitted whenever webhooks are on. Never declare it.
  • STRIPE_SECRET_KEY is 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/stripe

8787 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.completed

stripe 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.

SourceScopeWhere it belongs
Printed by stripe listen on startupThat 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 endpointThat endpoint, permanentlywrangler 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:

ScenarioHow
RenewalAdvance past current_period_endinvoice.paid
Trial endingAdvance past the trial → subscription leaves trialing
Failed renewalAttach card 4000 0000 0000 0341, then advance → invoice.payment_failed, status past_due
Cancel at period endCancel 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

  1. Live STRIPE_SECRET_KEY set on the prod target — and it starts sk_live_.
  2. A dashboard webhook endpoint on the prod hostname, subscribed to every event your handlers register for.
  3. That endpoint's signing secret set as STRIPE_WEBHOOK_SECRET on the prod target — not the dev endpoint's, not the CLI's.
  4. Every tier's priceId points 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.
  5. One real transaction, refunded afterwards. There is no test mode for the thing you skipped.

See also

On this page