Subscriptions Plugin (Stripe)
Subscription tiers, Stripe Checkout, the billing portal, and state sync through the inbound webhook surface.
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 — 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
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) webhooksBindingis not set — see 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. |
npx wrangler secret put STRIPE_SECRET_KEY
npx wrangler secret put STRIPE_WEBHOOK_SECRETUse 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:
POST /auth/v1/subscriptions/checkout
{ "tier": "pro" }Pass organizationId to buy on behalf of an organization. That path requires the organization plugin 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 machinery; there is no separate entitlements API to call and nothing to check inside your handlers.
// quickback.config.ts
export default defineConfig({
// ...providers, auth plugins as above...
authz: {
roles: {
paid: { roles: ['tier_pro'] },
},
},
});// 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 thetiersclaim 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/stripeand 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
src/plugins/subscriptions/lib/— the plugin itself, emitted as source. It is not an npm dependency; the only package added to yourpackage.jsonisstripe.src/plugins/subscriptions/schema.ts— a re-export of the table inlib/, so drizzle-kit, the auth schema barrel and the runtime all resolve to one definition.src/plugins/subscriptions/index.ts— the plugin with your tiers, auth database and org-membership lookup already bound.- A migration creating the
subscriptionstable in the auth database. - Registration of the Stripe handlers into the webhooks queue consumer.
STRIPE_SECRET_KEYandSTRIPE_WEBHOOK_SECRETon the generatedEnvtype.
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:
// 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:
stripe listen --forward-to localhost:8787/webhooks/v1/inbound/stripestripe listen prints a signing secret for the session — use it as STRIPE_WEBHOOK_SECRET in .dev.vars.