Skip to content

Billing

Billing uses Stripe through @better-auth/stripe, with the organization as the customer. Everything is in place but dormant until STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET and the STRIPE_PRICE_* ids are set. Without them nothing is capped and /app/billing says so.

packages/billing/src/catalog.ts is browser-safe: plan names, advertised prices, limits, features and highlights. The API, /app/billing and the public site’s /pricing page all render from it, so every surface advertises the same plans. The server half, @repo/billing, maps plans to Stripe Price ids from env and holds the SDK client. Frontends never import it.

Plan Price Workspaces Members AI credits / month Storage / workspace
Free free 1 3 50 100 MB
Pro $12 / month, 14-day trial 10 10 2,000 5 GB
Team $29 / month + $8 per member / month 100 100 20,000 50 GB

Keep the amounts in sync with the Stripe dashboard. Prices are advertised from code. Storage is the one per-workspace limit (storageBytes); the others are per organization.

The catalog is the single source of what a plan is:

  • PLAN_NAMES, cheapest first, and plans in the same order.
  • limitDefinitions: each limit’s scope (organization or workspace), unit (count or bytes), whether it resets monthly, and its English label. The contract’s PlanLimitsSchema, the LIMIT_REACHED kinds, billing.status.overLimit, the organizations.usage counters, the API’s limit messages, /pricing and /app/billing are all built from it.
  • featureDefinitions: what a plan can unlock beyond its limits, keyed by a name of your choosing. Each plan lists the keys it includes in features, which the API enforces (see Selling a feature on a plan). The kit defines none, so every plan has the same features.
  • highlights: a plan’s marketing bullets. A bullet names what every organization on the plan gets. One that names a feature a cheaper plan lacks must match a key in features, or it is a promise the code does not keep: teams and the audit log are listed on Free because every plan has them, and the paid plans add support and per-seat billing.
  • jobCredits and jobCreditLabels: what each kind of AI work costs, and what /pricing calls it.

The catalogue’s English strings are what the API and the Astro pricing page render. The app renders a plan’s name, blurb and highlights from Paraglide messages in English and German (planLabel, planDescription and planHighlights in apps/web/src/lib/i18n.ts). Prices and limits live in the catalogue alone. Plan copy lives in both places, so edit both.

A limit is five pieces, and bun run check fails while any of the first four is missing:

  1. An entry in limitDefinitions:

    projects: { scope: 'organization', unit: 'count', label: { one: 'project', other: 'projects' } },
  2. A value in every plan’s limits.

  3. For an organization-scoped limit, a counter in productUsageCounters (packages/db/src/product-usage.ts), which organizations.usage and overLimit read.

  4. Its labels in the app’s language, in productLabels.limits (apps/web/src/lib/product-labels.ts): a title and a pluralised value, from your messages in product.json.

  5. withLimit(organizationId, 'projects', { current, write }) in the procedure that creates one. That is the check itself, so nothing reminds you of it.

productLabels also labels your visible job kinds (PRODUCT_JOB_KINDS) and your features. Both product files are yours: kit releases never change them. catalog.ts is a kit file you edit, so a kit release that changes it can conflict with your plans and prices.

The shared roles carry a billing statement: read for every member (see the plan), manage for owners and admins (upgrade, cancel, restore, customer portal). The plugin’s authorizeReference and the UI use the same statement, so the buttons a user sees match what the API allows.

planForOrganization reads the subscription table the plugin maintains from webhooks. Statuses active, trialing and past_due entitle the paid plan. Stripe retries a failed card for days, so access continues while the app shows a “payment failed” banner. unpaid and canceled fall back to Free.

billing.status (GET /api/v1/organizations/{organizationId}/billing) returns { enabled, plan, limits, subscription, paymentFailed, overLimit, features, annualPlans }, including the non-entitling states the plugin’s own subscription/list hides. overLimit is [{ kind, current, limit }] over the organization-scoped limits (workspaces, members, aiCredits and any you add): the plan limits this organization already exceeds after a downgrade. Everything already there stays readable. Writes that would add to those counters are refused. It is empty while billing is off, and storageBytes never appears in it, because that cap is per workspace. /app/billing renders it as a warning listing each overage. features lists the keys the plan includes (empty while billing is off), and annualPlans the plans this deployment sells yearly.

