Billing Providers

Configure and use the unified billing client for payment processing

Access billing operations through the BillingClient API from @kit/billing-api. Call getBilling(auth) to get an instance, then use methods like checkout(), portal(), listSubscriptions(), and checkPlanLimit(). The same code works with both Stripe and Polar; only the environment variable changes.

This guide is part of the Billing & Subscriptions documentation.

The BillingClient is a unified TypeScript interface that abstracts provider-specific billing APIs (Stripe, Polar) into a consistent set of methods for checkout, subscription management, plan limits, entitlements, and usage metering.

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

The BillingClient API 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 Polar
NEXT_PUBLIC_BILLING_PROVIDER=polar

Currently 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 loader
const 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.url

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

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

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

Escape Hatches

For advanced use cases not covered by the unified API:

Get Raw Provider

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

Get Provider Client

const stripe = billing.getProviderClient() as Stripe;
// Access the raw Stripe SDK
const invoice = await stripe.invoices.retrieve('inv_xxx');

Stripe Provider

The default provider uses Stripe via Better Auth's Stripe plugin.

Setup

  1. Configure Stripe keys:
    • Make sure to add them to your .env.local file.
    STRIPE_SECRET_KEY=sk_xxx
    STRIPE_WEBHOOK_SECRET=whsec_xxx
  2. Create products and prices in Stripe Dashboard
  3. Map prices in billing config

To map the prices in the billing config, you can use the following code:

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:

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

Polar Provider

Polar is a developer-focused payment processor that integrates via Better Auth's Polar plugin.

Setup

  1. Configure Polar credentials:
    • Make sure to add them to your .env.local file.
    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 billing config

To map the products in the billing config, you can use the following code:

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

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

Use billing.capabilities to check what the active provider supports:

const { capabilities } = billing;
// Only show cancel button if provider supports it
if (capabilities.supportsCancel) {
// Show cancel button
}

Decision Rules

Use Stripe when:

  • You need per-organization customers with separate billing
  • You want entitlements (boolean feature flags tied to subscriptions)
  • You need API-based cancel/restore (not just portal)
  • Your customers expect Stripe's brand recognition

Use Polar when:

  • You prefer a merchant of record model (Polar handles taxes, VAT)
  • You're building a developer-focused product (Polar's niche)
  • You don't need per-organization customer records
  • You want simpler setup at the cost of fewer features

If unsure: Start with Stripe. It has broader feature support and you can always switch later (though subscriptions don't migrate).

Common Pitfalls

  • Forgetting auth parameter: getBilling(auth) requires your Better Auth instance. Without it, the client can't resolve provider configuration.
  • Using wrong ID type: subscriptionId in cancel/restore is providerSubscriptionId from listSubscriptions(), not your internal ID.
  • Assuming entitlements work on Polar: Entitlements are Stripe-only. On Polar, checkEntitlement() returns { entitled: false, source: 'unsupported' }.
  • Ignoring capabilities: Not all providers support all features. Check billing.capabilities.supportsCancel before showing cancel buttons.
  • Mixing customerType: For organization billing, use customerType: 'organization' and referenceId: org.id. Mismatching these causes customer lookup failures.
  • Not handling pagination: listSubscriptions() returns { subscriptions, pagination }. Large accounts may have 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).