# Billing Providers

> Work with the unified billing client API in your Next.js Prisma application

*Canonical: https://makerkit.dev/docs/nextjs-prisma/billing/providers*

---

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](./overview) 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](https://stripe.com) offers full-featured payment processing with organization customers, entitlements, and usage meters
- **Polar** - [Polar](https://polar.sh) 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:

```bash
# 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`:

```typescript
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

```typescript
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

```typescript
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

```typescript
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()`.

```typescript
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](./customization) explains seat calculation and customization options.
- [Seat Enforcement](./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](./seat-enforcement)).

### Retrieve Plan Limits

```typescript
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:

```typescript
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:

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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:

```typescript
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

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

### Access Raw SDK

```typescript
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`:
    ```bash
    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:

```typescript {% title="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

1. Add Polar credentials to `.env.local`:
    ```bash
    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:

```typescript {% title="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:

```typescript
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.

{% faq
   title="Frequently Asked Questions"
   items=[
     {"question": "How do I access the raw Stripe/Polar SDK?", "answer": "Call billing.getProviderClient() to get the underlying SDK instance. Cast it to the correct type (Stripe or Polar) based on your provider."},
     {"question": "What's the difference between referenceId and customerId?", "answer": "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."},
     {"question": "Can I call billing methods from client components?", "answer": "No. getBilling() is server-only. Use server actions to call billing methods and return results to the client."},
     {"question": "How do I check if a user has an active subscription?", "answer": "Call listSubscriptions() and check if any subscription has status 'active' or 'trialing'. The hasSubscription flag in checkPlanLimit() also indicates this."},
     {"question": "What happens if the provider is unavailable?", "answer": "Billing methods will throw. Wrap calls in try/catch and handle gracefully. For checkouts, show an error and suggest retrying."}
   ]
/%}

---

**Next:** [Stripe Setup →](./stripe-setup)

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