Entitlement is also bounded in time: active and trialing stop entitling three days past periodEnd, and past_due stops BILLING_DUNNING_DAYS (14 by default; 0 revokes at period end) past it. What Stripe does once its retries run out (cancel, mark unpaid, or leave the subscription past_due) is a setting in your dashboard, and this bound covers the last case.

After the plugin verifies each webhook signature, Starterdough repairs the entitlement through an additional idempotent write. Persistence failures return a non-success response so Stripe can retry. Update and checkout events retrieve current Stripe state before writing, preventing a stale payload from restoring an already-canceled plan.

A daily job at 04:47 UTC also repairs status, period and seat count. Incomplete purchases with no local Stripe subscription ID are matched using customer, organization and subscription metadata; ambiguous matches are reported instead of guessed. The browser success return is not required for recovery. Notification and audit effects are separately deduplicated in webhook_event; those effects remain best effort and can be lost if their first attempt fails.

Limits are enforced in the router (workspaces, AI credits, storage) and in organization hooks (seats, and how many free organizations one account may own). See Organizations, AI credits and the storage quota. Credits per job kind (jobCredits) live next to the plans in the catalog.

Gate the feature in the API first, then list it on the plans that include it:

  1. Define it: featureDefinitions = { sso: { label: 'Single sign-on' } }, and add 'sso' to features on each plan that includes it. /pricing and /app/billing list its label after the plan’s highlights, so no highlight needs to repeat it.

  2. Enforce it in the procedure. After the tenant guard, the planFeature middleware (apps/api/src/rpc/plan-features.ts) refuses an organization whose plan lacks it:

    authed.reports.export.use(orgScoped({ report: ['read'] })).use(planFeature('sso'))

    Spread FeatureNotInPlanError (from ./procedures) into the procedure’s .errors(). The refusal is FEATURE_NOT_IN_PLAN (403) with { feature, plan, upgradeTo }, where upgradeTo is the cheapest plan that includes it. Inside a handler or a transaction, call requirePlanFeature(organizationId, 'sso', tx) instead, or requireFeature from @repo/auth/plans, which throws PlanFeatureError.

  3. Label it for the app in productLabels.features. The billing page lists it on each plan card.

  4. In the app, featureAccess(billing, 'sso') from #lib/plans.js says whether the plan includes it and which plan to upgrade to. Hide the control, or show PlanUpsell with that upgradeTo in its place, which links to /app/billing?plan=<plan>. After a refused call, isFeatureNotInPlan(error) gives you error.data.upgradeTo for the same component.

Hiding is not a check: the procedure refuses on its own. With billing off nothing is refused and featureAccess makes everything available, as with the limits. The public demo runs without Stripe, so its API allows everything, while its sandboxes show the sample plan’s features.

A flat-priced plan can also be sold yearly. The kit ships monthly prices only. Selling Pro yearly takes both of these, or neither:

  1. annual: 12000 (minor units, so $120 a year) in Pro’s pricing in the catalog.
  2. A recurring yearly Price in Stripe, with its id in STRIPE_PRICE_PRO_ANNUAL.

With both, billing.status.annualPlans lists pro, /app/billing shows a Monthly/Yearly switch above the plan cards, and checkout passes annual: true, which the Stripe plugin turns into the yearly price (annualDiscountPriceId). An organization on Pro can switch between the two periods from its card. /pricing shows “or $120 / year” under the plan. It reads the catalog only, because it is a static page that cannot see env, so an annual without its price id advertises what the app cannot sell.

Per-seat plans stay monthly: the plugin bills a plan’s seat price monthly, and Stripe Checkout refuses a subscription that mixes intervals, so annual on a per-seat plan sells nothing. Selling Team yearly is a product decision: add a separate plan with a yearly base price and a yearly seat price. A plan other than Pro needs its own variable, read in priceIds in packages/billing/src/index.ts and declared in packages/env.

A Stripe Price’s amount never changes, so raising a plan’s price means a new Price, with its id in the plan’s STRIPE_PRICE_* and a restart of the API. Checkouts from then on take it. What happens to the subscriptions already on the old price is your choice:

  • Keep them on it. Create the new Price on the plan’s existing Product. A subscription left on the old Price is recognised by that Product: its webhooks and the daily reconcile keep its status and period current, and it keeps its plan. The API asks Stripe which Product each configured Price belongs to once per process, and only for a subscription on a price no plan names. Keep one Product per plan: a Product two plans’ prices share names neither.
  • Move them to the new price, in the dashboard or with the API (the subscription item’s price), then change the variable. Stripe prorates the change unless told not to.

