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.
The catalog
Section titled “The catalog”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, andplansin the same order.limitDefinitions: each limit’s scope (organizationorworkspace), unit (countorbytes), whether it resets monthly, and its English label. The contract’sPlanLimitsSchema, theLIMIT_REACHEDkinds,billing.status.overLimit, theorganizations.usagecounters, the API’s limit messages,/pricingand/app/billingare 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 infeatures, 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 infeatures, 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.jobCreditsandjobCreditLabels: what each kind of AI work costs, and what/pricingcalls 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.
Adding a limit
Section titled “Adding a limit”A limit is five pieces, and bun run check fails while any of the first four is missing:
-
An entry in
limitDefinitions:projects: { scope: 'organization', unit: 'count', label: { one: 'project', other: 'projects' } }, -
A value in every plan’s
limits. -
For an organization-scoped limit, a counter in
productUsageCounters(packages/db/src/product-usage.ts), whichorganizations.usageandoverLimitread. -
Its labels in the app’s language, in
productLabels.limits(apps/web/src/lib/product-labels.ts): atitleand a pluralisedvalue, from your messages inproduct.json. -
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.
Who may pay
Section titled “Who may pay”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.
What a plan grants
Section titled “What a plan grants”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.
Selling a feature on a plan
Section titled “Selling a feature on a plan”Gate the feature in the API first, then list it on the plans that include it:
-
Define it:
featureDefinitions = { sso: { label: 'Single sign-on' } }, and add'sso'tofeatureson each plan that includes it./pricingand/app/billinglist its label after the plan’s highlights, so no highlight needs to repeat it. -
Enforce it in the procedure. After the tenant guard, the
planFeaturemiddleware (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 isFEATURE_NOT_IN_PLAN(403) with{ feature, plan, upgradeTo }, whereupgradeTois the cheapest plan that includes it. Inside a handler or a transaction, callrequirePlanFeature(organizationId, 'sso', tx)instead, orrequireFeaturefrom@repo/auth/plans, which throwsPlanFeatureError. -
Label it for the app in
productLabels.features. The billing page lists it on each plan card. -
In the app,
featureAccess(billing, 'sso')from#lib/plans.jssays whether the plan includes it and which plan to upgrade to. Hide the control, or showPlanUpsellwith thatupgradeToin its place, which links to/app/billing?plan=<plan>. After a refused call,isFeatureNotInPlan(error)gives youerror.data.upgradeTofor 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.
Annual billing
Section titled “Annual billing”A flat-priced plan can also be sold yearly. The kit ships monthly prices only. Selling Pro yearly takes both of these, or neither:
annual: 12000(minor units, so $120 a year) in Pro’spricingin the catalog.- 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.
Changing a price
Section titled “Changing a price”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.
/pricinglinks 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’scallbackURL, 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_CHANGEwhen 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.
Webhook
Section titled “Webhook”POST ${API_URL}/api/auth/stripe/webhook. Events: checkout.session.completed,
customer.subscription.created|updated|deleted|trial_will_end, invoice.payment_failed.
Locally:
stripe listen --forward-to localhost:3000/api/auth/stripe/webhook # prints the signing secretGoing live
Section titled “Going live”- 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).
- Set
STRIPE_SECRET_KEY,STRIPE_PRICE_PRO_MONTHLY,STRIPE_PRICE_TEAM_MONTHLYandSTRIPE_PRICE_TEAM_SEATin the root.env, andSTRIPE_PRICE_PRO_ANNUALtogether withannualon Pro in the catalog (see Annual billing). - Register the webhook endpoint with the events above and set
STRIPE_WEBHOOK_SECRET. - Restart the API.
/app/billingnow sells plans and/admin/systemreports billing as enabled (and lists any plan without a configured price). - Decide what Stripe should do when its retries run out (Settings → Billing → Subscriptions and
emails): cancel, mark unpaid, or leave the subscription
past_due. SetBILLING_DUNNING_DAYSto match.
Schema independence
Section titled “Schema independence”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.
Not wired yet
Section titled “Not wired yet”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.