Quickback Docs
Plugins

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.

FieldRequiredMeaning
priceIdYes, except on the first tierStripe Price ID. Checkout maps a tier to this; webhooks map it back.
nameNo (defaults to the tier key)Display name, returned by GET /subscriptions/tiers.
limitsNo (defaults to {})Arbitrary numeric entitlements, stored on the subscription row. Use -1 for unlimited.
trialDaysNoTrial 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
  • 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:

SecretUsed for
STRIPE_SECRET_KEYOutbound Stripe API calls — creating Checkout and billing-portal sessions.
STRIPE_WEBHOOK_SECRETVerifying the HMAC signature on inbound Stripe events.
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).

MethodPathAccess
GET/subscriptions/tiersPublic — the configured tiers and their limits
GET/subscriptions/meAuthenticated — the caller's effective subscription
POST/subscriptions/checkoutAuthenticated — creates a Stripe Checkout Session
POST/subscriptions/portalAuthenticated — creates a Stripe billing-portal session
GET/subscriptionsAdmin, or org-scoped with ?organizationId=
POST/subscriptionsAdmin
POST/subscriptions/updateAdmin — takes { id, ... } in the body
POST/subscriptions/deleteAdmin — 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

StatusGrants
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:

expiresAtGrants
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

TransportTier rolesFreshness
Session cookie✓ live lookupImmediate — a downgrade bites on the next request
JWT bearer✓ from the token's tiers claimStale 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:

EventEffect on subscription state
checkout.session.completedCreates (or upgrades) the subscription, stamping tier, customer and price
customer.subscription.updatedRe-resolves the tier from the price; updates status, period end, cancellation
customer.subscription.deletedMarks cancelled and soft-deletes
invoice.paidMarks active and extends expiresAt to the newly paid period — the renewal path
invoice.payment_failedMarks 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:

// 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/stripe

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

On this page