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, features and limits. 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 catalogue’s English strings are what the API and the Astro pricing page render. The app renders a plan’s name, blurb and feature list from Paraglide messages in English and German (planLabel, planDescription and planFeatures in apps/web/src/lib/i18n.ts). Prices and limits live in the catalogue alone. Plan copy lives in both places, so edit both.

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 }, including the non-entitling states the plugin’s own subscription/list hides. overLimit is [{ kind: 'workspaces' | 'members' | 'aiCredits', current, limit }]: 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.

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.

A daily job at 04:47 UTC asks Stripe what is true and repairs the local row: status, period and seat count. The plugin’s webhook handlers log a failure and still answer 200, so Stripe never retries; the daily job covers a lost delivery. Deliveries are recorded in webhook_event and deduplicated, so a “resend” from the dashboard does not produce a second dunning email or a second audit row.

Limits are enforced in the router (workspaces, AI credits, storage) and in organization hooks (seats). See Organizations, AI credits and the storage quota. Credits per job kind (jobCredits) live next to the plans in the catalog.

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).
  2. Set STRIPE_SECRET_KEY, STRIPE_PRICE_PRO_MONTHLY, STRIPE_PRICE_TEAM_MONTHLY and STRIPE_PRICE_TEAM_SEAT in the root .env.
  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.

Annual prices and a second currency are one field each in the catalog and annualDiscountPriceId in the server mapping. 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.