The per-seat price, STRIPE_PRICE_TEAM_SEAT, cannot be kept. Whenever a member joins or leaves, the plugin sets the member count on the subscription’s item at the configured seat price, and adds such an item to a subscription that has none, which would bill the old seat item and the new one. Move every Team subscription’s seat item to the new Price before you change the variable. Team’s flat price can be kept like any other, with one difference while it is. Better Auth’s Stripe plugin skips its own handling of an update to a subscription of two items it cannot match to a plan. The kit’s recovery still keeps such a subscription entitled and current, but its changes record no subscription.update or subscription.cancel audit entry, send no subscription.updated webhook event, and email billing contacts no cancellation notice. Its deletion is recorded as usual. Move it to the new flat price when those matter.

A subscription on a price that neither a plan nor a plan’s Product names, such as a custom price made in the dashboard under a Product of its own, keeps the plan its row has and stays current, with a warning in the API’s log at each webhook and reconcile. One that has no row, because it was made in the dashboard, gets no plan: its events are acknowledged with that warning, and nothing is granted. Make a custom price under the Product of the plan it sells.

Checkout, the customer portal and cancel/restore are the plugin’s endpoints under /api/auth/subscription/*, called from /app/billing:

  • Checkout creates the organization’s Stripe customer at first checkout (none is created at sign-up) and allows promotion codes. Trials collect a card by default.
  • The plan a visitor picked survives the funnel. /pricing links to /signup?plan=pro, sign-up carries it to /app/billing?plan=pro, and the billing page preselects that card. It starts no checkout by itself. The destination is part of the verification link’s callbackURL, so it survives the round trip through the inbox. An unknown plan id falls back to /app.
  • Per-seat plans keep the seat quantity equal to the member count as people join and leave.
  • Plan changes between paid tiers go through subscription/upgrade (Stripe prorates). Downgrading to Free is “cancel at period end”, which can be restored until then.
  • A downgrade below current usage is refused. Before a plan change the API compares the organization’s usage with the target plan’s limits and answers PLAN_LIMIT_EXCEEDED_BY_CHANGE when it would not fit.
  • Emails for dunning (invoice.payment_failed), cancellation and trial ending go to the organization’s owners and admins. Every subscription change lands in the audit log (without an actor when it comes from a webhook).
  • The sole owner of an organization cannot delete their account while that organization still has other members, nor while either it or a solo organization has a live subscription. See Authentication. Deleting an organization deletes its stored documents with it.

Billing emails link to /app/billing?organization=<id>, which switches the active organization before rendering.

POST ${API_URL}/api/auth/stripe/webhook. Events: checkout.session.completed, customer.subscription.created|updated|deleted|trial_will_end, invoice.payment_failed.

Locally:

Terminal window
stripe listen --forward-to localhost:3000/api/auth/stripe/webhook # prints the signing secret
  1. Create the Products and recurring Prices in your Stripe account so the amounts match the catalog (Pro flat; Team flat + per-seat; a yearly Pro price only if you sell one), one Product per plan, which a later price change keeps (see Changing a price).
  2. Set STRIPE_SECRET_KEY, STRIPE_PRICE_PRO_MONTHLY, STRIPE_PRICE_TEAM_MONTHLY and STRIPE_PRICE_TEAM_SEAT in the root .env, and STRIPE_PRICE_PRO_ANNUAL together with annual on Pro in the catalog (see Annual billing).
  3. Register the webhook endpoint with the events above and set STRIPE_WEBHOOK_SECRET.
  4. Restart the API. /app/billing now sells plans and /admin/system reports billing as enabled (and lists any plan without a configured price).
  5. Decide what Stripe should do when its retries run out (Settings → Billing → Subscriptions and emails): cancel, mark unpaid, or leave the subscription past_due. Set BILLING_DUNNING_DAYS to match.

bun run auth:schema passes placeholder STRIPE_* values so the generated Drizzle schema contains the subscription table on every machine. The plugin itself is only active when the real env is present.

A second currency: the catalog advertises one currency per plan, and each plan has one Stripe price per period. Tax is your responsibility with Stripe (Stripe Tax helps). If you sell worldwide as a solo operator, consider a merchant of record such as Polar before launch.