# Subscriptions Plugin (Stripe)

The subscriptions plugin adds subscription tiers and Stripe billing to Better Auth. The compiler configures it from `quickback.config.ts`, emits it into your project as source, and wires its state-sync handlers onto the [inbound webhook surface](/platform/webhooks/inbound) — so Stripe events reach your database through one verified, deduplicated path.

The subscriptions table lives in the auth database, alongside `user` and `session`: entitlements are an access-control concern, not domain data.

## Enable it

```ts
import { defineAuth, defineConfig, defineDatabase, defineRuntime } from "@quickback/compiler";

export default defineConfig({
  name: "my-app",
  providers: {
    runtime: defineRuntime("cloudflare"),
    database: defineDatabase("cloudflare-d1", {
      // REQUIRED: subscription state only ever changes via inbound Stripe events.
      webhooksBinding: "WEBHOOKS_DB",
    }),
    auth: defineAuth("better-auth", {
      emailAndPassword: { enabled: true },
      plugins: {
        subscriptions: {
          tiers: {
            free: { name: "Free", limits: { projects: 3, storage: 1000 } },
            pro: {
              name: "Pro",
              priceId: "price_1234",
              trialDays: 14,
              limits: { projects: 100, storage: 100000 },
            },
            enterprise: {
              name: "Enterprise",
              priceId: "price_5678",
              limits: { projects: -1, storage: -1 },
            },
          },
        },
      },
    }),
  },
});
```

## Tiers are configuration, and the compile fails without them

There are no default tiers. A tier with no Stripe price is a tier checkout cannot charge for, so the compiler rejects the config instead of shipping something that looks enabled and cannot take money.

**Declaration order is meaningful: lowest → highest.** The first tier is the base tier — what a caller with no subscription row gets — and it is the only one that may omit `priceId`.

| Field | Required | Meaning |
|-------|----------|---------|
| `priceId` | Yes, except on the first tier | Stripe Price ID. Checkout maps a tier to this; webhooks map it back. |
| `name` | No (defaults to the tier key) | Display name, returned by `GET /subscriptions/tiers`. |
| `limits` | No (defaults to `{}`) | Arbitrary numeric entitlements, stored on the subscription row. Use `-1` for unlimited. |
| `trialDays` | No | Trial length passed to Stripe Checkout. |

These are compile errors, not warnings:

