Billing Providers
Work with the unified billing client API in your Next.js Prisma application
The BillingClient API from @kit/billing-api handles all billing operations. Get an instance via getBilling(auth), then call methods like checkout(), portal(), listSubscriptions(), and checkPlanLimit(). Your code stays the same across Stripe and Polar.
This page is part of the Billing & Subscriptions documentation.
BillingClient provides a unified TypeScript interface over Stripe and Polar, offering consistent methods for checkout, subscription management, quota enforcement, entitlements, and usage tracking.
Available providers:
- Stripe - Stripe offers full-featured payment processing with organization customers, entitlements, and usage meters
- Polar - Polar is a developer-focused provider that acts as merchant of record, handling taxes and compliance
The BillingClient exposes:
- Subscription operations: checkout, portal, list, cancel, restore
- Quota management: enforce countable limits (seats, projects, storage)
- Entitlements: boolean feature flags (Stripe Entitlements)
- Usage billing: metered pricing (Stripe Billing Meters)
When you need features beyond the unified API, access the raw provider SDK directly.
Setup
Choose your provider with an environment variable:
# Stripe (default)NEXT_PUBLIC_BILLING_PROVIDER=stripe# PolarNEXT_PUBLIC_BILLING_PROVIDER=polarSupported values: stripe, polar
Also set NEXT_PUBLIC_SITE_URL (absolute URL) for server-side redirect URLs in billing actions.
Working with BillingClient
Getting an Instance
Call getBilling to get a unified BillingClient:
import { getBilling } from '@kit/billing-api';import { auth } from '@kit/better-auth';// In server actions or loadersconst billing = await getBilling(auth);Pass your Better Auth instance as the auth parameter.
Context Parameters
Billing operations are context-dependent. Specify:
referenceId: user ID (individual) or organization ID (team)customerType:'user'or'organization'
With Stripe, customerType matters for per-organization customers since it determines which customer record to use (organizations.stripeCustomerId).
Start Checkout
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.urlOpen Customer Portal
const portal = await billing.portal({ referenceId: user.id, // user or organization ID customerType: 'user', // 'organization' for team billing returnUrl: '/settings/billing',});// Redirect to portal.urlFetch Subscriptions
const { subscriptions, pagination } = await billing.listSubscriptions({ referenceId: user.id, // or organization ID customerType: 'user', // 'organization' for team billing page: 1, limit: 10,});Cancel a Subscription
The subscriptionId value is provider-specific. In Makerkit UI flows, this comes from subscription.providerSubscriptionId returned by billing.listSubscriptions().
await billing.cancelSubscription({ subscriptionId: 'sub_xxx', referenceId: user.id, customerType: 'user',});Quota Enforcement
The billing client includes methods for checking and enforcing plan limits from your config.
Per-Seat Pricing
Makerkit supports quantity-based checkout for teams by calculating seat count from organization membership (minimum 1).
Related guides:
- Customization explains seat calculation and customization options.
- Seat Enforcement covers limiting team size based on purchased seats.
To require billing before teams can invite members, implement a custom policy (see "Requiring a subscription" in Seat Enforcement).
Retrieve 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)Validate Against Limit
Call before 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?// }Look Up Plan Config
Query plan details from your billing configuration:
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}Feature Entitlements
Stripe Entitlements enable boolean feature gating. Methods gracefully fall back when the provider lacks entitlement support.
Verify 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'List 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.}Metered Usage
Track usage for usage-based billing. Stripe uses Billing Meters; Polar uses Polar meters. Methods return safe defaults when unsupported.
Polar note: The Polar integration returns cumulative usage without time-range filtering.
Log 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);}Query 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`);}Capability Detection
Query what the active 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: booleanDirect Provider Access
For advanced scenarios beyond the unified API:
Get Provider Instance
const provider = billing.getProvider();// Returns the underlying BillingProvider instanceAccess Raw SDK
const stripe = billing.getProviderClient() as Stripe;// Use Stripe SDK directlyconst invoice = await stripe.invoices.retrieve('inv_xxx');Stripe Integration
The default provider connects to Stripe through Better Auth's Stripe plugin.
Configuration
- Add Stripe keys to
.env.local:STRIPE_SECRET_KEY=sk_xxxSTRIPE_WEBHOOK_SECRET=whsec_xxx - Create products and prices in Stripe Dashboard
- Map prices in your billing config
Example price mapping:
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 Handling
Better Auth processes Stripe webhooks automatically. 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 Integration
Polar is a developer-focused payment processor with Better Auth integration.
Configuration
- Add Polar credentials to
.env.local:NEXT_PUBLIC_BILLING_PROVIDER=polarPOLAR_ACCESS_TOKEN=polar_at_xxxPOLAR_ENVIRONMENT=sandbox # or 'production'POLAR_WEBHOOK_SECRET=whsec_xxx # optional - Create products in Polar Dashboard
- Map products in your billing config
Example product mapping:
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, }, ], }, ],};Polar Benefits
Polar has a "benefits" feature, but Makerkit doesn't map benefits to BillingClient entitlements (entitlements are Stripe-only). Access benefits directly through billing.getProviderClient().
Provider Feature Matrix
| Capability | Stripe | Polar |
|---|---|---|
| Checkout | ✅ | ✅ |
| Customer Portal | ✅ | ✅ |
| Cancel (API) | ✅ | ❌ (via portal) |
| Restore (API) | ✅ | ❌ (via portal) |
| Multi-line Item Checkout | ✅ | ❌ |
| Tiered Pricing | ✅ | ❌ |
| Optional Add-ons | ✅ | ❌ |
| Entitlements | ✅ | ❌ |
| Usage Meters | ✅ | ✅ |
| Organizations (per-org customers) | ✅ | ⚠️ Limited (no org customer context) |
| Trial Periods | ✅ | ✅ |
Check billing.capabilities for runtime feature detection:
const { capabilities } = billing;// Only show cancel button if provider supports itif (capabilities.supportsCancel) { // Show cancel button}Choosing a Provider
Pick Stripe when:
- You need separate billing per organization
- You want entitlement-based feature gating
- You need programmatic cancel/restore (beyond portal)
- Customers expect Stripe's brand trust
Pick Polar when:
- You want merchant of record (Polar handles taxes, VAT)
- You're targeting developers (Polar's specialty)
- You don't need per-org customer records
- You prefer simplicity over feature breadth
Default recommendation: Start with Stripe for broader feature support. You can switch later, but subscriptions won't transfer.
Avoid These Mistakes
- Omitting
authparameter:getBilling(auth)needs your Better Auth instance to resolve provider configuration. - Wrong ID type for cancel/restore: Pass
providerSubscriptionIdfromlistSubscriptions(), not your internal ID. - Expecting entitlements on Polar: Entitlements are Stripe-only. Polar returns
{ entitled: false, source: 'unsupported' }. - Skipping capability checks: Not all providers support all features. Check
billing.capabilities.supportsCancelbefore showing UI. - Mismatched customerType: Team billing needs
customerType: 'organization'withreferenceId: org.id. Mixing them breaks customer lookups. - Ignoring pagination:
listSubscriptions()returns{ subscriptions, pagination }. Large accounts span multiple pages.
Frequently Asked Questions
How do I access the raw Stripe/Polar SDK?
What's the difference between referenceId and customerId?
Can I call billing methods from client components?
How do I check if a user has an active subscription?
What happens if the provider is unavailable?
Next: Stripe Setup →
See also: Advanced Pricing for multi-line item checkout, tiered pricing, and optional add-ons (Stripe only).