Stripe Billing Integration
Overview
Stripe billing via @better-auth/stripe plugin. Billing is tightly coupled with auth – customer lifecycle, subscription state, and webhook handling are managed by the same system that manages sessions and organizations.
Non-goals: Usage-based billing, metered pricing, one-time payments, invoicing, Stripe Elements/embedded checkout, tax calculation, multi-currency. These can be added incrementally.
Decision Rationale
Better Auth plugin over raw Stripe SDK: RSK already uses Better Auth for auth, organizations, and sessions. The plugin handles customer sync, subscription lifecycle, webhook verification, and org-level billing – eliminating significant glue code.
Hosted Checkout over embedded Elements: Stripe Checkout is PCI-compliant out of the box, requires no @stripe/stripe-js client dependency, and handles payment method selection, 3D Secure, and receipts. The upgrade path to embedded Elements exists but isn't needed initially.
createCustomerOnSignUp: true: Creates Stripe customer records eagerly on signup. Simplifies the upgrade flow and enables Stripe-side analytics. Trade-off: creates unused records for users who never upgrade.
Architecture
┌─────────────┐ POST /api/auth/subscription/upgrade ┌───────────────┐
│ Browser │ ──────────────────────────────────────────→ │ API Worker │
│ (app) │ │ (Hono) │
│ │ ←── 302 redirect │ │
│ │──→ Stripe Checkout (hosted) │ Better Auth │
│ │ │ + stripe() │
│ │ POST /api/auth/stripe/webhook │ plugin │
│ │ │ │
│ │ Stripe ────────→│ webhook ──→ │
│ │ │ update DB │
│ │ GET /api/trpc/billing.subscription │ │
│ │ ──────────────────────────────────────────→ │ tRPC router │
└─────────────┘ ←── subscription data (TanStack Query) └───────────────┘Data flow:
- User clicks "Upgrade" – Better Auth client calls
auth.subscription.upgrade() - Plugin creates Stripe Checkout session – redirects browser to Stripe
- User completes payment – Stripe sends webhook to
/api/auth/stripe/webhook - Plugin verifies signature, updates
subscriptiontable – client refetches via tRPC
Why tRPC for reads, Better Auth client for mutations: Subscription queries benefit from TanStack Query caching, batching, and stale-while-revalidate. The two mutations the app owns – upgrade and portal – go through the auth client because the plugin handles Stripe API calls, session validation, and org authorization internally. Cancellation happens inside the hosted portal, not through a procedure of ours.
Billing Reference
Billing reads use session.activeOrganizationId when present and otherwise fall back to user.id. Stripe mutations pass the active organization explicitly as referenceId with customerType: "organization"; without one, the plugin defaults to the user. The plugin enforces one active subscription per reference ID.
- Organization context:
referenceId = activeOrganizationId– only org owner/admin can manage billing - No active organization:
referenceId = user.id– user manages their own subscription - The plugin authorizes explicit organization references against membership role, but only for its own mutation endpoints
billing.subscriptionis application-owned, so it re-checks membership againstmemberbefore reading, and throwsFORBIDDENfor a session that outlived its membership- The same lookup returns the role, so the response carries
canManage; settings shows the plan to every member but the management buttons only to those the plugin would accept - The billing query key includes
activeOrgId, so switching organizations automatically fetches fresh billing data via TanStack Query
Database Schema
The plugin uses stripeCustomerId on the user and organization tables, and a subscription table. The plugin manages the subscription table – no manual inserts/updates needed.
Schema must match plugin expectations. After auth config changes, update the schema in db/schema/ and run bun db:generate to create migrations.
Plan Configuration
Plan limits defined in apps/api/lib/plans.ts (single source of truth), referenced by both auth plugin config and tRPC router. Price IDs come from environment variables (STRIPE_*_PRICE_ID).
Config-as-code is the simplest correct approach – plans rarely change and this makes them testable and version-controlled.
Escape hatch: The plugin accepts plans: () => StripePlan[] for dynamic plans. Switch to this only when a real use case requires runtime plan management (e.g., admin dashboard for plan CRUD).
Limits enforcement: The limits object is returned by the billing.subscription tRPC query. Enforce limits in application logic (tRPC middleware, UI guards), not in the plugin itself.
Environment Variables
| Variable | Prefix |
|---|---|
STRIPE_SECRET_KEY | sk_ |
STRIPE_WEBHOOK_SECRET | whsec_ |
STRIPE_STARTER_PRICE_ID | price_ |
STRIPE_PRO_PRICE_ID | price_ |
STRIPE_PRO_ANNUAL_PRICE_ID | price_ |
Set in .env.local (local development), Cloudflare secrets (staging/production).
Webhook Setup
The plugin registers POST /api/auth/stripe/webhook automatically. It handles:
checkout.session.completed– activates subscriptioncustomer.subscription.created– records new subscriptioncustomer.subscription.updated– syncs status, cancellation schedulingcustomer.subscription.deleted– marks subscription canceled
Stripe Dashboard Configuration
Endpoint URL: https://<domain>/api/auth/stripe/webhook
Events:
- checkout.session.completed
- customer.subscription.created
- customer.subscription.updated
- customer.subscription.deletedLocal Development
stripe listen --forward-to localhost:5173/api/auth/stripe/webhook
# Copy the whsec_... signing secret to .env.localRaw Body Requirement
Stripe webhook verification requires the raw request body. The plugin handles this via request.text() – no special Hono middleware needed.
Testing
The plugin tests its own internals (webhooks, checkout, subscription lifecycle, authorization). App tests cover the seams we own:
- Router (
apps/api/routers/billing.test.ts) – membership revalidation (both predicates, and a session that outlived its membership),canManageper role, disabled integration, free plan fallback, plan limits mapping, unknown plan rejection, response shape - Query (
apps/app/lib/queries/billing.test.ts) – cache key includes org ID, null normalization, distinct keys per org, prefix for bulk invalidation
Checkout and webhook flows are not retested at app level – verified via stripe listen during development.
File Map
| Layer | Files |
|---|---|
| Schema | db/schema/subscription.ts, stripeCustomerId in db/schema/user.ts and db/schema/organization.ts |
| Server | apps/api/lib/plans.ts, stripe plugin in apps/api/lib/auth.ts |
| Router | apps/api/routers/billing.ts, registered in apps/api/lib/app.ts |
| Client | stripeClient in apps/app/lib/auth.ts, apps/app/lib/queries/billing.ts |
| UI | Billing card in apps/app/routes/(app)/settings.tsx |
| Tests | apps/api/routers/billing.test.ts, apps/app/lib/queries/billing.test.ts |