- the plugin is enabled with no `tiers`
- any tier after the first has no `priceId`
- two tiers share a `priceId` (webhooks resolve a tier *by* price, so it would be ambiguous)
- `webhooksBinding` is not set — see [Webhook wiring](#webhook-wiring)
- the auth database is not Cloudflare D1 (the table ships in the SQLite dialect)

## Secrets

Both are secrets, not vars — set them with `wrangler secret put`:

| Secret | Used for |
|--------|----------|
| `STRIPE_SECRET_KEY` | Outbound Stripe API calls — creating Checkout and billing-portal sessions. |
| `STRIPE_WEBHOOK_SECRET` | Verifying the HMAC signature on inbound Stripe events. |

```bash
npx wrangler secret put STRIPE_SECRET_KEY
npx wrangler secret put STRIPE_WEBHOOK_SECRET
```

Use test keys in a dev environment and live keys in production; named environments keep them separate.

## Endpoints

All mounted under your auth base path (`/auth/v1` by default).

| Method | Path | Access |
|--------|------|--------|
| `GET` | `/subscriptions/tiers` | Public — the configured tiers and their limits |
| `GET` | `/subscriptions/me` | Authenticated — the caller's effective subscription |
| `POST` | `/subscriptions/checkout` | Authenticated — creates a Stripe Checkout Session |
| `POST` | `/subscriptions/portal` | Authenticated — creates a Stripe billing-portal session |
| `GET` | `/subscriptions` | Admin, or org-scoped with `?organizationId=` |
| `POST` | `/subscriptions` | Admin |
| `POST` | `/subscriptions/update` | Admin — takes `{ id, ... }` in the body |
| `POST` | `/subscriptions/delete` | Admin — soft delete, takes `{ id }` in the body |

The two admin mutations are `POST` with the id in the **body**, not `PATCH`/`DELETE` on `/subscriptions/:id`. That matches the rest of the Better Auth surface, which is POST-with-body throughout; the generated OpenAPI describes them the same way.

`GET /subscriptions/me` returns the base tier with `isDefault: true` when the caller has no subscription row, so clients never have to special-case "not subscribed".

Checkout takes the tier name, not a price:

```json
POST /auth/v1/subscriptions/checkout
{ "tier": "pro" }
```

Pass `organizationId` to buy on behalf of an organization. That path requires the [organization plugin](/platform/auth/plugins) and checks the caller's membership role (`owner`, `admin` or `member` by default).

## Org vs personal: which subscription applies

A caller can have both a personal subscription and membership in a subscribed organization. One rule decides:

> **The active organization's subscription wins. Personal is the fallback.**

- Active organization set, and that org has a subscription → the **org** subscription applies.
- Active organization set, but the org has no subscription → the caller's **personal** subscription applies, so switching into an unpaid org never downgrades a paid user.
- No active organization → the caller's **personal** subscription applies.

If a subject somehow has two live rows — the admin create endpoint does not check, and a checkout race can produce one — the winner is decided explicitly, not left to the database: a granting row beats a non-granting one, and among equals the most recently started wins. So starting a new checkout never knocks out the subscription you are currently paying for.

It is deliberately not "whichever tier is higher". Setting an active organization is the caller stating which context they are acting in, and a max-tier rule would let one member's personal Pro plan quietly unlock an organization that is on Free.

## Entitlements: gating access on tier

Every tier becomes a role string — `pro` becomes `tier_pro` — which the auth middleware stamps into the caller's roles on each request. Access rules then gate on it through the ordinary [`authz.roles`](/define/authz-roles#subscription-tiers) machinery; there is no separate entitlements API to call and nothing to check inside your handlers.

```typescript
// quickback.config.ts
export default defineConfig({
  // ...providers, auth plugins as above...
  authz: {
    roles: {
      paid: { roles: ['tier_pro'] },
    },
  },
});
```

```typescript
// features/reports/actions/forecast.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Generate the revenue forecast. Paid plans only.",
  input: z.object({ months: z.number().int().min(1).max(24) }),
  access: { roles: ['paid'] },
  async execute({ input }) {
    return { months: input.months, forecast: [] };
  },
});
```

### Which statuses grant

| Status | Grants |
|--------|--------|
| `active` | ✓ |
| `trialing` | ✓ — a trial is a subscription, so trials reach paid surfaces |
| `past_due` | ✗ |
| `cancelled` / `canceled` | ✗ |
| `incomplete`, `unpaid`, anything else | ✗ |
| No subscription row at all | ✗ |

Everything not on the granting list denies, including states Stripe adds later and a tier name no longer in your config. A failed lookup denies too — a database error must never accidentally grant a paid entitlement.

### Expiry is the second bound

Status alone is only ever as current as the last webhook you successfully processed, and there are three ways it can freeze at `active` with nothing left to revoke it: Stripe retries a failing webhook for about three days and then **permanently gives up**; a Stripe status this plugin does not recognise leaves the current one untouched; and a subscription event carrying no period end leaves `expiresAt` unchanged.

So a granting status is checked against the row's `expiresAt` as well:

| `expiresAt` | Grants |
|---|---|
| In the future | ✓ |
| Up to **48 hours** past | ✓ — the grace window |
| More than 48 hours past | ✗, whatever the status says |
| `null` | ✓ — admin-created rows carry no Stripe period |

The 48-hour grace window is not slack, it is the renewal boundary: Stripe's `invoice.paid` for the new period lands minutes to hours after the old one ends, and until it does the row still carries the previous `expiresAt`. A strict check would false-deny every paying customer at every renewal. Forty-eight hours absorbs that plus a webhook backlog while still bounding a stuck row.

A subscription stuck at `active` with an `expiresAt` three months in the past therefore grants nothing — which is the case this bound exists for.

Note the asymmetry with cancellation: `cancel_at_period_end` leaves the row `active`, so a customer who cancels keeps their entitlements until Stripe sends `customer.subscription.deleted`. That is correct — they paid through the period.

### Tier order is the hierarchy

A granting subscription stamps its own tier **and every tier below it** in declared order. With `free → pro → enterprise`, an Enterprise subscriber carries `['tier_enterprise', 'tier_pro', 'tier_free']`, so `roles: ['tier_pro']` reads as "pro or better" and you never configure a hierarchy.

A caller with **no** subscription gets nothing — not even `tier_free`. The base tier is what checkout sells, not what a signed-up user is. Use `AUTHENTICATED` for "any logged-in user"; reserve `tier_free` for callers who actually hold a free-tier row.

### Which transports carry tier roles

| Transport | Tier roles | Freshness |
|-----------|-----------|-----------|
| Session cookie | ✓ live lookup | Immediate — a downgrade bites on the next request |
| JWT bearer | ✓ from the token's `tiers` claim | Stale until the token expires — `auth.jwt.expiresIn` (default 180s), or up to **15 min + one TTL** on a scope-bearing token |
| API key | ✗ none | — |
| OAuth bearer | ✗ none | — |
| Scope-only / anonymous | ✗ none | — |

The session path reads the subscriptions table and bakes the result into the JWT it mints, so the fast path costs no database round trip. That is the same tradeoff organization roles already make, and the reason `authMethod: 'session'` exists for callers who need a revocation to be immediate. The lag cuts both ways — a tier *upgrade* is invisible for just as long, so re-authenticate after checkout rather than waiting out the TTL.

How long the lag actually is depends on the token:

- **A plain bearer** is stale for at most one `auth.jwt.expiresIn` (default 180s).
- **A scope-bearing token** is stale for up to **`SCOPE_CARRY_FORWARD_MAX_SECONDS` (15 min) plus one TTL** — roughly 18 minutes at the default. The middleware's rolling refresh re-mints such a token on every authenticated call and carries the `tiers` claim forward verbatim, exactly as it carries the scope claim, so tier roles ride the refresh without a fresh subscription lookup.

> `auth.jwt.expiresIn` has **no upper bound**. It is the entitlement revocation window as much as the auth one — a 24-hour TTL means a cancelled customer keeps paid access for up to 24 hours on the JWT transport. If you raise it, raise it knowing that.


API keys and OAuth bearers stamp **nothing**, deliberately. A key is an integration credential rather than a person: it may be issued to a user who holds a subscription, but granting a paid entitlement to a long-lived server key is invisible at issue time and revoking the subscription would not revoke the key. A caller that needs tier-gated routes should authenticate with a session, or with a JWT minted from one.

Org-vs-personal precedence applies here exactly as it does everywhere else — the active organization's subscription is what gets stamped. See below.

## Webhook wiring

The plugin ships **no webhook route of its own**. Stripe events arrive on the standard inbound surface, which verifies the signature and deduplicates on `(provider, externalId)`, then dispatches through `WEBHOOKS_QUEUE`.

Delivery is **at-least-once, not exactly-once**: dedup stops Stripe redelivering an event you already accepted, but a handler that throws makes the queue retry the whole message, re-running every handler registered for it. The bundled handlers are idempotent — they upsert on `(userId, organizationId)` or look the row up by `stripeSubscriptionId` — so a replay converges instead of duplicating. Any handler you add for `stripe:*` needs the same property.

Point a Stripe webhook endpoint at:

```
POST https://<your-api-domain>/webhooks/v1/inbound/stripe
```

and subscribe it to the events the plugin consumes:

| Event | Effect on subscription state |
|-------|------------------------------|
| `checkout.session.completed` | Creates (or upgrades) the subscription, stamping tier, customer and price |
| `customer.subscription.updated` | Re-resolves the tier from the price; updates status, period end, cancellation |
| `customer.subscription.deleted` | Marks cancelled and soft-deletes |
| `invoice.paid` | Marks active and extends `expiresAt` to the newly paid period — the renewal path |
| `invoice.payment_failed` | Marks `past_due` |

Without these, a subscription is created at checkout and then never renews, downgrades or cancels — which is why `webhooksBinding` is a compile-time requirement rather than a runtime warning.

An event whose Stripe price maps to no configured tier is **logged and not granted**. That is deliberate: guessing a tier from an unrecognized price can only guess upward. If subscriptions stop activating after a pricing change, check that every tier's `priceId` matches the live Stripe price.

Cancellation is not immediate. `cancel_at_period_end` leaves the subscription `active` — the customer paid through the period — and entitlements end when Stripe sends `customer.subscription.deleted`.

State sync runs on the queue, which is correct for entitlement propagation. Anything that must post to a ledger synchronously belongs in an action, not a webhook handler.

> An inbound event is recorded and deduplicated **before** it is queued, so if the queue send itself fails, Stripe's redelivery is suppressed for that event ID. Replay it with the inbound retry endpoint (`POST /webhooks/v1/inbound/events/:id/retry`). This is inbound-surface behaviour and applies to every provider, not just Stripe.


## What the compiler generates

1. `src/plugins/subscriptions/lib/` — the plugin itself, emitted as source. It is **not** an npm dependency; the only package added to your `package.json` is `stripe`.
2. `src/plugins/subscriptions/schema.ts` — a re-export of the table in `lib/`, so drizzle-kit, the auth schema barrel and the runtime all resolve to one definition.
3. `src/plugins/subscriptions/index.ts` — the plugin with your tiers, auth database and org-membership lookup already bound.
4. A migration creating the `subscriptions` table in the auth database.
5. Registration of the Stripe handlers into the webhooks queue consumer.
6. `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` on the generated `Env` type.

Everything under `src/` is generated — change tiers in `quickback.config.ts` and recompile. That includes `lib/`: the plugin ships with the compiler that configured it, so the tier-to-role rule and the auth middleware reading those roles always come from one version. Upgrades arrive by recompiling, not by `npm update`.

## Limits name your own resources

`limits` is an open map of numbers — the plugin stores it on the subscription row and returns it from `GET /subscriptions/me`; it never enforces anything itself. The keys are yours, and they are only meaningful if they refer to something real. The tiers above cap `projects`, so the project table is what they are talking about:

```ts
// features/projects/projects.ts
import { q, defineTable } from "@quickback/compiler";

export const projects = q.table("projects", {
  id: q.uuid("id").primaryKey(),
  owner_id: q.uuid("owner_id").notNull(),
  name: q.text("name").notNull(),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(projects, {
  read: { access: { roles: ["AUTHENTICATED"] } },
  crud: {
    create: { access: { roles: ["AUTHENTICATED"] } },
    update: { access: { roles: ["AUTHENTICATED"] } },
    delete: { access: { roles: ["AUTHENTICATED"] } },
  },
});
```

To enforce a limit, read `limits` from `GET /subscriptions/me` (or query the subscriptions table directly in an action) and compare it against a count before creating the row. Enforcement is yours to write until tier-aware access rules land; the plugin's job is to make the caller's current tier and its limits available.

## Local development

Forward Stripe events to a local `wrangler dev`:

```bash
stripe listen --forward-to localhost:8787/webhooks/v1/inbound/stripe
```

`stripe listen` prints a signing secret for the session — use it as `STRIPE_WEBHOOK_SECRET` in `.dev.vars`.
