Version 3 of the kit: - Radix UI replaced with Base UI (using the Shadcn UI patterns) - next-intl replaces react-i18next - enhanceAction deprecated; usage moved to next-safe-action - main layout now wrapped with [locale] path segment - Teams only mode - Layout updates - Zod v4 - Next.js 16.2 - Typescript 6 - All other dependencies updated - Removed deprecated Edge CSRF - Dynamic Github Action runner
462 lines
12 KiB
Plaintext
462 lines
12 KiB
Plaintext
---
|
|
status: "published"
|
|
label: "Billing API"
|
|
title: "Billing API Reference for Next.js Supabase SaaS Kit"
|
|
order: 10
|
|
description: "Complete API reference for Makerkit's billing service. Create checkouts, manage subscriptions, report usage, and handle billing operations programmatically."
|
|
---
|
|
|
|
The Billing Gateway Service provides a unified API for all billing operations, regardless of which payment provider you use (Stripe, Lemon Squeezy, or Paddle). This abstraction lets you switch providers without changing your application code.
|
|
|
|
## Getting the Billing Service
|
|
|
|
```tsx
|
|
import { createBillingGatewayService } from '@kit/billing-gateway';
|
|
|
|
// Get service for the configured provider
|
|
const service = createBillingGatewayService(
|
|
process.env.NEXT_PUBLIC_BILLING_PROVIDER
|
|
);
|
|
|
|
// Or specify a provider explicitly
|
|
const stripeService = createBillingGatewayService('stripe');
|
|
```
|
|
|
|
For most operations, get the provider from the user's subscription record:
|
|
|
|
```tsx
|
|
import { createAccountsApi } from '@kit/accounts/api';
|
|
|
|
const accountsApi = createAccountsApi(supabaseClient);
|
|
const subscription = await accountsApi.getSubscription(accountId);
|
|
const provider = subscription?.billing_provider ?? 'stripe';
|
|
|
|
const service = createBillingGatewayService(provider);
|
|
```
|
|
|
|
## Create Checkout Session
|
|
|
|
Start a new subscription or one-off purchase.
|
|
|
|
```tsx
|
|
const { checkoutToken } = await service.createCheckoutSession({
|
|
accountId: 'uuid-of-account',
|
|
plan: billingConfig.products[0].plans[0], // From billing.config.ts
|
|
returnUrl: 'https://yourapp.com/billing/return',
|
|
customerEmail: 'user@example.com', // Optional
|
|
customerId: 'cus_xxx', // Optional, if customer already exists
|
|
enableDiscountField: true, // Optional, show coupon input
|
|
variantQuantities: [ // Optional, for per-seat billing
|
|
{ variantId: 'price_xxx', quantity: 5 }
|
|
],
|
|
});
|
|
```
|
|
|
|
**Parameters:**
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `accountId` | `string` | Yes | UUID of the account making the purchase |
|
|
| `plan` | `Plan` | Yes | Plan object from your billing config |
|
|
| `returnUrl` | `string` | Yes | URL to redirect after checkout |
|
|
| `customerEmail` | `string` | No | Pre-fill customer email |
|
|
| `customerId` | `string` | No | Existing customer ID (skips customer creation) |
|
|
| `enableDiscountField` | `boolean` | No | Show coupon/discount input |
|
|
| `variantQuantities` | `array` | No | Override quantities for line items |
|
|
|
|
**Returns:**
|
|
|
|
```tsx
|
|
{
|
|
checkoutToken: string // Token to open checkout UI
|
|
}
|
|
```
|
|
|
|
**Example: Server Action**
|
|
|
|
```tsx
|
|
'use server';
|
|
|
|
import { createBillingGatewayService } from '@kit/billing-gateway';
|
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|
import billingConfig from '~/config/billing.config';
|
|
|
|
export async function createCheckout(planId: string, accountId: string) {
|
|
const supabase = getSupabaseServerClient();
|
|
const { data: { user } } = await supabase.auth.getUser();
|
|
|
|
if (!user) {
|
|
throw new Error('Not authenticated');
|
|
}
|
|
|
|
const plan = billingConfig.products
|
|
.flatMap(p => p.plans)
|
|
.find(p => p.id === planId);
|
|
|
|
if (!plan) {
|
|
throw new Error('Plan not found');
|
|
}
|
|
|
|
const service = createBillingGatewayService(billingConfig.provider);
|
|
|
|
const { checkoutToken } = await service.createCheckoutSession({
|
|
accountId,
|
|
plan,
|
|
returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/billing/return`,
|
|
customerEmail: user.email,
|
|
});
|
|
|
|
return { checkoutToken };
|
|
}
|
|
```
|
|
|
|
## Retrieve Checkout Session
|
|
|
|
Check the status of a checkout session after redirect.
|
|
|
|
```tsx
|
|
const session = await service.retrieveCheckoutSession({
|
|
sessionId: 'cs_xxx', // From URL params after redirect
|
|
});
|
|
```
|
|
|
|
**Returns:**
|
|
|
|
```tsx
|
|
{
|
|
checkoutToken: string | null,
|
|
status: 'complete' | 'expired' | 'open',
|
|
isSessionOpen: boolean,
|
|
customer: {
|
|
email: string | null
|
|
}
|
|
}
|
|
```
|
|
|
|
**Example: Return page handler**
|
|
|
|
```tsx
|
|
// app/[locale]/home/[account]/billing/return/page.tsx
|
|
import { createBillingGatewayService } from '@kit/billing-gateway';
|
|
|
|
export default async function BillingReturnPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ session_id?: string }>
|
|
}) {
|
|
const { session_id } = await searchParams;
|
|
|
|
if (!session_id) {
|
|
return <div>Invalid session</div>;
|
|
}
|
|
|
|
const service = createBillingGatewayService('stripe');
|
|
const session = await service.retrieveCheckoutSession({
|
|
sessionId: session_id,
|
|
});
|
|
|
|
if (session.status === 'complete') {
|
|
return <div>Payment successful!</div>;
|
|
}
|
|
|
|
return <div>Payment pending or failed</div>;
|
|
}
|
|
```
|
|
|
|
## Create Billing Portal Session
|
|
|
|
Open the customer portal for subscription management.
|
|
|
|
```tsx
|
|
const { url } = await service.createBillingPortalSession({
|
|
customerId: 'cus_xxx', // From billing_customers table
|
|
returnUrl: 'https://yourapp.com/billing',
|
|
});
|
|
|
|
// Redirect user to the portal URL
|
|
```
|
|
|
|
**Parameters:**
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `customerId` | `string` | Yes | Customer ID from billing provider |
|
|
| `returnUrl` | `string` | Yes | URL to redirect after portal session |
|
|
|
|
**Example: Server Action**
|
|
|
|
```tsx
|
|
'use server';
|
|
|
|
import { redirect } from 'next/navigation';
|
|
import { createBillingGatewayService } from '@kit/billing-gateway';
|
|
import { createAccountsApi } from '@kit/accounts/api';
|
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|
|
|
export async function openBillingPortal(accountId: string) {
|
|
const supabase = getSupabaseServerClient();
|
|
const api = createAccountsApi(supabase);
|
|
|
|
const customerId = await api.getCustomerId(accountId);
|
|
|
|
if (!customerId) {
|
|
throw new Error('No billing customer found');
|
|
}
|
|
|
|
const service = createBillingGatewayService('stripe');
|
|
|
|
const { url } = await service.createBillingPortalSession({
|
|
customerId,
|
|
returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/billing`,
|
|
});
|
|
|
|
redirect(url);
|
|
}
|
|
```
|
|
|
|
## Cancel Subscription
|
|
|
|
Cancel a subscription immediately or at period end.
|
|
|
|
```tsx
|
|
const { success } = await service.cancelSubscription({
|
|
subscriptionId: 'sub_xxx',
|
|
invoiceNow: false, // Optional: charge immediately for usage
|
|
});
|
|
```
|
|
|
|
**Parameters:**
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `subscriptionId` | `string` | Yes | Subscription ID from provider |
|
|
| `invoiceNow` | `boolean` | No | Invoice outstanding usage immediately |
|
|
|
|
**Example: Cancel at period end**
|
|
|
|
```tsx
|
|
'use server';
|
|
|
|
import { createBillingGatewayService } from '@kit/billing-gateway';
|
|
import { createAccountsApi } from '@kit/accounts/api';
|
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|
|
|
export async function cancelSubscription(accountId: string) {
|
|
const supabase = getSupabaseServerClient();
|
|
const api = createAccountsApi(supabase);
|
|
|
|
const subscription = await api.getSubscription(accountId);
|
|
|
|
if (!subscription) {
|
|
throw new Error('No subscription found');
|
|
}
|
|
|
|
const service = createBillingGatewayService(subscription.billing_provider);
|
|
|
|
await service.cancelSubscription({
|
|
subscriptionId: subscription.id,
|
|
});
|
|
|
|
return { success: true };
|
|
}
|
|
```
|
|
|
|
## Report Usage (Metered Billing)
|
|
|
|
Report usage for metered billing subscriptions.
|
|
|
|
### Stripe
|
|
|
|
Stripe uses customer ID and a meter event name:
|
|
|
|
```tsx
|
|
await service.reportUsage({
|
|
id: 'cus_xxx', // Customer ID
|
|
eventName: 'api_requests', // Meter name in Stripe
|
|
usage: {
|
|
quantity: 100,
|
|
},
|
|
});
|
|
```
|
|
|
|
### Lemon Squeezy
|
|
|
|
Lemon Squeezy uses subscription item ID:
|
|
|
|
```tsx
|
|
await service.reportUsage({
|
|
id: 'sub_item_xxx', // Subscription item ID
|
|
usage: {
|
|
quantity: 100,
|
|
action: 'increment', // or 'set'
|
|
},
|
|
});
|
|
```
|
|
|
|
**Parameters:**
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `id` | `string` | Yes | Customer ID (Stripe) or subscription item ID (LS) |
|
|
| `eventName` | `string` | Stripe only | Meter event name |
|
|
| `usage.quantity` | `number` | Yes | Usage amount |
|
|
| `usage.action` | `'increment' \| 'set'` | No | How to apply usage (LS only) |
|
|
|
|
**Example: Track API usage**
|
|
|
|
```tsx
|
|
import { createBillingGatewayService } from '@kit/billing-gateway';
|
|
import { createAccountsApi } from '@kit/accounts/api';
|
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|
|
|
export async function trackApiUsage(accountId: string, requestCount: number) {
|
|
const supabase = getSupabaseServerClient();
|
|
const api = createAccountsApi(supabase);
|
|
|
|
const subscription = await api.getSubscription(accountId);
|
|
|
|
if (!subscription || subscription.status !== 'active') {
|
|
return; // No active subscription
|
|
}
|
|
|
|
const service = createBillingGatewayService(subscription.billing_provider);
|
|
const customerId = await api.getCustomerId(accountId);
|
|
|
|
if (subscription.billing_provider === 'stripe') {
|
|
await service.reportUsage({
|
|
id: customerId!,
|
|
eventName: 'api_requests',
|
|
usage: { quantity: requestCount },
|
|
});
|
|
} else {
|
|
// Lemon Squeezy: need subscription item ID
|
|
const { data: item } = await supabase
|
|
.from('subscription_items')
|
|
.select('id')
|
|
.eq('subscription_id', subscription.id)
|
|
.eq('type', 'metered')
|
|
.single();
|
|
|
|
if (item) {
|
|
await service.reportUsage({
|
|
id: item.id,
|
|
usage: { quantity: requestCount, action: 'increment' },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Query Usage
|
|
|
|
Retrieve usage data for a metered subscription.
|
|
|
|
### Stripe
|
|
|
|
```tsx
|
|
const usage = await service.queryUsage({
|
|
id: 'meter_xxx', // Stripe Meter ID
|
|
customerId: 'cus_xxx',
|
|
filter: {
|
|
startTime: Math.floor(Date.now() / 1000) - 86400 * 30, // 30 days ago
|
|
endTime: Math.floor(Date.now() / 1000),
|
|
},
|
|
});
|
|
```
|
|
|
|
### Lemon Squeezy
|
|
|
|
```tsx
|
|
const usage = await service.queryUsage({
|
|
id: 'sub_item_xxx', // Subscription item ID
|
|
customerId: 'cus_xxx',
|
|
filter: {
|
|
page: 1,
|
|
size: 100,
|
|
},
|
|
});
|
|
```
|
|
|
|
**Returns:**
|
|
|
|
```tsx
|
|
{
|
|
value: number // Total usage in period
|
|
}
|
|
```
|
|
|
|
## Update Subscription Item
|
|
|
|
Update the quantity of a subscription item (e.g., seat count).
|
|
|
|
```tsx
|
|
const { success } = await service.updateSubscriptionItem({
|
|
subscriptionId: 'sub_xxx',
|
|
subscriptionItemId: 'si_xxx',
|
|
quantity: 10,
|
|
});
|
|
```
|
|
|
|
**Parameters:**
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `subscriptionId` | `string` | Yes | Subscription ID |
|
|
| `subscriptionItemId` | `string` | Yes | Line item ID within subscription |
|
|
| `quantity` | `number` | Yes | New quantity (minimum 1) |
|
|
|
|
{% alert type="default" title="Automatic seat updates" %}
|
|
For per-seat billing, Makerkit automatically updates seat counts when team members are added or removed. You typically don't need to call this directly.
|
|
{% /alert %}
|
|
|
|
## Get Subscription Details
|
|
|
|
Retrieve subscription details from the provider.
|
|
|
|
```tsx
|
|
const subscription = await service.getSubscription('sub_xxx');
|
|
```
|
|
|
|
**Returns:** Provider-specific subscription object.
|
|
|
|
## Get Plan Details
|
|
|
|
Retrieve plan/price details from the provider.
|
|
|
|
```tsx
|
|
const plan = await service.getPlanById('price_xxx');
|
|
```
|
|
|
|
**Returns:** Provider-specific plan/price object.
|
|
|
|
## Error Handling
|
|
|
|
All methods can throw errors. Wrap calls in try-catch:
|
|
|
|
```tsx
|
|
try {
|
|
const { checkoutToken } = await service.createCheckoutSession({
|
|
// ...
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
console.error('Billing error:', error.message);
|
|
}
|
|
// Handle error appropriately
|
|
}
|
|
```
|
|
|
|
Common errors:
|
|
- Invalid API keys
|
|
- Invalid price/plan IDs
|
|
- Customer not found
|
|
- Subscription not found
|
|
- Network/provider errors
|
|
|
|
## Related Documentation
|
|
|
|
- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Architecture and concepts
|
|
- [Webhooks](/docs/next-supabase-turbo/billing/billing-webhooks) - Handle billing events
|
|
- [Metered Usage](/docs/next-supabase-turbo/billing/metered-usage) - Usage-based billing guide
|
|
- [Per-Seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing
|