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
# Polar
NEXT_PUBLIC_BILLING_PROVIDER=polar

Supported 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 loaders
const 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.url

Open 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.url

Fetch 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:

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 unlimited
console.log(limits.seats); // e.g., 10
console.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 unsupported
for (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 array
if (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: boolean

Direct Provider Access

For advanced scenarios beyond the unified API:

Get Provider Instance

const provider = billing.getProvider();
// Returns the underlying BillingProvider instance

Access Raw SDK

const stripe = billing.getProviderClient() as Stripe;
// Use Stripe SDK directly
const invoice = await stripe.invoices.retrieve('inv_xxx');

Stripe Integration

The default provider connects to Stripe through Better Auth's Stripe plugin.

Configuration

  1. Add Stripe keys to .env.local:
    STRIPE_SECRET_KEY=sk_xxx
    STRIPE_WEBHOOK_SECRET=whsec_xxx
  2. Create products and prices in Stripe Dashboard
  3. 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:

EventHandler
checkout.session.completedCreates subscription
customer.subscription.updatedUpdates subscription status
customer.subscription.deletedMarks subscription canceled
invoice.payment_failedTriggers payment failed hook

Polar Integration

Polar is a developer-focused payment processor with Better Auth integration.

Configuration

  1. Add Polar credentials to .env.local:
    NEXT_PUBLIC_BILLING_PROVIDER=polar
    POLAR_ACCESS_TOKEN=polar_at_xxx
    POLAR_ENVIRONMENT=sandbox # or 'production'
    POLAR_WEBHOOK_SECRET=whsec_xxx # optional
  2. Create products in Polar Dashboard
  3. 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

CapabilityStripePolar
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 it
if (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 auth parameter: getBilling(auth) needs your Better Auth instance to resolve provider configuration.
  • Wrong ID type for cancel/restore: Pass providerSubscriptionId from listSubscriptions(), 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.supportsCancel before showing UI.
  • Mismatched customerType: Team billing needs customerType: 'organization' with referenceId: 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?
Call billing.getProviderClient() to get the underlying SDK instance. Cast it to the correct type (Stripe or Polar) based on your provider.
What's the difference between referenceId and customerId?
referenceId is your internal user or organization ID. customerId is the provider's customer identifier (e.g., cus_... for Stripe). The billing client maps between them.
Can I call billing methods from client components?
No. getBilling() is server-only. Use server actions to call billing methods and return results to the client.
How do I check if a user has an active subscription?
Call listSubscriptions() and check if any subscription has status 'active' or 'trialing'. The hasSubscription flag in checkPlanLimit() also indicates this.
What happens if the provider is unavailable?
Billing methods will throw. Wrap calls in try/catch and handle gracefully. For checkouts, show an error and suggest retrying.

Next: Stripe Setup →

See also: Advanced Pricing for multi-line item checkout, tiered pricing, and optional add-ons (Stripe only).