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 alongside user and session: entitlements are an access-control concern, not domain data. See Where the table lives for what that means on each database provider.
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 database provider has no subscriptions implementation —
cloudflare-d1,neonandplanetscale-postgresdo - the provider is Neon in the deprecated
connectionMode: 'websocket', whose database module emits no service-role handle
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.
Where the table lives
One table, two dialects, selected at compile time. src/plugins/subscriptions/schema.ts re-exports whichever one your database provider uses, so drizzle-kit, the schema barrel and the runtime all resolve to a single definition.
| Provider | Table | Handle |
|---|---|---|
cloudflare-d1 | subscriptions in the auth D1 database (AUTH_DB) | drizzle(env.AUTH_DB) |
neon, planetscale-postgres | auth.subscriptions, in the same Postgres database and the same migration journal as everything else | the database module's internal service-role handle |
Column names, nullability and the JS values you read back are identical across the two. Timestamps are integer epoch-milliseconds on SQLite and timestamptz on Postgres; both hand your code a Date. limits is a JSON string on both.
On Postgres it is a SYSTEM table
auth.subscriptions carries the same shape as the webhook store and the rate-limit counters: RLS enabled and forced, an anonymous-denial policy, and the role bootstrap's service-role policy. There is no caller scope policy, so a request-scoped database handle reads zero rows from it — including the caller's own — and is denied every write with 42501.
That is not a restriction on the API: every reader and writer of entitlement state already runs on the internal service lane. The Stripe queue consumer has no caller context at all, the admin endpoints run after a platform-admin check, and tier-role resolution runs inside the auth middleware. Authorization for the endpoints above is unchanged — session, admin role and organization membership, checked before the database is touched. What the missing scope policy buys is that a bug in an unrelated route cannot read or mint a paid entitlement through a caller handle.
Neon's deprecated connectionMode: 'websocket' is rejected for the same reason webhooks are: its database module emits no createServiceDb.
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 your dialect's table fromlib/, so drizzle-kit, the schema barrel and the runtime all resolve to one definition. See Where the table lives.src/plugins/subscriptions/index.ts— the plugin with your tiers, database handle and org-membership lookup already bound.- A migration creating the subscriptions table — in the auth D1 database, or as
auth.subscriptionsin the single Postgres journal with service-role-only RLS. - 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.