Billing Providers
Configure and use the unified billing client for payment processing
The SaaS Kit uses a provider abstraction for billing, allowing you to swap payment processors without changing application code.
Supported providers:
- Stripe - Stripe is a full-featured payment processor with per-organization customers, entitlements, and usage meters
- Polar - Polar is an up-and-coming developer-focused billing provider that acts as a merchant of record for your business
We use a unified BillingClient API to abstract the different providers and their API calls. The client provides:
- Core operations: checkout, portal, subscriptions, cancel/restore
- Plan limits: check and enforce countable quotas (seats, projects, storage)
- Entitlements: boolean feature gating (Stripe Entitlements)
- Usage meters: usage-based billing (Stripe Billing Meters)
We provide raw access to the provider SDK as an escape hatch, in case the API doesn't cover all your requirements.
Configuration
Set the billing provider via environment variable:
# Use Stripe (default)NEXT_PUBLIC_BILLING_PROVIDER=stripe# Use PolarNEXT_PUBLIC_BILLING_PROVIDER=polarCurrently supported: stripe, polar
You also need NEXT_PUBLIC_SITE_URL set (absolute URL) for server-side return URLs used by billing actions.
Using the Billing Client
Get Billing Client Instance
The getBilling function returns a unified BillingClient that provides access to all billing operations:
import { getBilling } from '@kit/billing-api';import { auth } from '@kit/better-auth';// In a server action or loaderconst billing = await getBilling(auth);The auth parameter is required and should be your Better Auth instance.
Context: referenceId and customerType
Many billing APIs are contextual. Always decide:
referenceId: user id (personal) or organization id (org billing)customerType:'user'or'organization'
For Stripe, customerType is especially important for per-organization customers (so the provider can resolve organizations.stripeCustomerId).
Create Checkout Session
const result = await billing.checkout({ userId: user.id, planId: 'pro-monthly', successUrl: '/billing/success', cancelUrl: '/billing/cancel', referenceId: org.id, // For organization billing customerType: 'organization', customer: { email: user.email, name: user.name, }, metadata: { source: 'upgrade-page', },});// Redirect to result.urlCustomer Portal
const portal = await billing.portal({ referenceId: user.id, // Required - user or organization ID customerType: 'user', // use 'organization' for org billing returnUrl: '/settings/billing',});// Redirect to portal.urlList Subscriptions
const { subscriptions, pagination } = await billing.listSubscriptions({ referenceId: user.id, // or organization ID customerType: 'user', // use 'organization' for org billing page: 1, limit: 10,});Cancel Subscription
subscriptionId is provider implementation-defined. In the Makerkit UI flows, the value passed to cancel/restore comes from subscription.providerSubscriptionId returned by billing.listSubscriptions().
await billing.cancelSubscription({ subscriptionId: 'sub_xxx', referenceId: user.id, customerType: 'user',});Plan Limits
The billing client provides methods to check and enforce plan limits configured in your billing config.
Seat-based (Quantity) Billing
Makerkit supports quantity-based (“per-seat”) checkout for organizations by deriving a seat count from the organization member count during checkout (with a minimum of 1).
See:
- Customization for how seat count is computed and how to tailor it for your product.
- Seat enforcement for organization invites for limiting team size based on purchased quantity.
If you want to require billing before orgs can invite/add members, implement it via the Policy API (see “Requiring a subscription” in Seat enforcement for organization invites).
Get Plan Limits
const { limits, hasSubscription } = await billing.getPlanLimits(referenceId);// limits is a Record<string, number | null>// null means unlimitedconsole.log(limits.seats); // e.g., 10console.log(limits.projects); // e.g., null (unlimited)Check Plan Limit
Use this before allowing actions that consume limited resources:
const result = await billing.checkPlanLimit({ referenceId: user.id, limitKey: 'seats', currentUsage: currentMemberCount,});if (!result.allowed) { throw new Error(`Seat limit reached (${result.current}/${result.limit})`);}// result shape:// {// allowed: boolean, // Can the action proceed?// limit: number | null, // The limit (null = unlimited)// current: number, // Current usage// remaining: number | null, // Remaining capacity// hasSubscription: boolean // Has active subscription?// }Find Plan by Name
Look up plan configuration from the billing config:
const plan = billing.findPlanByName('pro-monthly');if (plan) { console.log(plan.cost); // 19.99 console.log(plan.limits); // { seats: 10 } console.log(plan.priceId); // Stripe price ID}Entitlements (Feature Flags)
Entitlements provide boolean feature gating using Stripe Entitlements. Methods return safe defaults when the provider doesn't support entitlements.
Check Entitlement
const { entitled, source } = await billing.checkEntitlement( customerId, 'advanced-analytics');if (!entitled) { throw new Error('Upgrade to access advanced analytics');}// source is 'provider' or 'unsupported'Get All Entitlements
const entitlements = await billing.listEntitlements(customerId);// Returns Entitlement[] or empty array if unsupportedfor (const e of entitlements) { console.log(e.lookupKey); // 'api-access', 'advanced-analytics', etc.}Usage Meters
Usage meters enable usage-based billing. Stripe uses Stripe Billing Meters; Polar uses Polar meters. Methods return safe defaults when unsupported.
Polar limitation: the kit’s Polar integration returns all-time usage (no time-range filtering).
Record Usage
const result = await billing.recordUsage( 'api_calls', // event name customerId, // Provider customer ID (e.g. Stripe cus_…) 1, // value);if (!result.recorded) { console.error(result.error);}Get Usage
const usage = await billing.getUsage( 'meter_123', // meter ID customerId,);// Returns MeterUsageSummary[] or empty arrayif (usage.length > 0) { console.log(`Used ${usage[0].aggregatedValue} API calls this period`);}Provider Capabilities
Check what the current provider supports:
const { capabilities } = billing;if (capabilities.supportsRestore) { // Show restore button for canceled subscriptions}if (capabilities.supportsEntitlements) { // Use entitlement-based feature gating}// Available capabilities:// - supportsRestore: boolean// - supportsCancel: boolean// - supportsPortal: boolean// - supportsOrganizations: boolean// - supportsEntitlements: boolean// - supportsUsageMeters: booleanEscape Hatches
For advanced use cases not covered by the unified API:
Get Raw Provider
const provider = billing.getProvider();// Returns the underlying BillingProvider instanceGet Provider Client
const stripe = billing.getProviderClient() as Stripe;// Access the raw Stripe SDKconst invoice = await stripe.invoices.retrieve('inv_xxx');Stripe Provider
The default provider uses Stripe via Better Auth's Stripe plugin.
Setup
- Configure Stripe keys:
STRIPE_SECRET_KEY=sk_xxxSTRIPE_WEBHOOK_SECRET=whsec_xxxCreate products and prices in Stripe Dashboard
Map prices in billing config:
packages/billing/config/src/config.ts
import { BillingConfig } from '@kit/billing';export const billingConfig: BillingConfig = { products: [ { id: 'pro', name: 'Pro Plan', description: 'For growing teams', currency: 'USD', features: ['All features', 'Priority support'], plans: [ { name: 'pro-monthly', planId: process.env.STRIPE_PRICE_PRO_MONTHLY!, displayName: 'Pro Monthly', interval: 'month', cost: 29, }, ], }, ],};Webhook Events
Stripe webhooks are handled automatically by Better Auth. Key events:
| Event | Handler |
|---|---|
checkout.session.completed | Creates subscription |
customer.subscription.updated | Updates subscription status |
customer.subscription.deleted | Marks subscription canceled |
invoice.payment_failed | Triggers payment failed hook |
Polar Provider
Polar is a developer-focused payment processor that integrates via Better Auth's Polar plugin.
Setup
- Configure Polar credentials:
NEXT_PUBLIC_BILLING_PROVIDER=polarPOLAR_ACCESS_TOKEN=polar_at_xxxPOLAR_ENVIRONMENT=sandbox # or 'production'POLAR_WEBHOOK_SECRET=whsec_xxx # optionalCreate products in Polar Dashboard
Map products in billing config:
packages/billing/config/src/config.ts
export const billingConfig: BillingConfig = { products: [ { id: 'pro', name: 'Pro Plan', description: 'For growing teams', currency: 'USD', features: ['All features', 'Priority support'], plans: [ { name: 'pro-monthly', planId: process.env.POLAR_PRODUCT_PRO_MONTHLY!, displayName: 'Pro Monthly', interval: 'month', cost: 29, }, ], }, ],};Benefits
Polar supports “benefits”, but Makerkit does not currently map benefits to BillingClient entitlements (entitlements are Stripe-only in this kit). If you need benefits, use the raw Polar SDK via billing.getProviderClient().
Provider Capabilities Comparison
| Capability | Stripe | Polar |
|---|---|---|
| Checkout | ✅ | ✅ |
| Customer Portal | ✅ | ✅ |
| Cancel (API) | ✅ | ❌ (via portal) |
| Restore (API) | ✅ | ❌ (via portal) |
| Entitlements | ✅ | ❌ |
| Usage Meters | ✅ | ✅ |
| Organizations (per-org customers) | ✅ | ⚠️ Limited (no org customer context) |
| Trial Periods | ✅ | ✅ |
Use billing.capabilities to check what the active provider supports:
const { capabilities } = billing;// Only show cancel button if provider supports itif (capabilities.supportsCancel) { // Show cancel button}Next: Billing Configuration