# The Stripe Dev Loop

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](/configure/domains) and
[D1 environments](/platform/database/d1#named-cloudflare-deployment-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.

```bash
# 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:

```bash title=".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`](/configure/bindings#secrets) with
`required: true` adds it to the generated env guard:

```ts
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](/platform/auth/plugins/subscriptions) 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:

```bash
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:

```bash
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](/platform/payments/one-off-checkout) 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_SECRET`s

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.

```bash
# 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](/platform/auth/plugins/subscriptions#webhook-wiring)
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

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](/platform/auth/plugins/subscriptions#webhook-wiring).
5. One real transaction, refunded afterwards. There is no test mode for the
   thing you skipped.

## See also

- [Payments overview](/platform/payments) — what is native, and what is not.
- [One-off checkout](/platform/payments/one-off-checkout) — the recipe this loop tests.
- [Subscriptions Plugin](/platform/auth/plugins/subscriptions) — tiers, portal, renewals.
- [Inbound Webhooks](/platform/webhooks/inbound) — verification, dedup, retries.
