Billing & Subscriptions
Learn how to implement and manage billing with Stripe or Polar via Better Auth
Monetize your SaaS with a production-ready billing system that supports Stripe and Polar. Configure products in one file, switch providers via environment variable, and handle subscriptions, trials, upgrades, and cancellations without touching billing code.
The MakerKit billing system is a provider-agnostic subscription management layer built on Better Auth that abstracts Stripe and Polar behind a unified BillingClient API, enabling checkout, portal access, plan limits, entitlements, and usage metering through a single interface.
Don't use directly: the raw Stripe/Polar SDKs unless you need features not exposed by BillingClient. Use billing.getProviderClient() as an escape hatch.
Overview
The billing system provides:
- Multi-Provider Billing - Switch between Stripe and Polar via environment variable
- Subscription Management - Create, upgrade, cancel, and restore subscriptions
- Multi-Tenant Billing - Support for both personal accounts and organizations
- Flexible Pricing - Configure multiple products, plans, and billing intervals
- Per-seat billing - Quantity-based subscriptions using seat count
- Trial Periods - Built-in support for free trials with automatic conversion
- Customer Portal - Provider-hosted portal for self-service billing management
- Lifecycle Hooks - Extensible hooks for subscription events
- Permission-Based - Role-based authorization for billing operations
Provider Selection
Switch billing providers via environment variable:
# Use Stripe (default)NEXT_PUBLIC_BILLING_PROVIDER=stripe# Use PolarNEXT_PUBLIC_BILLING_PROVIDER=polarThe same application code works with either provider - only configuration changes.
Key Concepts (Context, IDs, Permissions)
Billing in Makerkit is contextual: the same UI and APIs work for personal accounts and organizations. Understanding the identifiers below makes it much easier to debug and extend billing safely.
Context: referenceId + customerType
referenceId: The owner of the subscription. It is either:- A user ID for personal billing
- An organization ID for organization billing
customerType: Which billing “entity” the provider should use:'user'for personal billing'organization'for organization billing (not all providers support true org customers)
In the web app, the billing page resolves this from the current account context:
- Personal:
referenceId = session.user.id,customerType = 'user' - Organization:
referenceId = session.session.activeOrganizationId,customerType = 'organization'
IDs: customerId, subscriptionId, providerSubscriptionId
| Name | Meaning | Typical examples | Notes |
|---|---|---|---|
customerId | Provider customer ID | Stripe: cus_… | Used for portals, entitlements, usage meters. Stripe supports separate user and org customers. |
subscriptionId | Subscription identifier used in API calls (cancel/restore/upgrade) | Implementation-defined | Important: this is delegated to the active provider implementation. In the kit UI, it is sourced from subscription.providerSubscriptionId. |
providerSubscriptionId | Provider’s subscription ID (when available) | Stripe: sub_… | Exposed on the unified Subscription returned by billing.listSubscriptions. |
Permissions: personal vs organization billing
- Personal billing: the current user can manage their own billing.
- Organization billing: operations are gated by Better Auth org permissions on the
billingresource.
Common mapping used across the kit:
| Permission | Enables |
|---|---|
billing:read | View billing page and list subscriptions |
billing:create | Start checkout / upgrade subscription |
billing:update | Open customer portal, restore subscription |
billing:delete | Cancel subscription |
Required environment variables (high level)
NEXT_PUBLIC_BILLING_PROVIDER:stripe(default) orpolarNEXT_PUBLIC_SITE_URL: required for server-side return URLs (must be a valid absolute URL, e.g.https://yourdomain.com)
See Stripe Setup and Polar Setup for provider-specific variables.
Architecture
The billing implementation consists of several key packages:
packages/├── billing/│ ├── api/ # @kit/billing-api - Unified BillingClient│ ├── core/ # @kit/billing - Core types and registry│ ├── config/ # @kit/web-billing-config - App config│ ├── stripe/ # @kit/billing-stripe - Stripe provider│ ├── polar/ # @kit/billing-polar - Polar provider│ └── ui/ # @kit/billing-ui - UI components├── better-auth/│ └── src/plugins/│ └── billing.ts # Better Auth billing plugin (provider factory)System Overview
┌─────────────────────────────────────────────────────────────────────────────┐│ UI LAYER ││ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ││ │ pricing-table │ │ checkout-button │ │ subscription- │ ││ │ │ │ │ │ card │ ││ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ ││ │ │ │ ││ ┌────────┴────────┐ ┌────────┴────────┐ ┌────────┴────────┐ ││ │ plan-picker │ │ billing-portal- │ │ cancel-dialog │ ││ │ │ │ button │ │ │ ││ └─────────────────┘ └─────────────────┘ └─────────────────┘ ││ @kit/billing-ui │└───────────────────────────────────┬─────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────────────────────┐│ SERVER ACTIONS ││ ┌─────────────────────────────────────────────────────────────────────┐ ││ │ billing-server-actions.ts │ ││ │ • createCheckoutSessionAction │ ││ │ • createBillingPortalSessionAction │ ││ │ • cancelSubscriptionAction │ ││ │ • restoreSubscriptionAction │ ││ └─────────────────────────────────────────────────────────────────────┘ │└───────────────────────────────────┬─────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────────────────────┐│ BILLING CLIENT ││ ┌─────────────────────────────────────────────────────────────────────┐ ││ │ getBilling(auth) → BillingClient │ ││ │ ┌─────────────────────────────────────────────────────────────┐ │ ││ │ │ Methods: │ Utilities: │ │ ││ │ │ • checkout() │ • checkPlanLimit() │ │ ││ │ │ • portal() │ • checkEntitlement() │ │ ││ │ │ • listSubscriptions() │ • recordUsage() │ │ ││ │ │ • cancelSubscription() │ • capabilities │ │ ││ │ │ • restoreSubscription() │ • getProvider() │ │ ││ │ └─────────────────────────────────────────────────────────────┘ │ ││ └─────────────────────────────────────────────────────────────────────┘ ││ @kit/billing-api │└───────────────────────────────────┬─────────────────────────────────────────┘ │ ▼┌─────────────────────────────────────────────────────────────────────────────┐│ PROVIDER REGISTRY ││ ┌─────────────────────────────────────────────────────────────────────┐ ││ │ NEXT_PUBLIC_BILLING_PROVIDER env var → Provider Factory (lazy) │ ││ └─────────────────────────────────────────────────────────────────────┘ │└───────────────────────────────────┬─────────────────────────────────────────┘ │ ┌───────────────┴───────────────┐ ▼ ▼┌──────────────────────────────────┐ ┌──────────────────────────────────┐│ STRIPE PROVIDER │ │ POLAR PROVIDER ││ ┌────────────────────────────┐ │ │ ┌────────────────────────────┐ ││ │ Capabilities: │ │ │ │ Capabilities: │ ││ │ ✓ checkout │ │ │ │ ✓ checkout │ ││ │ ✓ portal │ │ │ │ ✓ portal │ ││ │ ✓ cancel │ │ │ │ ✗ cancel (via portal) │ ││ │ ✓ restore │ │ │ │ ✗ restore (via portal) │ ││ │ ✓ entitlements │ │ │ │ ✗ entitlements │ ││ │ ✓ usage meters │ │ │ │ ✓ usage meters │ ││ └────────────────────────────┘ │ │ └────────────────────────────┘ ││ @kit/billing-stripe │ │ @kit/billing-polar │└──────────────────────────────────┘ └──────────────────────────────────┘Checkout Flow
User UI Server Action Provider │ │ │ │ │ Click Checkout │ │ │ │────────────────────>│ │ │ │ │ createCheckoutSession │ │ │ │────────────────────────>│ │ │ │ │ billing.checkout() │ │ │ │───────────────────────>│ │ │ │ │ │ │ │ Checkout URL │ │ │ │<───────────────────────│ │ │ redirect(url) │ │ │ │<────────────────────────│ │ │ Redirect to │ │ │ │ Provider Checkout │ │ │ │<────────────────────│ │ │ │ │ │ │ │ Complete Payment │ │ │ │─────────────────────────────────────────────────────────────────────── >│ │ │ │ Webhook: subscription │ │ │ │<───────────────────────│ │ │ │ Update DB via │ │ │ │ Better Auth Plugin │ │ │ │ Execute lifecycle │ │ │ │ hooks │ │ Redirect to │ │ │ │ Success Page │ │ │ │<──────────────────────────────────────────────────────────────────────-│Key Concept: BillingClient
The @kit/billing-api package exports a unified BillingClient that provides:
- Core operations: checkout, portal, subscriptions, cancel/restore
- Plan limits: check and enforce quotas (seats, projects, storage)
- Entitlements: boolean feature gating (returns defaults if unsupported)
- Usage meters: usage-based billing (returns defaults if unsupported)
import { getBilling } from '@kit/billing-api';const billing = await getBilling(auth);// All methods available on single objectawait billing.checkout({ ... });await billing.checkPlanLimit({ referenceId, limitKey, currentUsage });await billing.checkEntitlement(customerId, 'feature-key');Key Features
Subscription Lifecycle
The system handles the complete subscription lifecycle:
- Checkout - Stripe Checkout sessions for new subscriptions
- Trials - Automatic trial period management
- Active Subscriptions - Ongoing subscription monitoring
- Upgrades/Downgrades - Plan changes with prorated billing
- Cancellations - Cancel at period end with reactivation option
- Payment Failures - Automatic retry and notification handling
Multi-Tenant Support
Billing works seamlessly across account types:
// Personal account billingconst referenceId = user.id;// Organization billingconst referenceId = organizationId;The same billing code handles both scenarios through Better Auth's polymorphic subscription system.
Quick Start
1. Choose Your Provider
Set your billing provider in apps/web/.env.development:
# For Stripe (default)NEXT_PUBLIC_BILLING_PROVIDER=stripeSTRIPE_SECRET_KEY=sk_test_...STRIPE_WEBHOOK_SECRET=whsec_...# For PolarNEXT_PUBLIC_BILLING_PROVIDER=polarPOLAR_ACCESS_TOKEN=polar_at_...POLAR_WEBHOOK_SECRET=whsec_...POLAR_ENVIRONMENT=sandboxSee Stripe Setup or Polar Setup for detailed configuration.
2. Configure Plans
Create products in your provider's dashboard, then configure them:
import { BillingConfig } from '@kit/billing';export const billingConfig: BillingConfig = { products: [ { id: 'starter', name: 'Starter', description: 'For individuals and small teams', currency: 'USD', features: ['Core features', 'Email support'], plans: [ { name: 'starter-monthly', // Configure provider-specific IDs planId: process.env.STRIPE_PRICE_STARTER_MONTHLY!, displayName: 'Starter Monthly', interval: 'month', cost: 9.99, }, ], }, ],};Use environment variables for IDs so you can swap them in different environments. Only the active provider's configuration is used at runtime.
3. Test the Flow
- Run the development server:
pnpm dev - Navigate to
/settings/billing - Select a plan and proceed to checkout
- For Stripe: Use test card
4242 4242 4242 4242 - For Polar: Use the sandbox environment for testing
Common Pitfalls
- Using Price ID instead of Product ID for Polar: Stripe uses Price IDs (
price_...) but Polar uses Product IDs (prod_...). Mixing them causes checkout failures. - Forgetting
NEXT_PUBLIC_SITE_URL: Server actions need this for return URLs. Without it, redirects fail silently. - Missing webhook secret in production: Webhooks fail signature verification and subscriptions won't sync. Set
STRIPE_WEBHOOK_SECRETorPOLAR_WEBHOOK_SECRET. - Expecting Polar to have org customers: Polar is user-centric. Organization billing works, but there's no separate org customer record. Use Stripe for strict B2B billing.
- Not testing webhooks locally: Use
pnpm --filter web run stripe:listenfor Stripe or ngrok for Polar. Without webhooks, subscription state won't update. - Confusing
referenceIdwithcustomerId:referenceIdis your internal user/org ID.customerIdis the provider's customer ID (cus_...for Stripe). - Plan limits not enforcing automatically: Limits are config-based and advisory. You must call
billing.checkPlanLimit()in your code to actually block actions.
Frequently Asked Questions
Can I use both Stripe and Polar simultaneously?
How do I test billing without real payments?
Do subscriptions survive provider switches?
How do I handle failed payments?
Can I offer lifetime deals?
How do I upgrade/downgrade plans?
Next: Billing Configuration →