Skip to content

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

text
┌─────────────┐     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:

  1. User clicks "Upgrade" – Better Auth client calls auth.subscription.upgrade()
  2. Plugin creates Stripe Checkout session – redirects browser to Stripe
  3. User completes payment – Stripe sends webhook to /api/auth/stripe/webhook
  4. Plugin verifies signature, updates subscription table – 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.subscription is application-owned, so it re-checks membership against member before reading, and throws FORBIDDEN for 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

VariablePrefix
STRIPE_SECRET_KEYsk_
STRIPE_WEBHOOK_SECRETwhsec_
STRIPE_STARTER_PRICE_IDprice_
STRIPE_PRO_PRICE_IDprice_
STRIPE_PRO_ANNUAL_PRICE_IDprice_

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 subscription
  • customer.subscription.created – records new subscription
  • customer.subscription.updated – syncs status, cancellation scheduling
  • customer.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.deleted

Local Development

bash
stripe listen --forward-to localhost:5173/api/auth/stripe/webhook
# Copy the whsec_... signing secret to .env.local

Raw 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), canManage per 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

LayerFiles
Schemadb/schema/subscription.ts, stripeCustomerId in db/schema/user.ts and db/schema/organization.ts
Serverapps/api/lib/plans.ts, stripe plugin in apps/api/lib/auth.ts
Routerapps/api/routers/billing.ts, registered in apps/api/lib/app.ts
ClientstripeClient in apps/app/lib/auth.ts, apps/app/lib/queries/billing.ts
UIBilling card in apps/app/routes/(app)/settings.tsx
Testsapps/api/routers/billing.test.ts, apps/app/lib/queries/billing.test.ts