# Payments

There is no payments *pillar*. Payments are not a security boundary and not a
schema concern, so Quickback ships three concrete pieces rather than an
abstraction over billing:

| Piece | What it is | Where |
|-------|------------|-------|
| Subscriptions plugin | Tiers, Checkout, the billing portal, and subscription state kept in sync by Stripe | [Subscriptions Plugin](/platform/auth/plugins/subscriptions) |
| `services.stripe` | A configured Stripe client on the services layer, reachable from any action | This page |
| `stripe:*` inbound webhooks | Signature-verified, deduplicated Stripe events dispatched to your handlers | [Inbound Webhooks](/platform/webhooks/inbound) |

Everything else — one-off charges, marketplaces, usage billing — is composed
from those three. The [one-off checkout recipe](/platform/payments/one-off-checkout)
shows the composition end to end.

## `services.stripe`

Actions already receive a `services` object ([Executor Parameters](/define/actions/scoped-db#executor-parameters)).
When a project needs Stripe, a configured client appears on it:

```ts
async execute({ services, record }) {
  const session = await services.stripe.checkout.sessions.create({ /* … */ });
}
```

No import, no client construction, no key plumbing.

### When it is emitted

The client appears when **either** of these is true:

1. the [subscriptions plugin](/platform/auth/plugins/subscriptions) is enabled, or
2. the webhooks subsystem is on — that is, `providers.database` declares a
   `webhooksBinding`.

Condition 2 is broader than it may look, and deliberately so. There is no
per-provider inbound webhook configuration surface: the inbound route registers
exactly one provider, Stripe, so *any* project that turned webhooks on is a
project that can receive Stripe events. The same condition adds `stripe` to the
generated `package.json` (pinned at `^22.5.0`), so the dependency and the client
can never disagree about whether Stripe is in play.

> **Accepted over-trigger.** A project that only *receives* Stripe webhooks and
> never calls the API still gets the SDK in its bundle — roughly 130 KB gzipped
> — because inbound signature verification is hand-rolled on `crypto.subtle` and
> does not need the SDK at all. This was taken deliberately: projects receiving
> Stripe events almost always call Stripe from their handlers, and the one-off
> checkout recipe depends on the client being there. If a real project hits
> Worker bundle limits because of it, that is the trigger to narrow the
> condition.


### Cloudflare only, and it fails at compile time

Both triggers are Cloudflare-only surfaces, and the non-Cloudflare
`createServices()` takes no environment to read a key from. So a project that
wants Stripe on another runtime is a **compile error**, not a silently missing
client. The error names which of the two triggers pulled Stripe in.

### Lazy, memoized, and fail-closed on the secret

The client is built on first access and reused for the rest of the request. A
request that never touches Stripe pays nothing; one that touches it repeatedly
builds the client once.

If `STRIPE_SECRET_KEY` is unset, the **first access throws** — it never yields a
half-configured client that fails later at the network boundary:

```
services.stripe requires the STRIPE_SECRET_KEY secret, which is not set.
Set it with `wrangler secret put STRIPE_SECRET_KEY` for a deployed Worker,
or add STRIPE_SECRET_KEY to .dev.vars for local development.
```

Nothing checks the key before that. The Worker starts fine without it and fails
on the first call that reaches Stripe — including one made from a queue handler,
where the failure surfaces as a retried message rather than an HTTP response. See
[the dev loop](/platform/payments/dev-loop#getting-a-legible-failure-instead-of-an-opaque-one)
for how much of that is worth improving, and how little the env guard covers.

> `STRIPE_SECRET_KEY` is added to the generated `Env` type only when the
> **subscriptions plugin** is enabled. A webhooks-only project gets
> `services.stripe` but no `Env` entry for the key — the client reads it off the
> environment regardless. Do not "fix" this by redeclaring the name under
> `bindings.secrets` when the plugin is on: the compiler does not deduplicate,
> and the generated project fails `tsc` with *Duplicate identifier
> 'STRIPE_SECRET_KEY'*.


### The client's configuration

```ts
new Stripe(secretKey, { maxNetworkRetries: 3, timeout: 30_000 })
```

Two things are notable by their absence:

- **No `httpClient`.** On Workers the `workerd` export condition resolves to
  stripe-node's fetch/`SubtleCrypto` build, so `Stripe.createFetchHttpClient()`
  is not needed. Passing it is not an improvement.
- **No `apiVersion`.** stripe-node types `apiVersion` as an exact string literal
  matching the SDK's own pinned version, so hardcoding one breaks `tsc` the next
  time the pin moves. Omitting it uses the SDK's pinned version — the same
  choice Stripe's own Workers template makes. Pin the *SDK*, not the API version
  string.

### Reaching Stripe outside an action

`services` is an action-executor parameter. Queue-dispatched webhook handlers
get a raw `env` instead, and the generated `Env` **is** `CloudflareBindings` —
so `createServices` takes it directly:

```ts
import { createServices } from "../../../lib/services";

onWebhookEvent("stripe:invoice.paid", async (ctx) => {
  const { stripe } = createServices(ctx.env);
});
```

You rarely need this on the inbound path: the surface has already verified the
signature and handed you the parsed envelope. Reach for it when a handler must
call *back* into Stripe — fetching line items an event omits, say.

> If you ever verify a Stripe signature yourself, note that on Workers it is
> async-only: `await stripe.webhooks.constructEventAsync(...)`. The synchronous
> `constructEvent` has no Web Crypto path and throws. The inbound surface
> already does this for you.


## What is not native

| Not built | Why, and what to do instead |
|-----------|------------------------------|
| **Stripe Connect** | Nothing to build. The inbound envelope already preserves the top-level `account` (`acct_…`) — read it via `ctx.event.account` and route on it yourself. See [Stripe Connect](/platform/webhooks/inbound#stripe-connect). |
| **Invoicing** | Stripe's own invoicing API is a better invoicing product than a wrapper over it would be. Call it through `services.stripe`. |
| **A generic payments pillar** | A provider-neutral payments abstraction over one provider is a guess about the second one. There is deliberately no `providers.payments` category; it gets revisited if a second payments provider actually lands. |
| **A one-off payment primitive** | Deliberately a [recipe](/platform/payments/one-off-checkout), not a define surface, until the recipe proves to have a hole in real use. |

## The escape hatch

`services.stripe` is a plain, fully-typed `Stripe` instance. Anything the Stripe
Node SDK can do, an action can do — payment intents, refunds, transfers, Connect
account links, tax calculations — with no Quickback surface in between and no
feature request required.

The rule that constrains it is not about Stripe. It is about *where* money
moves: anything that must post to a ledger synchronously belongs in an action,
where the caller sees the failure, and never in an async queue handler. The
[recipe](/platform/payments/one-off-checkout#the-money-rule) spells this out.

## Next

- [One-off checkout](/platform/payments/one-off-checkout) — charge for a record, then fulfil it.
- [The Stripe dev loop](/platform/payments/dev-loop) — test keys, `stripe listen`, test clocks.
- [Subscriptions Plugin](/platform/auth/plugins/subscriptions) — recurring billing, already built.
