Next.js Supabase V3 (#463)

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
This commit is contained in:
Giancarlo Buomprisco
2026-03-24 13:40:38 +08:00
committed by GitHub
parent 4912e402a3
commit 7ebff31475
840 changed files with 71395 additions and 20095 deletions

View File

@@ -0,0 +1,461 @@
---
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

View File

@@ -0,0 +1,632 @@
---
status: "published"
label: "Billing Schema"
title: "Configure SaaS Pricing Plans with the Billing Schema"
order: 1
description: "Define your SaaS pricing with Makerkit's billing schema. Configure products, plans, flat subscriptions, per-seat pricing, metered usage, and one-off payments for Stripe, Lemon Squeezy, or Paddle."
---
The billing schema defines your products and pricing in a single configuration file. This schema drives the pricing table UI, checkout sessions, and subscription management across all supported providers (Stripe, Lemon Squeezy, Paddle).
## Schema Structure
The schema has three levels:
```
Products (what you sell)
└── Plans (pricing options: monthly, yearly)
└── Line Items (how you charge: flat, per-seat, metered)
```
**Example:** A "Pro" product might have "Pro Monthly" and "Pro Yearly" plans. Each plan has line items defining the actual charges.
## Quick Start
Create or edit `apps/web/config/billing.config.ts`:
```tsx
import { createBillingSchema } from '@kit/billing';
const provider = process.env.NEXT_PUBLIC_BILLING_PROVIDER ?? 'stripe';
export default createBillingSchema({
provider,
products: [
{
id: 'pro',
name: 'Pro',
description: 'For growing teams',
currency: 'USD',
badge: 'Popular',
plans: [
{
id: 'pro-monthly',
name: 'Pro Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_xxxxxxxxxxxxx', // Your Stripe Price ID
name: 'Pro Plan',
cost: 29,
type: 'flat',
},
],
},
{
id: 'pro-yearly',
name: 'Pro Yearly',
paymentType: 'recurring',
interval: 'year',
lineItems: [
{
id: 'price_yyyyyyyyyyyyy', // Your Stripe Price ID
name: 'Pro Plan',
cost: 290,
type: 'flat',
},
],
},
],
},
],
});
```
{% alert type="warning" title="Match IDs exactly" %}
Line item `id` values **must match** the Price IDs in your billing provider (Stripe, Lemon Squeezy, or Paddle). The schema validates this format but cannot verify the IDs exist in your provider account.
{% /alert %}
## Setting the Billing Provider
Set the provider via environment variable:
```bash
NEXT_PUBLIC_BILLING_PROVIDER=stripe # or lemon-squeezy, paddle
```
Also update the database configuration:
```sql
UPDATE public.config SET billing_provider = 'stripe';
```
The provider determines which API is called when creating checkouts, managing subscriptions, and processing webhooks.
## Products
Products represent what you're selling (e.g., "Starter", "Pro", "Enterprise"). Each product can have multiple plans with different billing intervals.
```tsx
{
id: 'pro',
name: 'Pro',
description: 'For growing teams',
currency: 'USD',
badge: 'Popular',
highlighted: true,
enableDiscountField: true,
features: [
'Unlimited projects',
'Priority support',
'Advanced analytics',
],
plans: [/* ... */],
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `id` | Yes | Unique identifier (your choice, not the provider's ID) |
| `name` | Yes | Display name in pricing table |
| `description` | Yes | Short description shown to users |
| `currency` | Yes | ISO currency code (e.g., "USD", "EUR") |
| `plans` | Yes | Array of pricing plans |
| `badge` | No | Badge text (e.g., "Popular", "Best Value") |
| `highlighted` | No | Visually highlight this product |
| `enableDiscountField` | No | Show coupon/discount input at checkout |
| `features` | No | Feature list for pricing table |
| `hidden` | No | Hide from pricing table (for legacy plans) |
The `id` is your internal identifier. It doesn't need to match anything in Stripe or your payment provider.
## Plans
Plans define pricing options within a product. Typically, you'll have monthly and yearly variants.
```tsx
{
id: 'pro-monthly',
name: 'Pro Monthly',
paymentType: 'recurring',
interval: 'month',
trialDays: 14,
lineItems: [/* ... */],
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `id` | Yes | Unique identifier (your choice) |
| `name` | Yes | Display name |
| `paymentType` | Yes | `'recurring'` or `'one-time'` |
| `interval` | Recurring only | `'month'` or `'year'` |
| `lineItems` | Yes | Array of line items (charges) |
| `trialDays` | No | Free trial period in days |
| `custom` | No | Mark as custom/enterprise plan (see below) |
| `href` | Custom only | Link for custom plans |
| `label` | Custom only | Button label for custom plans |
| `buttonLabel` | No | Custom checkout button text |
**Plan ID validation:** The schema validates that plan IDs are unique across all products.
## Line Items
Line items define how you charge for a plan. Makerkit supports three types:
| Type | Use Case | Example |
|------|----------|---------|
| `flat` | Fixed recurring price | $29/month |
| `per_seat` | Per-user pricing | $10/seat/month |
| `metered` | Usage-based pricing | $0.01 per API call |
**Provider limitations:**
- **Stripe:** Supports multiple line items per plan (mix flat + per-seat + metered)
- **Lemon Squeezy:** One line item per plan only
- **Paddle:** Flat and per-seat only (no metered billing)
### Flat Subscriptions
The most common pricing model. A fixed amount charged at each billing interval.
```tsx
{
id: 'pro-monthly',
name: 'Pro Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', // Stripe Price ID
name: 'Pro Plan',
cost: 29,
type: 'flat',
},
],
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `id` | Yes | **Must match** your provider's Price ID |
| `name` | Yes | Display name |
| `cost` | Yes | Price (for UI display only) |
| `type` | Yes | `'flat'` |
{% alert type="default" title="Cost is for display only" %}
The `cost` field is used for the pricing table UI. The actual charge comes from your billing provider. Make sure they match to avoid confusing users.
{% /alert %}
### Metered Billing
Charge based on usage (API calls, storage, tokens). You report usage through the billing API, and the provider calculates charges at the end of each billing period.
```tsx
{
id: 'api-monthly',
name: 'API Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
name: 'API Requests',
cost: 0,
type: 'metered',
unit: 'requests',
tiers: [
{ upTo: 1000, cost: 0 }, // First 1000 free
{ upTo: 10000, cost: 0.001 }, // $0.001 per request
{ upTo: 'unlimited', cost: 0.0005 }, // Volume discount
],
},
],
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `id` | Yes | **Must match** your provider's Price ID |
| `name` | Yes | Display name |
| `cost` | Yes | Base cost (usually 0 for metered) |
| `type` | Yes | `'metered'` |
| `unit` | Yes | Unit label (e.g., "requests", "GBs", "tokens") |
| `tiers` | Yes | Array of pricing tiers |
**Tier structure:**
```tsx
{
upTo: number | 'unlimited', // Usage threshold
cost: number, // Cost per unit in this tier
}
```
The last tier should always have `upTo: 'unlimited'`.
{% alert type="warning" title="Provider-specific metered billing" %}
Stripe and Lemon Squeezy handle metered billing differently. See the [metered usage guide](/docs/next-supabase-turbo/billing/metered-usage) for provider-specific implementation details.
{% /alert %}
### Per-Seat Billing
Charge based on team size. Makerkit automatically updates seat counts when members are added or removed from a team account.
```tsx
{
id: 'team-monthly',
name: 'Team Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
name: 'Team Seats',
cost: 0,
type: 'per_seat',
tiers: [
{ upTo: 3, cost: 0 }, // First 3 seats free
{ upTo: 10, cost: 12 }, // $12/seat for 4-10
{ upTo: 'unlimited', cost: 10 }, // Volume discount
],
},
],
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `id` | Yes | **Must match** your provider's Price ID |
| `name` | Yes | Display name |
| `cost` | Yes | Base cost (usually 0 for tiered) |
| `type` | Yes | `'per_seat'` |
| `tiers` | Yes | Array of pricing tiers |
**Common patterns:**
```tsx
// Free tier + flat per-seat
tiers: [
{ upTo: 5, cost: 0 }, // 5 free seats
{ upTo: 'unlimited', cost: 15 },
]
// Volume discounts
tiers: [
{ upTo: 10, cost: 20 }, // $20/seat for 1-10
{ upTo: 50, cost: 15 }, // $15/seat for 11-50
{ upTo: 'unlimited', cost: 10 },
]
// Flat price (no tiers)
tiers: [
{ upTo: 'unlimited', cost: 10 },
]
```
Makerkit handles seat count updates automatically when:
- A new member joins the team
- A member is removed from the team
- A member invitation is accepted
[Full per-seat billing guide →](/docs/next-supabase-turbo/billing/per-seat-billing)
### One-Off Payments
Single charges for lifetime access, add-ons, or credits. One-off payments are stored in the `orders` table instead of `subscriptions`.
```tsx
{
id: 'lifetime',
name: 'Lifetime Access',
paymentType: 'one-time',
// No interval for one-time payments
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
name: 'Lifetime Access',
cost: 299,
type: 'flat',
},
],
}
```
**Key differences from subscriptions:**
- `paymentType` must be `'one-time'`
- No `interval` field
- Line items can only be `type: 'flat'`
- Data is stored in `orders` and `order_items` tables
[Full one-off payments guide →](/docs/next-supabase-turbo/billing/one-off-payments)
## Combining Line Items (Stripe Only)
With Stripe, you can combine multiple line items in a single plan. This is useful for hybrid pricing models:
```tsx
{
id: 'growth-monthly',
name: 'Growth Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
// Base platform fee
{
id: 'price_base_fee',
name: 'Platform Fee',
cost: 49,
type: 'flat',
},
// Per-seat charges
{
id: 'price_seats',
name: 'Team Seats',
cost: 0,
type: 'per_seat',
tiers: [
{ upTo: 5, cost: 0 },
{ upTo: 'unlimited', cost: 10 },
],
},
// Usage-based charges
{
id: 'price_api',
name: 'API Calls',
cost: 0,
type: 'metered',
unit: 'calls',
tiers: [
{ upTo: 10000, cost: 0 },
{ upTo: 'unlimited', cost: 0.001 },
],
},
],
}
```
{% alert type="warning" title="Lemon Squeezy and Paddle limitations" %}
Lemon Squeezy and Paddle only support one line item per plan. The schema validation will fail if you add multiple line items with these providers.
{% /alert %}
## Custom Plans (Enterprise/Contact Us)
Display a plan in the pricing table without checkout functionality. Useful for enterprise tiers or "Contact Us" options.
```tsx
{
id: 'enterprise',
name: 'Enterprise',
paymentType: 'recurring',
interval: 'month',
custom: true,
label: '$5,000+', // or 'common.contactUs' for i18n
href: '/contact',
buttonLabel: 'Contact Sales',
lineItems: [], // Must be empty array
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `custom` | Yes | Set to `true` |
| `label` | Yes | Price label (e.g., "Custom pricing", "$5,000+") |
| `href` | Yes | Link destination (e.g., "/contact", "mailto:sales@...") |
| `buttonLabel` | No | Custom CTA text |
| `lineItems` | Yes | Must be empty array `[]` |
Custom plans appear in the pricing table but clicking them navigates to `href` instead of opening checkout.
## Legacy Plans
When you discontinue a plan but have existing subscribers, use the `hidden` flag to keep the plan in your schema without showing it in the pricing table:
```tsx
{
id: 'old-pro',
name: 'Pro (Legacy)',
description: 'This plan is no longer available',
currency: 'USD',
hidden: true, // Won't appear in pricing table
plans: [
{
id: 'old-pro-monthly',
name: 'Pro Monthly (Legacy)',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_legacy_xxx',
name: 'Pro Plan',
cost: 19,
type: 'flat',
},
],
},
],
}
```
Hidden plans:
- Don't appear in the pricing table
- Still display correctly in the user's billing section
- Allow existing subscribers to continue without issues
**If you remove a plan entirely:** Makerkit will attempt to fetch plan details from the billing provider. This works for `flat` line items only. For complex plans, keep them in your schema with `hidden: true`.
## Schema Validation
The `createBillingSchema` function validates your configuration and throws errors for common mistakes:
| Validation | Rule |
|------------|------|
| Unique Plan IDs | Plan IDs must be unique across all products |
| Unique Line Item IDs | Line item IDs must be unique across all plans |
| Provider constraints | Lemon Squeezy: max 1 line item per plan |
| Required tiers | Metered and per-seat items require `tiers` array |
| One-time payments | Must have `type: 'flat'` line items only |
| Recurring payments | Must specify `interval: 'month'` or `'year'` |
## Complete Example
Here's a full billing schema with multiple products and pricing models:
```tsx
import { createBillingSchema } from '@kit/billing';
const provider = process.env.NEXT_PUBLIC_BILLING_PROVIDER ?? 'stripe';
export default createBillingSchema({
provider,
products: [
// Free tier (custom plan, no billing)
{
id: 'free',
name: 'Free',
description: 'Get started for free',
currency: 'USD',
plans: [
{
id: 'free',
name: 'Free',
paymentType: 'recurring',
interval: 'month',
custom: true,
label: '$0',
href: '/auth/sign-up',
buttonLabel: 'Get Started',
lineItems: [],
},
],
},
// Pro tier with monthly/yearly
{
id: 'pro',
name: 'Pro',
description: 'For professionals and small teams',
currency: 'USD',
badge: 'Popular',
highlighted: true,
features: [
'Unlimited projects',
'Priority support',
'Advanced analytics',
'Custom integrations',
],
plans: [
{
id: 'pro-monthly',
name: 'Pro Monthly',
paymentType: 'recurring',
interval: 'month',
trialDays: 14,
lineItems: [
{
id: 'price_pro_monthly',
name: 'Pro Plan',
cost: 29,
type: 'flat',
},
],
},
{
id: 'pro-yearly',
name: 'Pro Yearly',
paymentType: 'recurring',
interval: 'year',
trialDays: 14,
lineItems: [
{
id: 'price_pro_yearly',
name: 'Pro Plan',
cost: 290,
type: 'flat',
},
],
},
],
},
// Team tier with per-seat pricing
{
id: 'team',
name: 'Team',
description: 'For growing teams',
currency: 'USD',
features: [
'Everything in Pro',
'Team management',
'SSO authentication',
'Audit logs',
],
plans: [
{
id: 'team-monthly',
name: 'Team Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_team_monthly',
name: 'Team Seats',
cost: 0,
type: 'per_seat',
tiers: [
{ upTo: 5, cost: 0 },
{ upTo: 'unlimited', cost: 15 },
],
},
],
},
],
},
// Enterprise tier
{
id: 'enterprise',
name: 'Enterprise',
description: 'For large organizations',
currency: 'USD',
features: [
'Everything in Team',
'Dedicated support',
'Custom contracts',
'SLA guarantees',
],
plans: [
{
id: 'enterprise',
name: 'Enterprise',
paymentType: 'recurring',
interval: 'month',
custom: true,
label: 'Custom',
href: '/contact',
buttonLabel: 'Contact Sales',
lineItems: [],
},
],
},
],
});
```
## Related Documentation
- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Architecture and provider comparison
- [Stripe Setup](/docs/next-supabase-turbo/billing/stripe) - Configure Stripe billing
- [Lemon Squeezy Setup](/docs/next-supabase-turbo/billing/lemon-squeezy) - Configure Lemon Squeezy
- [Paddle Setup](/docs/next-supabase-turbo/billing/paddle) - Configure Paddle
- [Per-Seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing
- [Metered Usage](/docs/next-supabase-turbo/billing/metered-usage) - Usage-based pricing
- [One-Off Payments](/docs/next-supabase-turbo/billing/one-off-payments) - Lifetime deals and add-ons

View File

@@ -0,0 +1,467 @@
---
status: "published"
label: "Handling Webhooks"
title: "Handle Billing Webhooks in Next.js Supabase SaaS Kit"
order: 9
description: "Learn how to handle billing webhooks from Stripe, Lemon Squeezy, and Paddle. Extend the default webhook handler with custom logic for payment events, subscription changes, and more."
---
Webhooks let your billing provider notify your application about events like successful payments, subscription changes, and cancellations. Makerkit handles the core webhook processing, but you can extend it with custom logic.
## Default Webhook Behavior
Makerkit's webhook handler automatically:
1. Verifies the webhook signature
2. Processes the event based on type
3. Updates the database (`subscriptions`, `subscription_items`, `orders`, `order_items`)
4. Returns appropriate HTTP responses
The webhook endpoint is: `/api/billing/webhook`
## Extending the Webhook Handler
Add custom logic by providing callbacks to `handleWebhookEvent`:
```tsx {% title="apps/web/app/api/billing/webhook/route.ts" %}
import { getBillingEventHandlerService } from '@kit/billing-gateway';
import { getPlanTypesMap } from '@kit/billing';
import { enhanceRouteHandler } from '@kit/next/routes';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import billingConfig from '~/config/billing.config';
export const POST = enhanceRouteHandler(
async ({ request }) => {
const provider = billingConfig.provider;
const logger = await getLogger();
const ctx = { name: 'billing.webhook', provider };
logger.info(ctx, 'Received billing webhook');
const supabaseClientProvider = () => getSupabaseServerAdminClient();
const service = await getBillingEventHandlerService(
supabaseClientProvider,
provider,
getPlanTypesMap(billingConfig),
);
try {
await service.handleWebhookEvent(request, {
// Add your custom callbacks here
onCheckoutSessionCompleted: async (subscription, customerId) => {
logger.info({ customerId }, 'Checkout completed');
// Send welcome email, provision resources, etc.
},
onSubscriptionUpdated: async (subscription) => {
logger.info({ subscriptionId: subscription.id }, 'Subscription updated');
// Handle plan changes, sync with external systems
},
onSubscriptionDeleted: async (subscriptionId) => {
logger.info({ subscriptionId }, 'Subscription deleted');
// Clean up resources, send cancellation email
},
onPaymentSucceeded: async (sessionId) => {
logger.info({ sessionId }, 'Payment succeeded');
// Send receipt, update analytics
},
onPaymentFailed: async (sessionId) => {
logger.info({ sessionId }, 'Payment failed');
// Send payment failure notification
},
onInvoicePaid: async (data) => {
logger.info({ accountId: data.target_account_id }, 'Invoice paid');
// Recharge credits, send invoice email
},
});
logger.info(ctx, 'Successfully processed billing webhook');
return new Response('OK', { status: 200 });
} catch (error) {
logger.error({ ...ctx, error }, 'Failed to process billing webhook');
return new Response('Failed to process webhook', { status: 500 });
}
},
{ auth: false } // Webhooks don't require authentication
);
```
## Available Callbacks
### onCheckoutSessionCompleted
Called when a checkout is successfully completed (new subscription or order).
```tsx
onCheckoutSessionCompleted: async (subscription, customerId) => {
// subscription: UpsertSubscriptionParams | UpsertOrderParams
// customerId: string
const accountId = subscription.target_account_id;
// Send welcome email
await sendEmail({
to: subscription.target_customer_email,
template: 'welcome',
data: { planName: subscription.line_items[0]?.product_id },
});
// Provision resources
await provisionResources(accountId);
// Track analytics
await analytics.track('subscription_created', {
accountId,
plan: subscription.line_items[0]?.variant_id,
});
}
```
### onSubscriptionUpdated
Called when a subscription is updated (plan change, renewal, etc.).
```tsx
onSubscriptionUpdated: async (subscription) => {
// subscription: UpsertSubscriptionParams
const accountId = subscription.target_account_id;
const status = subscription.status;
// Handle plan changes
if (subscription.line_items) {
await syncPlanFeatures(accountId, subscription.line_items);
}
// Handle status changes
if (status === 'past_due') {
await sendPaymentReminder(accountId);
}
if (status === 'canceled') {
await scheduleResourceCleanup(accountId);
}
}
```
### onSubscriptionDeleted
Called when a subscription is fully deleted/expired.
```tsx
onSubscriptionDeleted: async (subscriptionId) => {
// subscriptionId: string
// Look up the subscription in your database
const { data: subscription } = await supabase
.from('subscriptions')
.select('account_id')
.eq('id', subscriptionId)
.single();
if (subscription) {
// Clean up resources
await cleanupResources(subscription.account_id);
// Send cancellation email
await sendCancellationEmail(subscription.account_id);
// Update analytics
await analytics.track('subscription_canceled', {
accountId: subscription.account_id,
});
}
}
```
### onPaymentSucceeded
Called when a payment succeeds (for async payment methods like bank transfers).
```tsx
onPaymentSucceeded: async (sessionId) => {
// sessionId: string (checkout session ID)
// Look up the session details
const session = await billingService.retrieveCheckoutSession({ sessionId });
// Send receipt
await sendReceipt(session.customer.email);
}
```
### onPaymentFailed
Called when a payment fails.
```tsx
onPaymentFailed: async (sessionId) => {
// sessionId: string
// Notify the customer
await sendPaymentFailedEmail(sessionId);
// Log for monitoring
logger.warn({ sessionId }, 'Payment failed');
}
```
### onInvoicePaid
Called when an invoice is paid (subscriptions only, useful for credit recharges).
```tsx
onInvoicePaid: async (data) => {
// data: {
// target_account_id: string,
// target_customer_id: string,
// target_customer_email: string,
// line_items: SubscriptionLineItem[],
// }
const accountId = data.target_account_id;
const variantId = data.line_items[0]?.variant_id;
// Recharge credits based on plan
await rechargeCredits(accountId, variantId);
// Send invoice email
await sendInvoiceEmail(data.target_customer_email);
}
```
### onEvent (Catch-All)
Handle any event not covered by the specific callbacks.
```tsx
onEvent: async (event) => {
// event: unknown (provider-specific event object)
// Example: Handle Stripe-specific events
if (event.type === 'invoice.payment_succeeded') {
const invoice = event.data.object as Stripe.Invoice;
// Custom handling
}
// Example: Handle Lemon Squeezy events
if (event.event_name === 'license_key_created') {
// Handle license key creation
}
}
```
## Provider-Specific Events
### Stripe Events
| Event | Callback | Description |
|-------|----------|-------------|
| `checkout.session.completed` | `onCheckoutSessionCompleted` | Checkout completed |
| `customer.subscription.created` | `onSubscriptionUpdated` | New subscription |
| `customer.subscription.updated` | `onSubscriptionUpdated` | Subscription changed |
| `customer.subscription.deleted` | `onSubscriptionDeleted` | Subscription ended |
| `checkout.session.async_payment_succeeded` | `onPaymentSucceeded` | Async payment succeeded |
| `checkout.session.async_payment_failed` | `onPaymentFailed` | Async payment failed |
| `invoice.paid` | `onInvoicePaid` | Invoice paid |
### Lemon Squeezy Events
| Event | Callback | Description |
|-------|----------|-------------|
| `order_created` | `onCheckoutSessionCompleted` | Order created |
| `subscription_created` | `onCheckoutSessionCompleted` | Subscription created |
| `subscription_updated` | `onSubscriptionUpdated` | Subscription updated |
| `subscription_expired` | `onSubscriptionDeleted` | Subscription expired |
### Paddle Events
| Event | Callback | Description |
|-------|----------|-------------|
| `transaction.completed` | `onCheckoutSessionCompleted` | Transaction completed |
| `subscription.activated` | `onSubscriptionUpdated` | Subscription activated |
| `subscription.updated` | `onSubscriptionUpdated` | Subscription updated |
| `subscription.canceled` | `onSubscriptionDeleted` | Subscription canceled |
## Example: Credit Recharge System
Here's a complete example of recharging credits when an invoice is paid:
```tsx {% title="apps/web/app/api/billing/webhook/route.ts" %}
import { getBillingEventHandlerService } from '@kit/billing-gateway';
import { getPlanTypesMap } from '@kit/billing';
import { enhanceRouteHandler } from '@kit/next/routes';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import billingConfig from '~/config/billing.config';
export const POST = enhanceRouteHandler(
async ({ request }) => {
const provider = billingConfig.provider;
const logger = await getLogger();
const adminClient = getSupabaseServerAdminClient();
const service = await getBillingEventHandlerService(
() => adminClient,
provider,
getPlanTypesMap(billingConfig),
);
try {
await service.handleWebhookEvent(request, {
onInvoicePaid: async (data) => {
const accountId = data.target_account_id;
const variantId = data.line_items[0]?.variant_id;
if (!variantId) {
logger.error({ accountId }, 'No variant ID in invoice');
return;
}
// Get credits for this plan from your plans table
const { data: plan } = await adminClient
.from('plans')
.select('tokens')
.eq('variant_id', variantId)
.single();
if (!plan) {
logger.error({ variantId }, 'Plan not found');
return;
}
// Reset credits for the account
const { error } = await adminClient
.from('credits')
.upsert({
account_id: accountId,
tokens: plan.tokens,
});
if (error) {
logger.error({ accountId, error }, 'Failed to update credits');
throw error;
}
logger.info({ accountId, tokens: plan.tokens }, 'Credits recharged');
},
});
return new Response('OK', { status: 200 });
} catch (error) {
logger.error({ error }, 'Webhook processing failed');
return new Response('Failed', { status: 500 });
}
},
{ auth: false }
);
```
## Webhook Security
### Signature Verification
Makerkit automatically verifies webhook signatures. Never disable this in production.
The verification uses:
- **Stripe:** `STRIPE_WEBHOOK_SECRET`
- **Lemon Squeezy:** `LEMON_SQUEEZY_SIGNING_SECRET`
- **Paddle:** `PADDLE_WEBHOOK_SECRET_KEY`
### Idempotency
Webhooks can be delivered multiple times. Make your handlers idempotent:
```tsx
onCheckoutSessionCompleted: async (subscription) => {
// Check if already processed
const { data: existing } = await supabase
.from('processed_webhooks')
.select('id')
.eq('subscription_id', subscription.id)
.single();
if (existing) {
logger.info({ id: subscription.id }, 'Already processed, skipping');
return;
}
// Process the webhook
await processSubscription(subscription);
// Mark as processed
await supabase
.from('processed_webhooks')
.insert({ subscription_id: subscription.id });
}
```
### Error Handling
Return appropriate HTTP status codes:
- **200:** Success (even if you skip processing)
- **500:** Temporary failure (provider will retry)
- **400:** Invalid request (provider won't retry)
```tsx
try {
await service.handleWebhookEvent(request, callbacks);
return new Response('OK', { status: 200 });
} catch (error) {
if (isTemporaryError(error)) {
// Provider will retry
return new Response('Temporary failure', { status: 500 });
}
// Don't retry invalid requests
return new Response('Invalid request', { status: 400 });
}
```
## Debugging Webhooks
### Local Development
Use the Stripe CLI or ngrok to test webhooks locally:
```bash
# Stripe CLI
stripe listen --forward-to localhost:3000/api/billing/webhook
# ngrok (for Lemon Squeezy/Paddle)
ngrok http 3000
```
### Logging
Add detailed logging to track webhook processing:
```tsx
const logger = await getLogger();
logger.info({ eventType: event.type }, 'Processing webhook');
logger.debug({ payload: event }, 'Webhook payload');
logger.error({ error }, 'Webhook failed');
```
### Webhook Logs in Provider Dashboards
Check webhook delivery status:
- **Stripe:** Dashboard → Developers → Webhooks → Recent events
- **Lemon Squeezy:** Settings → Webhooks → View logs
- **Paddle:** Developer Tools → Notifications → View logs
## Related Documentation
- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Architecture and concepts
- [Stripe Setup](/docs/next-supabase-turbo/billing/stripe) - Configure Stripe webhooks
- [Lemon Squeezy Setup](/docs/next-supabase-turbo/billing/lemon-squeezy) - Configure LS webhooks
- [Credit-Based Billing](/docs/next-supabase-turbo/billing/credit-based-billing) - Recharge credits on payment

View File

@@ -0,0 +1,487 @@
---
status: "published"
label: 'Credits Based Billing'
title: 'Implement Credit-Based Billing for AI SaaS Apps'
order: 7
description: 'Build a credit/token system for your AI SaaS. Learn how to add credits tables, consumption tracking, and automatic recharge on subscription renewal in Makerkit.'
---
Credit-based billing charges users based on tokens or credits consumed rather than time. This model is common in AI SaaS applications where users pay for API calls, generated content, or compute time.
Makerkit doesn't include credit-based billing out of the box, but you can implement it using subscriptions plus custom database tables. This guide shows you how.
## Architecture Overview
```
User subscribes → Credits allocated → User consumes credits → Invoice paid → Credits recharged
```
Components:
1. **`plans` table**: Maps subscription variants to credit amounts
2. **`credits` table**: Tracks available credits per account
3. **Database functions**: Check and consume credits
4. **Webhook handler**: Recharge credits on subscription renewal
## Step 1: Create the Plans Table
Store the credit allocation for each plan variant:
```sql
CREATE TABLE public.plans (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
variant_id TEXT NOT NULL UNIQUE,
tokens INTEGER NOT NULL
);
ALTER TABLE public.plans ENABLE ROW LEVEL SECURITY;
-- Allow authenticated users to read plans
CREATE POLICY read_plans ON public.plans
FOR SELECT TO authenticated
USING (true);
-- Insert your plans
INSERT INTO public.plans (name, variant_id, tokens) VALUES
('Starter', 'price_starter_monthly', 1000),
('Pro', 'price_pro_monthly', 10000),
('Enterprise', 'price_enterprise_monthly', 100000);
```
The `variant_id` should match the line item ID in your billing schema (e.g., Stripe Price ID).
## Step 2: Create the Credits Table
Track available credits per account:
```sql
CREATE TABLE public.credits (
account_id UUID PRIMARY KEY REFERENCES public.accounts(id) ON DELETE CASCADE,
tokens INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE public.credits ENABLE ROW LEVEL SECURITY;
-- Users can read their own credits
CREATE POLICY read_credits ON public.credits
FOR SELECT TO authenticated
USING (account_id = (SELECT auth.uid()));
-- Only service role can modify credits
-- No INSERT/UPDATE/DELETE policies for authenticated users
```
{% alert type="warning" title="Security: Restrict credit modifications" %}
Users should only read their credits. All modifications should go through the service role (admin client) to prevent manipulation.
{% /alert %}
## Step 3: Create Helper Functions
### Check if account has enough credits
```sql
CREATE OR REPLACE FUNCTION public.has_credits(
p_account_id UUID,
p_tokens INTEGER
)
RETURNS BOOLEAN
SET search_path = ''
AS $$
BEGIN
RETURN (
SELECT tokens >= p_tokens
FROM public.credits
WHERE account_id = p_account_id
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION public.has_credits TO authenticated, service_role;
```
### Consume credits
```sql
CREATE OR REPLACE FUNCTION public.consume_credits(
p_account_id UUID,
p_tokens INTEGER
)
RETURNS BOOLEAN
SET search_path = ''
AS $$
DECLARE
v_current_tokens INTEGER;
BEGIN
-- Get current balance with row lock
SELECT tokens INTO v_current_tokens
FROM public.credits
WHERE account_id = p_account_id
FOR UPDATE;
-- Check if enough credits
IF v_current_tokens IS NULL OR v_current_tokens < p_tokens THEN
RETURN FALSE;
END IF;
-- Deduct credits
UPDATE public.credits
SET tokens = tokens - p_tokens,
updated_at = NOW()
WHERE account_id = p_account_id;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION public.consume_credits TO service_role;
```
### Add credits (for recharges)
```sql
CREATE OR REPLACE FUNCTION public.add_credits(
p_account_id UUID,
p_tokens INTEGER
)
RETURNS VOID
SET search_path = ''
AS $$
BEGIN
INSERT INTO public.credits (account_id, tokens)
VALUES (p_account_id, p_tokens)
ON CONFLICT (account_id)
DO UPDATE SET
tokens = public.credits.tokens + p_tokens,
updated_at = NOW();
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION public.add_credits TO service_role;
```
### Reset credits (for subscription renewal)
```sql
CREATE OR REPLACE FUNCTION public.reset_credits(
p_account_id UUID,
p_tokens INTEGER
)
RETURNS VOID
SET search_path = ''
AS $$
BEGIN
INSERT INTO public.credits (account_id, tokens)
VALUES (p_account_id, p_tokens)
ON CONFLICT (account_id)
DO UPDATE SET
tokens = p_tokens,
updated_at = NOW();
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION public.reset_credits TO service_role;
```
## Step 4: Consume Credits in Your Application
When a user performs an action that costs credits:
```tsx
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
export async function consumeApiCredits(
accountId: string,
tokensRequired: number
) {
const adminClient = getSupabaseServerAdminClient();
// Consume credits atomically
const { data: success, error } = await adminClient.rpc('consume_credits', {
p_account_id: accountId,
p_tokens: tokensRequired,
});
if (error) {
throw new Error(`Failed to consume credits: ${error.message}`);
}
if (!success) {
throw new Error('Insufficient credits');
}
return true;
}
```
### Example: AI API Route
```tsx
// app/api/ai/generate/route.ts
import { NextResponse } from 'next/server';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
const TOKENS_PER_REQUEST = 10;
export async function POST(request: Request) {
const client = getSupabaseServerClient();
const adminClient = getSupabaseServerAdminClient();
// Get current user's account
const { data: { user } } = await client.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Check credits before processing
const { data: hasCredits } = await client.rpc('has_credits', {
p_account_id: user.id,
p_tokens: TOKENS_PER_REQUEST,
});
if (!hasCredits) {
return NextResponse.json(
{ error: 'Insufficient credits', code: 'INSUFFICIENT_CREDITS' },
{ status: 402 }
);
}
try {
// Call AI API
const { prompt } = await request.json();
const result = await callAIService(prompt);
// Consume credits after successful response
await adminClient.rpc('consume_credits', {
p_account_id: user.id,
p_tokens: TOKENS_PER_REQUEST,
});
return NextResponse.json({ result });
} catch (error) {
return NextResponse.json(
{ error: 'Generation failed' },
{ status: 500 }
);
}
}
```
## Step 5: Display Credits in UI
Create a component to show remaining credits:
```tsx
// components/credits-display.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
export function CreditsDisplay({ accountId }: { accountId: string }) {
const client = useSupabase();
const { data: credits, isLoading } = useQuery({
queryKey: ['credits', accountId],
queryFn: async () => {
const { data, error } = await client
.from('credits')
.select('tokens')
.eq('account_id', accountId)
.single();
if (error) throw error;
return data?.tokens ?? 0;
},
});
if (isLoading) return <span>Loading...</span>;
return (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Credits:</span>
<span className="font-medium">{credits?.toLocaleString()}</span>
</div>
);
}
```
## Step 6: Recharge Credits on Subscription Renewal
Extend the webhook handler to recharge credits when an invoice is paid:
```tsx {% title="apps/web/app/api/billing/webhook/route.ts" %}
import { getBillingEventHandlerService } from '@kit/billing-gateway';
import { getPlanTypesMap } from '@kit/billing';
import { enhanceRouteHandler } from '@kit/next/routes';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import billingConfig from '~/config/billing.config';
export const POST = enhanceRouteHandler(
async ({ request }) => {
const provider = billingConfig.provider;
const logger = await getLogger();
const adminClient = getSupabaseServerAdminClient();
const service = await getBillingEventHandlerService(
() => adminClient,
provider,
getPlanTypesMap(billingConfig),
);
try {
await service.handleWebhookEvent(request, {
onInvoicePaid: async (data) => {
const accountId = data.target_account_id;
const variantId = data.line_items[0]?.variant_id;
if (!variantId) {
logger.warn({ accountId }, 'No variant ID in invoice');
return;
}
// Get token allocation for this plan
const { data: plan, error: planError } = await adminClient
.from('plans')
.select('tokens')
.eq('variant_id', variantId)
.single();
if (planError || !plan) {
logger.error({ variantId, planError }, 'Plan not found');
return;
}
// Reset credits to plan allocation
const { error: creditError } = await adminClient.rpc('reset_credits', {
p_account_id: accountId,
p_tokens: plan.tokens,
});
if (creditError) {
logger.error({ accountId, creditError }, 'Failed to reset credits');
throw creditError;
}
logger.info(
{ accountId, tokens: plan.tokens },
'Credits recharged on invoice payment'
);
},
onCheckoutSessionCompleted: async (subscription) => {
// Also allocate credits on initial subscription
const accountId = subscription.target_account_id;
const variantId = subscription.line_items[0]?.variant_id;
if (!variantId) return;
const { data: plan } = await adminClient
.from('plans')
.select('tokens')
.eq('variant_id', variantId)
.single();
if (plan) {
await adminClient.rpc('reset_credits', {
p_account_id: accountId,
p_tokens: plan.tokens,
});
logger.info(
{ accountId, tokens: plan.tokens },
'Initial credits allocated'
);
}
},
});
return new Response('OK', { status: 200 });
} catch (error) {
logger.error({ error }, 'Webhook failed');
return new Response('Failed', { status: 500 });
}
},
{ auth: false }
);
```
## Step 7: Use Credits in RLS Policies (Optional)
Gate features based on credit balance:
```sql
-- Only allow creating tasks if user has credits
CREATE POLICY tasks_insert_with_credits ON public.tasks
FOR INSERT TO authenticated
WITH CHECK (
public.has_credits((SELECT auth.uid()), 1)
);
-- Only allow API calls if user has credits
CREATE POLICY api_calls_with_credits ON public.api_logs
FOR INSERT TO authenticated
WITH CHECK (
public.has_credits(account_id, 1)
);
```
## Testing
1. Create a subscription in test mode
2. Verify initial credits are allocated
3. Consume some credits via your API
4. Trigger a subscription renewal (Stripe: `stripe trigger invoice.paid`)
5. Verify credits are recharged
## Common Patterns
### Rollover Credits
To allow unused credits to roll over:
```sql
-- In onInvoicePaid, add instead of reset:
await adminClient.rpc('add_credits', {
p_account_id: accountId,
p_tokens: plan.tokens,
});
```
### Credit Expiration
Add an expiration date to credits:
```sql
ALTER TABLE public.credits ADD COLUMN expires_at TIMESTAMPTZ;
-- Check expiration in has_credits function
CREATE OR REPLACE FUNCTION public.has_credits(...)
-- Add: AND (expires_at IS NULL OR expires_at > NOW())
```
### Usage Tracking
Track credit consumption for analytics:
```sql
CREATE TABLE public.credit_transactions (
id SERIAL PRIMARY KEY,
account_id UUID REFERENCES accounts(id),
amount INTEGER NOT NULL,
type TEXT NOT NULL, -- 'consume', 'recharge', 'bonus'
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
## Related Documentation
- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Billing architecture
- [Webhooks](/docs/next-supabase-turbo/billing/billing-webhooks) - Webhook event handling
- [Metered Usage](/docs/next-supabase-turbo/billing/metered-usage) - Alternative usage-based billing
- [Database Functions](/docs/next-supabase-turbo/development/database-functions) - Creating Postgres functions

View File

@@ -0,0 +1,638 @@
---
status: "published"
label: "Custom Integration"
title: "How to create a custom billing integration in Makerkit"
order: 11
description: "Learn how to create a custom billing integration in Makerkit"
---
This guide explains how to create billing integration plugins for the Makerkit SaaS platform to allow you to use a custom billing provider.
{% sequence title="How to create a custom billing integration in Makerkit" description="Learn how to create a custom billing integration in Makerkit" %}
[Architecture Overview](#architecture-overview)
[Package Structure](#package-structure)
[Core Interface Implementation](#core-interface-implementation)
[Environment Configuration](#environment-configuration)
[Billing Strategy Service](#billing-strategy-service)
[Webhook Handler Service](#webhook-handler-service)
[Client-Side Components](#client-side-components)
[Registration and Integration](#registration-and-integration)
[Testing Strategy](#testing-strategy)
[Security Best Practices](#security-best-practices)
[Example Implementation](#example-implementation)
{% /sequence %}
## Architecture Overview
The Makerkit billing system uses a plugin-based architecture that allows multiple billing providers to coexist. The system consists of:
### Core Components
1. **Billing Strategy Provider Service** - Abstract interface for billing operations
2. **Billing Webhook Handler Service** - Abstract interface for webhook processing
3. **Registry System** - Dynamic loading and management of providers
4. **Schema Validation** - Type-safe configuration and data validation
### Provider Structure
Each billing provider is implemented as a separate package under `packages/{provider-name}/` with:
- **Server-side services** - Billing operations and webhook handling
- **Client-side components** - Checkout flows and UI integration
- **Configuration schemas** - Environment variable validation
- **SDK abstractions** - Provider-specific API integrations
### Data Flow
```
Client Request → Registry → Provider Service → External API → Webhook → Handler → Database
```
## Creating a package
You can create a new package for your billing provider by running the following command:
```bash
pnpm turbo gen package
```
This will create a new package in the packages directory, ready to use. You can move this anywhere in the `packages` directory, but we recommend keeping it in the `packages/billing` directory.
## Package Structure
Once we finalize the package structure, your structure should look like this:
```
packages/{provider-name}/
├── package.json
├── tsconfig.json
├── index.ts
└── src/
├── index.ts
├── components/
│ ├── index.ts
│ └── {provider}-checkout.tsx
├── constants/
│ └── {provider}-events.ts
├── schema/
│ ├── {provider}-client-env.schema.ts
│ └── {provider}-server-env.schema.ts
└── services/
├── {provider}-billing-strategy.service.ts
├── {provider}-webhook-handler.service.ts
├── {provider}-sdk.ts
└── create-{provider}-billing-portal-session.ts
```
### package.json Template
```json
{
"name": "@kit/{provider-name}",
"private": true,
"version": "0.1.0",
"exports": {
".": "./src/index.ts",
"./components": "./src/components/index.ts"
},
"typesVersions": {
"*": {
"*": ["src/*"]
}
},
"dependencies": {
"{provider-sdk}": "^x.x.x"
},
"devDependencies": {
"@kit/billing": "workspace:*",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/react": "19.1.13",
"next": "16.0.0",
"react": "19.1.1",
"zod": "^3.25.74"
}
}
```
## Core Interface Implementation
### BillingStrategyProviderService
This abstract class defines the contract for all billing operations:
```typescript {% title="packages/{provider}/src/services/{provider}-billing-strategy.service.ts" %}
import { BillingStrategyProviderService } from '@kit/billing';
export class YourProviderBillingStrategyService
implements BillingStrategyProviderService
{
private readonly namespace = 'billing.{provider}';
async createCheckoutSession(params) {
// Implementation
}
async createBillingPortalSession(params) {
// Implementation
}
async cancelSubscription(params) {
// Implementation
}
async retrieveCheckoutSession(params) {
// Implementation
}
async reportUsage(params) {
// Implementation (if supported)
}
async queryUsage(params) {
// Implementation (if supported)
}
async updateSubscriptionItem(params) {
// Implementation
}
async getPlanById(planId: string) {
// Implementation
}
async getSubscription(subscriptionId: string) {
// Implementation
}
}
```
### BillingWebhookHandlerService
This abstract class handles webhook events from the billing provider:
```typescript {% title="packages/{provider}/src/services/{provider}-webhook-handler.service.ts" %}
import { BillingWebhookHandlerService } from '@kit/billing';
export class YourProviderWebhookHandlerService
implements BillingWebhookHandlerService
{
private readonly provider = '{provider}' as const;
private readonly namespace = 'billing.{provider}';
async verifyWebhookSignature(request: Request) {
// Verify signature using provider's SDK
// Throw error if invalid
}
async handleWebhookEvent(event: unknown, params) {
// Route events to appropriate handlers
switch (event.type) {
case 'subscription.created':
return this.handleSubscriptionCreated(event, params);
case 'subscription.updated':
return this.handleSubscriptionUpdated(event, params);
// ... other events
}
}
}
```
## Environment Configuration
### Server Environment Schema
Create schemas for server-side configuration:
```typescript {% title="packages/{provider}/src/schema/{provider}-server-env.schema.ts" %}
// src/schema/{provider}-server-env.schema.ts
import * as z from 'zod';
export const YourProviderServerEnvSchema = z.object({
apiKey: z.string({
description: '{Provider} API key for server-side operations',
required_error: '{PROVIDER}_API_KEY is required',
}),
webhooksSecret: z.string({
description: '{Provider} webhook secret for verifying signatures',
required_error: '{PROVIDER}_WEBHOOK_SECRET is required',
}),
});
export type YourProviderServerEnv = z.infer<typeof YourProviderServerEnvSchema>;
```
### Client Environment Schema
Create schemas for client-side configuration:
```typescript {% title="packages/{provider}/src/schema/{provider}-client-env.schema.ts" %}
// src/schema/{provider}-client-env.schema.ts
import * as z from 'zod';
export const YourProviderClientEnvSchema = z.object({
publicKey: z.string({
description: '{Provider} public key for client-side operations',
required_error: 'NEXT_PUBLIC_{PROVIDER}_PUBLIC_KEY is required',
}),
});
export type YourProviderClientEnv = z.infer<typeof YourProviderClientEnvSchema>;
```
## Billing Strategy Service
### Implementation Example
{% alert type="warning" title="This is an abstract example" %}
The "client" class in the example below is not a real class, it's just an example of how to implement the BillingStrategyProviderService interface. You should refer to the SDK of your billing provider to implement the actual methods.
{% /alert %}
Here's a detailed implementation pattern based on the Paddle service:
```typescript
import 'server-only';
import * as z from 'zod';
import { BillingStrategyProviderService } from '@kit/billing';
import { getLogger } from '@kit/shared/logger';
import { createYourProviderClient } from './your-provider-sdk';
export class YourProviderBillingStrategyService
implements BillingStrategyProviderService
{
private readonly namespace = 'billing.{provider}';
async createCheckoutSession(
params: z.infer<typeof CreateBillingCheckoutSchema>,
) {
const logger = await getLogger();
const client = await createYourProviderClient();
const ctx = {
name: this.namespace,
customerId: params.customerId,
accountId: params.accountId,
};
logger.info(ctx, 'Creating checkout session...');
try {
const response = await client.checkout.create({
customer: {
id: params.customerId,
email: params.customerEmail,
},
lineItems: params.plan.lineItems.map((item) => ({
priceId: item.id,
quantity: 1,
})),
successUrl: params.returnUrl,
metadata: {
accountId: params.accountId,
},
});
logger.info(ctx, 'Checkout session created successfully');
return {
checkoutToken: response.id,
};
} catch (error) {
logger.error({ ...ctx, error }, 'Failed to create checkout session');
throw new Error('Failed to create checkout session');
}
}
async cancelSubscription(
params: z.infer<typeof CancelSubscriptionParamsSchema>,
) {
const logger = await getLogger();
const client = await createYourProviderClient();
const ctx = {
name: this.namespace,
subscriptionId: params.subscriptionId,
};
logger.info(ctx, 'Cancelling subscription...');
try {
await client.subscriptions.cancel(params.subscriptionId, {
immediate: params.invoiceNow ?? true,
});
logger.info(ctx, 'Subscription cancelled successfully');
return { success: true };
} catch (error) {
logger.error({ ...ctx, error }, 'Failed to cancel subscription');
throw new Error('Failed to cancel subscription');
}
}
// Implement other required methods...
}
```
### SDK Client Wrapper
Create a reusable SDK client:
```typescript
// src/services/{provider}-sdk.ts
import 'server-only';
import { YourProviderServerEnvSchema } from '../schema/{provider}-server-env.schema';
export async function createYourProviderClient() {
// parse the environment variables
const config = YourProviderServerEnvSchema.parse({
apiKey: process.env.{PROVIDER}_API_KEY,
webhooksSecret: process.env.{PROVIDER}_WEBHOOK_SECRET,
});
return new YourProviderSDK({
apiKey: config.apiKey,
});
}
```
## Webhook Handler Service
### Implementation Pattern
```typescript
import { BillingWebhookHandlerService, PlanTypeMap } from '@kit/billing';
import { getLogger } from '@kit/shared/logger';
import { createYourProviderClient } from './your-provider-sdk';
export class YourProviderWebhookHandlerService
implements BillingWebhookHandlerService
{
constructor(private readonly planTypesMap: PlanTypeMap) {}
private readonly provider = '{provider}' as const;
private readonly namespace = 'billing.{provider}';
async verifyWebhookSignature(request: Request) {
const body = await request.clone().text();
const signature = request.headers.get('{provider}-signature');
if (!signature) {
throw new Error('Missing {provider} signature');
}
const { webhooksSecret } = YourProviderServerEnvSchema.parse({
apiKey: process.env.{PROVIDER}_API_KEY,
webhooksSecret: process.env.{PROVIDER}_WEBHOOK_SECRET,
environment: process.env.{PROVIDER}_ENVIRONMENT || 'sandbox',
});
const client = await createYourProviderClient();
try {
const eventData = await client.webhooks.verify(body, signature, webhooksSecret);
if (!eventData) {
throw new Error('Invalid signature');
}
return eventData;
} catch (error) {
throw new Error(`Webhook signature verification failed: ${error}`);
}
}
async handleWebhookEvent(event: unknown, params) {
const logger = await getLogger();
switch (event.type) {
case 'checkout.session.completed': {
return this.handleCheckoutCompleted(event, params.onCheckoutSessionCompleted);
}
case 'customer.subscription.created':
case 'customer.subscription.updated': {
return this.handleSubscriptionUpdated(event, params.onSubscriptionUpdated);
}
case 'customer.subscription.deleted': {
return this.handleSubscriptionDeleted(event, params.onSubscriptionDeleted);
}
default: {
logger.info(
{
name: this.namespace,
eventType: event.type,
},
'Unhandled webhook event type',
);
if (params.onEvent) {
await params.onEvent(event);
}
}
}
}
private async handleCheckoutCompleted(event, onCheckoutSessionCompleted) {
// Extract subscription/order data from event
// Transform to standard format
// Call onCheckoutSessionCompleted with normalized data
}
// Implement other event handlers...
}
```
## Client-Side Components
### Checkout Component
Create a React component for the checkout flow:
```typescript
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { YourProviderClientEnvSchema } from '../schema/{provider}-client-env.schema';
interface YourProviderCheckoutProps {
onClose?: () => void;
checkoutToken: string;
}
const config = YourProviderClientEnvSchema.parse({
publicKey: process.env.NEXT_PUBLIC_{PROVIDER}_PUBLIC_KEY,
environment: process.env.NEXT_PUBLIC_{PROVIDER}_ENVIRONMENT || 'sandbox',
});
export function YourProviderCheckout({
onClose,
checkoutToken,
}: YourProviderCheckoutProps) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function initializeCheckout() {
try {
// Initialize provider's JavaScript SDK
const { YourProviderSDK } = await import('{provider}-js-sdk');
const sdk = new YourProviderSDK({
publicKey: config.publicKey,
environment: config.environment,
});
// Open checkout
await sdk.redirectToCheckout({
sessionId: checkoutToken,
successUrl: window.location.href,
cancelUrl: window.location.href,
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Checkout failed';
setError(errorMessage);
onClose?.();
}
}
void initializeCheckout();
}, [checkoutToken, onClose]);
if (error) {
throw new Error(error);
}
return null; // Provider handles the UI
}
```
## Registration and Integration
### Register Billing Strategy
Add your provider to the billing strategy registry:
```typescript
// packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-registry.ts
// Register {Provider} billing strategy
billingStrategyRegistry.register('{provider}', async () => {
const { YourProviderBillingStrategyService } = await import('@kit/{provider}');
return new YourProviderBillingStrategyService();
});
```
### Register Webhook Handler
Add your provider to the webhook handler factory:
```typescript
// packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts
// Register {Provider} webhook handler
billingWebhookHandlerRegistry.register('{provider}', async () => {
const { YourProviderWebhookHandlerService } = await import('@kit/{provider}');
return new YourProviderWebhookHandlerService(planTypesMap);
});
```
### Update Package Exports
Export your services from the main index file:
```typescript
// packages/{provider}/src/index.ts
export { YourProviderBillingStrategyService } from './services/{provider}-billing-strategy.service';
export { YourProviderWebhookHandlerService } from './services/{provider}-webhook-handler.service';
export * from './components';
export * from './constants/{provider}-events';
export {
YourProviderClientEnvSchema,
type YourProviderClientEnv,
} from './schema/{provider}-client-env.schema';
export {
YourProviderServerEnvSchema,
type YourProviderServerEnv,
} from './schema/{provider}-server-env.schema';
```
## Security Best Practices
### Environment Variables
1. **Never expose secrets in client-side code**
2. **Use different credentials for sandbox and production**
3. **Validate all environment variables with Zod schemas**
4. **Store secrets securely (e.g., in environment variables or secret managers)**
### Webhook Security
1. **Always verify webhook signatures**
2. **Use HTTPS endpoints for webhooks**
3. **Log security events for monitoring**
### Data Handling
1. **Validate all incoming data with Zod schemas**
2. **Sanitize user inputs**
3. **Never log sensitive information (API keys, customer data)**
4. **Use structured logging with appropriate log levels**
### Error Handling
1. **Don't expose internal errors to users**
2. **Log errors with sufficient context for debugging**
3. **Implement proper error boundaries in React components**
4. **Handle rate limiting and API errors gracefully**
## Example Implementation
For a complete reference implementation, see the Stripe integration at `packages/billing/stripe/`. Key files to study:
- `src/services/stripe-billing-strategy.service.ts` - Complete billing strategy implementation
- `src/services/stripe-webhook-handler.service.ts` - Webhook handling patterns
- `src/components/stripe-embedded-checkout.tsx` - Client-side checkout component
- `src/schema/` - Environment configuration schemas
Also take a look at the Lemon Squeezy integration at `packages/billing/lemon-squeezy/` or the Paddle integration at `packages/plugins/paddle/` (in the plugins repository)
## Conclusion
**Important:** Different providers have different APIs, so the implementation will be different for each provider.
Following this guide, you should be able to create a robust billing integration that:
- Implements all required interfaces correctly
- Handles errors gracefully and securely
- Provides a good user experience
- Follows established patterns and best practices
- Integrates seamlessly with the existing billing system
Remember to:
1. Test thoroughly with the provider's sandbox environment
2. Follow security best practices throughout development
3. Document any provider-specific requirements or limitations
4. Consider edge cases and error scenarios
5. Validate your implementation against the existing test suite

View File

@@ -0,0 +1,266 @@
---
status: "published"
label: "Lemon Squeezy"
title: "Configure Lemon Squeezy Billing for Your Next.js SaaS"
order: 3
description: "Complete guide to setting up Lemon Squeezy payments in Makerkit. Lemon Squeezy is a Merchant of Record that handles global tax compliance, billing, and payments for your SaaS."
---
Lemon Squeezy is a Merchant of Record (MoR), meaning they handle all billing complexity for you: VAT, sales tax, invoicing, and compliance across 100+ countries. You receive payouts minus their fees.
## Why Choose Lemon Squeezy?
**Pros:**
- Automatic global tax compliance (VAT, GST, sales tax)
- No need to register for tax collection in different countries
- Simpler setup than Stripe for international sales
- Built-in license key generation (great for desktop apps)
- Lower complexity for solo founders
**Cons:**
- One line item per plan (no mixing flat + metered + per-seat)
- Less flexibility than Stripe
- Higher fees than Stripe in some regions
## Prerequisites
1. Create a [Lemon Squeezy account](https://lemonsqueezy.com)
2. Create a Store in your Lemon Squeezy dashboard
3. Create Products and Variants for your pricing plans
4. Set up a webhook endpoint
## Step 1: Environment Variables
Add these variables to your `.env.local`:
```bash
LEMON_SQUEEZY_SECRET_KEY=your_api_key_here
LEMON_SQUEEZY_SIGNING_SECRET=your_webhook_signing_secret
LEMON_SQUEEZY_STORE_ID=your_store_id
```
| Variable | Description | Where to Find |
|----------|-------------|---------------|
| `LEMON_SQUEEZY_SECRET_KEY` | API key for server-side calls | Settings → API |
| `LEMON_SQUEEZY_SIGNING_SECRET` | Webhook signature verification | Settings → Webhooks |
| `LEMON_SQUEEZY_STORE_ID` | Your store's numeric ID | Settings → Stores |
{% alert type="error" title="Keep secrets secure" %}
Add these to `.env.local` only. Never commit them to your repository or add them to `.env`.
{% /alert %}
## Step 2: Configure Billing Provider
Set Lemon Squeezy as your billing provider in the environment:
```bash
NEXT_PUBLIC_BILLING_PROVIDER=lemon-squeezy
```
And update the database:
```sql
UPDATE public.config SET billing_provider = 'lemon-squeezy';
```
## Step 3: Create Products in Lemon Squeezy
1. Go to your Lemon Squeezy Dashboard → Products
2. Click **New Product**
3. Configure your product:
- **Name**: "Pro Plan", "Starter Plan", etc.
- **Pricing**: Choose subscription or one-time
- **Variant**: Create variants for different billing intervals
4. Copy the **Variant ID** (not Product ID) for your billing schema
The Variant ID looks like `123456` (numeric). This goes in your line item's `id` field.
## Step 4: Update Billing Schema
Lemon Squeezy has a key limitation: **one line item per plan**. You cannot mix flat, per-seat, and metered billing in a single plan.
```tsx
import { createBillingSchema } from '@kit/billing';
export default createBillingSchema({
provider: 'lemon-squeezy',
products: [
{
id: 'pro',
name: 'Pro',
description: 'For professionals',
currency: 'USD',
plans: [
{
id: 'pro-monthly',
name: 'Pro Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: '123456', // Lemon Squeezy Variant ID
name: 'Pro Plan',
cost: 29,
type: 'flat',
},
// Cannot add more line items with Lemon Squeezy!
],
},
],
},
],
});
```
{% alert type="warning" title="Single line item only" %}
The schema validation will fail if you add multiple line items with Lemon Squeezy. This is a platform limitation.
{% /alert %}
## Step 5: Configure Webhooks
### Local Development
Lemon Squeezy requires a public URL for webhooks. Use a tunneling service like ngrok:
```bash
# Install ngrok
npm install -g ngrok
# Expose your local server
ngrok http 3000
```
Copy the ngrok URL (e.g., `https://abc123.ngrok.io`).
### Create Webhook in Lemon Squeezy
1. Go to Settings → Webhooks
2. Click **Add Webhook**
3. Configure:
- **URL**: `https://your-ngrok-url.ngrok.io/api/billing/webhook` (dev) or `https://yourdomain.com/api/billing/webhook` (prod)
- **Secret**: Generate a secure secret and save it as `LEMON_SQUEEZY_SIGNING_SECRET`
4. Select these events:
- `order_created`
- `subscription_created`
- `subscription_updated`
- `subscription_expired`
5. Click **Save**
### Production Webhooks
For production, replace the ngrok URL with your actual domain:
```
https://yourdomain.com/api/billing/webhook
```
## Metered Usage with Lemon Squeezy
Lemon Squeezy handles metered billing differently than Stripe. Usage applies to the entire subscription, not individual line items.
### Setup Fee + Metered Usage
Use the `setupFee` property for a flat base charge plus usage-based pricing:
```tsx
{
id: 'api-monthly',
name: 'API Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: '123456',
name: 'API Access',
cost: 0,
type: 'metered',
unit: 'requests',
setupFee: 10, // $10 base fee
tiers: [
{ upTo: 1000, cost: 0 },
{ upTo: 'unlimited', cost: 0.001 },
],
},
],
}
```
The setup fee is charged once when the subscription is created.
### Reporting Usage
Report usage using the billing API:
```tsx
import { createBillingGatewayService } from '@kit/billing-gateway';
async function reportUsage(subscriptionItemId: string, quantity: number) {
const service = createBillingGatewayService('lemon-squeezy');
return service.reportUsage({
id: subscriptionItemId, // From subscription_items table
usage: {
quantity,
action: 'increment',
},
});
}
```
See the [metered usage guide](/docs/next-supabase-turbo/billing/metered-usage) for complete implementation details.
## Testing
### Test Mode
Lemon Squeezy has a test mode. Enable it in your dashboard under Settings → Test Mode.
Test mode uses separate products and variants, so create test versions of your products.
### Test Cards
In test mode, use these card numbers:
- **Success**: `4242 4242 4242 4242`
- **Decline**: `4000 0000 0000 0002`
Any future expiry date and any 3-digit CVC will work.
## Common Issues
### Webhook signature verification failed
1. Check that `LEMON_SQUEEZY_SIGNING_SECRET` matches the secret in your Lemon Squeezy webhook settings
2. Ensure the raw request body is used for verification (not parsed JSON)
3. Verify the webhook URL is correct
### Subscription not created
1. Check webhook logs in Lemon Squeezy dashboard
2. Verify the `order_created` event is enabled
3. Check your application logs for errors
### Multiple line items error
Lemon Squeezy only supports one line item per plan. Restructure your pricing to use a single line item, or use Stripe for more complex pricing models.
## Testing Checklist
Before going live:
- [ ] Create test products in Lemon Squeezy test mode
- [ ] Test subscription checkout with test card
- [ ] Verify subscription appears in user's billing section
- [ ] Test subscription cancellation
- [ ] Verify webhook events are processed correctly
- [ ] Test with failing card to verify error handling
- [ ] Switch to production products and webhook URL
## Related Documentation
- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Architecture and provider comparison
- [Billing Schema](/docs/next-supabase-turbo/billing/billing-schema) - Configure your pricing
- [Webhooks](/docs/next-supabase-turbo/billing/billing-webhooks) - Custom webhook handling
- [Metered Usage](/docs/next-supabase-turbo/billing/metered-usage) - Usage-based billing implementation

View File

@@ -0,0 +1,399 @@
---
status: "published"
label: "Metered Usage"
title: "Implement Metered Usage Billing for APIs and SaaS"
order: 5
description: "Charge customers based on actual usage with metered billing. Learn how to configure usage-based pricing and report usage to Stripe or Lemon Squeezy in your Next.js SaaS."
---
Metered usage billing charges customers based on consumption (API calls, storage, compute time, etc.). You report usage throughout the billing period, and the provider calculates charges at invoice time.
## How It Works
1. Customer subscribes to a metered plan
2. Your application tracks usage and reports it to the billing provider
3. At the end of each billing period, the provider invoices based on total usage
4. Makerkit stores usage data in `subscription_items` for reference
## Schema Configuration
Define a metered line item in your billing schema:
```tsx {% title="apps/web/config/billing.config.ts" %}
{
id: 'api-plan',
name: 'API Plan',
description: 'Pay only for what you use',
currency: 'USD',
plans: [
{
id: 'api-monthly',
name: 'API Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_api_requests', // Provider Price ID
name: 'API Requests',
cost: 0,
type: 'metered',
unit: 'requests',
tiers: [
{ upTo: 1000, cost: 0 }, // First 1000 free
{ upTo: 10000, cost: 0.001 }, // $0.001/request
{ upTo: 'unlimited', cost: 0.0005 }, // Volume discount
],
},
],
},
],
}
```
The `tiers` define progressive pricing. The last tier should always have `upTo: 'unlimited'`.
## Provider Differences
Stripe and Lemon Squeezy handle metered billing differently:
| Feature | Stripe | Lemon Squeezy |
|---------|--------|---------------|
| Report to | Customer ID + meter name | Subscription item ID |
| Usage action | Implicit increment | Explicit `increment` or `set` |
| Multiple meters | Yes (per customer) | No (per subscription) |
| Real-time usage | Yes (Billing Meter) | Limited |
## Stripe Implementation
Stripe uses [Billing Meters](https://docs.stripe.com/billing/subscriptions/usage-based/implementation-guide) for metered billing.
### 1. Create a Meter in Stripe
1. Go to Stripe Dashboard → Billing → Meters
2. Click **Create meter**
3. Configure:
- **Event name**: `api_requests` (you'll use this in your code)
- **Aggregation**: Sum (most common)
- **Value key**: `value` (default)
### 2. Create a Metered Price
1. Go to Products → Your Product
2. Add a price with **Usage-based** pricing
3. Select your meter
4. Configure tier pricing
### 3. Report Usage
```tsx
import { createBillingGatewayService } from '@kit/billing-gateway';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function reportApiUsage(accountId: string, requestCount: number) {
const supabase = getSupabaseServerClient();
const api = createAccountsApi(supabase);
// Get customer ID for this account
const customerId = await api.getCustomerId(accountId);
if (!customerId) {
throw new Error('No billing customer found');
}
const service = createBillingGatewayService('stripe');
await service.reportUsage({
id: customerId,
eventName: 'api_requests', // Matches your Stripe meter
usage: {
quantity: requestCount,
},
});
}
```
### 4. Integrate with Your API
```tsx
// app/api/data/route.ts
import { NextResponse } from 'next/server';
import { reportApiUsage } from '~/lib/billing';
export async function GET(request: Request) {
const accountId = getAccountIdFromRequest(request);
// Process the request
const data = await fetchData();
// Report usage (fire and forget or await)
reportApiUsage(accountId, 1).catch(console.error);
return NextResponse.json(data);
}
```
For high-volume APIs, batch usage reports:
```tsx
// lib/usage-buffer.ts
const usageBuffer = new Map<string, number>();
export function bufferUsage(accountId: string, quantity: number) {
const current = usageBuffer.get(accountId) ?? 0;
usageBuffer.set(accountId, current + quantity);
}
// Flush every minute
setInterval(async () => {
for (const [accountId, quantity] of usageBuffer.entries()) {
if (quantity > 0) {
await reportApiUsage(accountId, quantity);
usageBuffer.set(accountId, 0);
}
}
}, 60000);
```
## Lemon Squeezy Implementation
Lemon Squeezy requires reporting to a subscription item ID.
### 1. Create a Usage-Based Product
1. Go to Products → New Product
2. Select **Usage-based** pricing
3. Configure your pricing tiers
### 2. Get the Subscription Item ID
After a customer subscribes, find their subscription item:
```tsx
const { data: subscriptionItem } = await supabase
.from('subscription_items')
.select('id')
.eq('subscription_id', subscriptionId)
.eq('type', 'metered')
.single();
```
### 3. Report Usage
```tsx
import { createBillingGatewayService } from '@kit/billing-gateway';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function reportUsageLS(
accountId: string,
quantity: number
) {
const supabase = getSupabaseServerClient();
// Get subscription and item
const { data: subscription } = await supabase
.from('subscriptions')
.select('id')
.eq('account_id', accountId)
.eq('status', 'active')
.single();
if (!subscription) {
throw new Error('No active subscription');
}
const { data: item } = await supabase
.from('subscription_items')
.select('id')
.eq('subscription_id', subscription.id)
.eq('type', 'metered')
.single();
if (!item) {
throw new Error('No metered item found');
}
const service = createBillingGatewayService('lemon-squeezy');
await service.reportUsage({
id: item.id,
usage: {
quantity,
action: 'increment', // or 'set' to replace
},
});
}
```
### Lemon Squeezy Usage Actions
- **`increment`**: Add to existing usage (default)
- **`set`**: Replace the current usage value
```tsx
// Increment by 100
await service.reportUsage({
id: itemId,
usage: { quantity: 100, action: 'increment' },
});
// Set total to 500 (overwrites previous)
await service.reportUsage({
id: itemId,
usage: { quantity: 500, action: 'set' },
});
```
## Querying Usage
### 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,
endTime: Math.floor(Date.now() / 1000),
},
});
console.log(`Total usage: ${usage.value}`);
```
### Lemon Squeezy
```tsx
const usage = await service.queryUsage({
id: 'sub_item_xxx',
customerId: 'cus_xxx',
filter: {
page: 1,
size: 100,
},
});
```
## Combining Metered + Flat Pricing (Stripe Only)
Charge a base fee plus usage:
```tsx
lineItems: [
{
id: 'price_base',
name: 'Platform Access',
cost: 29,
type: 'flat',
},
{
id: 'price_api',
name: 'API Calls',
cost: 0,
type: 'metered',
unit: 'calls',
tiers: [
{ upTo: 10000, cost: 0 }, // Included in base
{ upTo: 'unlimited', cost: 0.001 },
],
},
]
```
## Setup Fee with Metered Usage (Lemon Squeezy)
Lemon Squeezy supports a one-time setup fee:
```tsx
{
id: '123456',
name: 'API Access',
cost: 0,
type: 'metered',
unit: 'requests',
setupFee: 49, // One-time charge on subscription creation
tiers: [
{ upTo: 1000, cost: 0 },
{ upTo: 'unlimited', cost: 0.001 },
],
}
```
## Displaying Usage to Users
Show customers their current usage:
```tsx
'use client';
import { useQuery } from '@tanstack/react-query';
export function UsageDisplay({ accountId }: { accountId: string }) {
const { data: usage, isLoading } = useQuery({
queryKey: ['usage', accountId],
queryFn: () => fetch(`/api/usage/${accountId}`).then(r => r.json()),
refetchInterval: 60000, // Update every minute
});
if (isLoading) return <span>Loading usage...</span>;
return (
<div className="space-y-2">
<div className="flex justify-between">
<span>API Requests</span>
<span>{usage?.requests?.toLocaleString() ?? 0}</span>
</div>
<div className="h-2 bg-muted rounded">
<div
className="h-full bg-primary rounded"
style={{ width: `${Math.min(100, (usage?.requests / 10000) * 100)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{usage?.requests > 10000
? `${((usage.requests - 10000) * 0.001).toFixed(2)} overage`
: `${10000 - usage?.requests} free requests remaining`}
</p>
</div>
);
}
```
## Testing Metered Billing
1. **Create a metered subscription**
2. **Report some usage:**
```bash
# Stripe CLI
stripe billing_meters create_event \
--event-name api_requests \
--payload customer=cus_xxx,value=100
```
3. **Check usage in dashboard**
4. **Create an invoice to see charges:**
```bash
stripe invoices create --customer cus_xxx
stripe invoices finalize inv_xxx
```
## Common Issues
### Usage not appearing
1. Verify the meter event name matches
2. Check that customer ID is correct
3. Look for errors in your application logs
4. Check Stripe Dashboard → Billing → Meters → Events
### Incorrect charges
1. Verify your tier configuration in Stripe matches your schema
2. Check if using graduated vs. volume pricing
3. Review the invoice line items in Stripe Dashboard
## Related Documentation
- [Billing Schema](/docs/next-supabase-turbo/billing/billing-schema) - Configure pricing
- [Billing API](/docs/next-supabase-turbo/billing/billing-api) - Full API reference
- [Credit-Based Billing](/docs/next-supabase-turbo/billing/credit-based-billing) - Alternative usage model
- [Stripe Setup](/docs/next-supabase-turbo/billing/stripe) - Provider configuration

View File

@@ -0,0 +1,387 @@
---
status: "published"
label: "One-Off Payments"
title: "Configure One-Off Payments for Lifetime Deals and Add-Ons"
order: 8
description: "Implement one-time purchases in your SaaS for lifetime access, add-ons, or credits. Learn how to configure one-off payments with Stripe, Lemon Squeezy, or Paddle in Makerkit."
---
One-off payments are single charges for non-recurring products: lifetime access, add-ons, credit packs, or physical goods. Unlike subscriptions, one-off purchases are stored in the `orders` table.
## Use Cases
- **Lifetime access**: One-time purchase for perpetual access
- **Add-ons**: Additional features or capacity
- **Credit packs**: Buy credits/tokens in bulk
- **Digital products**: Templates, courses, ebooks
- **One-time services**: Setup fees, consulting
## Schema Configuration
Define a one-time payment plan:
```tsx {% title="apps/web/config/billing.config.ts" %}
{
id: 'lifetime',
name: 'Lifetime Access',
description: 'Pay once, access forever',
currency: 'USD',
badge: 'Best Value',
features: [
'All Pro features',
'Lifetime updates',
'Priority support',
],
plans: [
{
id: 'lifetime-deal',
name: 'Lifetime Access',
paymentType: 'one-time', // Not recurring
// No interval for one-time
lineItems: [
{
id: 'price_lifetime_xxx', // Provider Price ID
name: 'Lifetime Access',
cost: 299,
type: 'flat', // Only flat is supported for one-time
},
],
},
],
}
```
**Key differences from subscriptions:**
- `paymentType` is `'one-time'` instead of `'recurring'`
- No `interval` field
- Line items must be `type: 'flat'` (no metered or per-seat)
## Provider Setup
### Stripe
1. Create a product in Stripe Dashboard
2. Add a **One-time** price
3. Copy the Price ID to your billing schema
### Lemon Squeezy
1. Create a product with **Single payment** pricing
2. Copy the Variant ID to your billing schema
### Paddle
1. Create a product with one-time pricing
2. Copy the Price ID to your billing schema
## Database Storage
One-off purchases are stored differently than subscriptions:
| Entity | Table | Description |
|--------|-------|-------------|
| Subscriptions | `subscriptions`, `subscription_items` | Recurring payments |
| One-off | `orders`, `order_items` | Single payments |
### Orders Table Schema
```sql
orders
├── id (text) - Order ID from provider
├── account_id (uuid) - Purchasing account
├── billing_customer_id (int) - Customer reference
├── status (payment_status) - 'pending', 'succeeded', 'failed'
├── billing_provider (enum) - 'stripe', 'lemon-squeezy', 'paddle'
├── total_amount (numeric) - Total charge
├── currency (varchar)
└── created_at, updated_at
order_items
├── id (text) - Item ID
├── order_id (text) - Reference to order
├── product_id (text)
├── variant_id (text)
├── price_amount (numeric)
└── quantity (integer)
```
## Checking Order Status
Query orders to check if a user has purchased a product:
```tsx
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function hasLifetimeAccess(accountId: string): Promise<boolean> {
const supabase = getSupabaseServerClient();
const { data: order } = await supabase
.from('orders')
.select('id, status')
.eq('account_id', accountId)
.eq('status', 'succeeded')
.single();
return !!order;
}
// Check for specific product
export async function hasPurchasedProduct(
accountId: string,
productId: string
): Promise<boolean> {
const supabase = getSupabaseServerClient();
const { data: order } = await supabase
.from('orders')
.select(`
id,
order_items!inner(product_id)
`)
.eq('account_id', accountId)
.eq('status', 'succeeded')
.eq('order_items.product_id', productId)
.single();
return !!order;
}
```
## Gating Features
Use order status to control access:
```tsx
// Server Component
import { hasLifetimeAccess } from '~/lib/orders';
export default async function PremiumFeature({
accountId,
}: {
accountId: string;
}) {
const hasAccess = await hasLifetimeAccess(accountId);
if (!hasAccess) {
return <UpgradePrompt />;
}
return <PremiumContent />;
}
```
### RLS Policy Example
Gate database access based on orders:
```sql
-- Function to check if account has a successful order
CREATE OR REPLACE FUNCTION public.has_lifetime_access(p_account_id UUID)
RETURNS BOOLEAN
SET search_path = ''
AS $$
BEGIN
RETURN EXISTS (
SELECT 1
FROM public.orders
WHERE account_id = p_account_id
AND status = 'succeeded'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Example policy
CREATE POLICY premium_content_access ON public.premium_content
FOR SELECT TO authenticated
USING (
public.has_lifetime_access(account_id)
);
```
## Handling Webhooks
One-off payment webhooks work similarly to subscriptions:
```tsx {% title="apps/web/app/api/billing/webhook/route.ts" %}
await service.handleWebhookEvent(request, {
onCheckoutSessionCompleted: async (orderOrSubscription, customerId) => {
// Check if this is an order (one-time) or subscription
if ('order_id' in orderOrSubscription) {
// One-time payment
logger.info({ orderId: orderOrSubscription.order_id }, 'Order completed');
// Provision access, send receipt, etc.
await provisionLifetimeAccess(orderOrSubscription.target_account_id);
await sendOrderReceipt(orderOrSubscription);
} else {
// Subscription
logger.info('Subscription created');
}
},
onPaymentFailed: async (sessionId) => {
// Handle failed one-time payments
await notifyPaymentFailed(sessionId);
},
});
```
### Stripe-Specific Events
For one-off payments, add these webhook events in Stripe:
- `checkout.session.completed`
- `checkout.session.async_payment_failed`
- `checkout.session.async_payment_succeeded`
{% alert type="default" title="Async payment methods" %}
Some payment methods (bank transfers, certain local methods) are asynchronous. Listen for `async_payment_succeeded` to confirm these payments.
{% /alert %}
## Mixing Orders and Subscriptions
You can offer both one-time and recurring products:
```tsx
products: [
// Subscription product
{
id: 'pro',
name: 'Pro',
plans: [
{
id: 'pro-monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [{ id: 'price_monthly', cost: 29, type: 'flat' }],
},
],
},
// One-time product
{
id: 'lifetime',
name: 'Lifetime',
plans: [
{
id: 'lifetime-deal',
paymentType: 'one-time',
lineItems: [{ id: 'price_lifetime', cost: 299, type: 'flat' }],
},
],
},
]
```
Check for either type of access:
```tsx
export async function hasAccess(accountId: string): Promise<boolean> {
const supabase = getSupabaseServerClient();
// Check subscription
const { data: subscription } = await supabase
.from('subscriptions')
.select('id')
.eq('account_id', accountId)
.eq('status', 'active')
.single();
if (subscription) return true;
// Check lifetime order
const { data: order } = await supabase
.from('orders')
.select('id')
.eq('account_id', accountId)
.eq('status', 'succeeded')
.single();
return !!order;
}
```
## Billing Mode Configuration
By default, Makerkit checks subscriptions for billing status. To use orders as the primary billing mechanism (versions before 2.12.0):
```bash
BILLING_MODE=one-time
```
When set, the billing section will display orders instead of subscriptions.
{% alert type="default" title="Version 2.12.0+" %}
From version 2.12.0 onwards, orders and subscriptions can coexist. The `BILLING_MODE` setting is only needed if you want to exclusively use one-time payments.
{% /alert %}
## Add-On Purchases
Sell additional items to existing subscribers:
```tsx
// Add-on product
{
id: 'addon-storage',
name: 'Extra Storage',
plans: [
{
id: 'storage-10gb',
name: '10GB Storage',
paymentType: 'one-time',
lineItems: [
{ id: 'price_storage_10gb', name: '10GB Storage', cost: 19, type: 'flat' },
],
},
],
}
```
Track purchased add-ons:
```tsx
export async function getStorageLimit(accountId: string): Promise<number> {
const supabase = getSupabaseServerClient();
// Base storage from subscription
const baseStorage = 5; // GB
// Additional storage from orders
const { data: orders } = await supabase
.from('orders')
.select('order_items(product_id)')
.eq('account_id', accountId)
.eq('status', 'succeeded');
const additionalStorage = orders?.reduce((total, order) => {
const hasStorage = order.order_items.some(
item => item.product_id === 'storage-10gb'
);
return hasStorage ? total + 10 : total;
}, 0) ?? 0;
return baseStorage + additionalStorage;
}
```
## Testing One-Off Payments
1. **Test checkout:**
- Navigate to your pricing page
- Select the one-time product
- Complete checkout with test card `4242 4242 4242 4242`
2. **Verify database:**
```sql
SELECT * FROM orders WHERE account_id = 'your-account-id';
SELECT * FROM order_items WHERE order_id = 'order-id';
```
3. **Test access gating:**
- Verify features are unlocked after purchase
- Test with accounts that haven't purchased
## Related Documentation
- [Billing Schema](/docs/next-supabase-turbo/billing/billing-schema) - Configure pricing
- [Webhooks](/docs/next-supabase-turbo/billing/billing-webhooks) - Handle payment events
- [Stripe Setup](/docs/next-supabase-turbo/billing/stripe) - Provider configuration
- [Credit-Based Billing](/docs/next-supabase-turbo/billing/credit-based-billing) - Token/credit systems

268
docs/billing/overview.mdoc Normal file
View File

@@ -0,0 +1,268 @@
---
status: "published"
label: "How Billing Works"
title: "Billing in Next.js Supabase Turbo"
description: "Complete guide to implementing billing in your Next.js Supabase SaaS. Configure subscriptions, one-off payments, metered usage, and per-seat pricing with Stripe, Lemon Squeezy, or Paddle."
order: 0
---
Makerkit's billing system lets you accept payments through Stripe, Lemon Squeezy, or Paddle with a unified API. You define your pricing once in a schema, and the gateway routes requests to your chosen provider. Switching providers requires changing one environment variable.
## Quick Start
Set your billing provider:
```bash
NEXT_PUBLIC_BILLING_PROVIDER=stripe # or lemon-squeezy, paddle
```
Update the database configuration to match:
```sql
UPDATE public.config SET billing_provider = 'stripe';
```
Then [configure your billing schema](/docs/next-supabase-turbo/billing/billing-schema) with your products and pricing.
## Choose Your Provider
| Provider | Best For | Tax Handling | Multi-line Items |
|----------|----------|--------------|------------------|
| [Stripe](/docs/next-supabase-turbo/billing/stripe) | Maximum flexibility, global reach | You handle (or use Stripe Tax) | Yes |
| [Lemon Squeezy](/docs/next-supabase-turbo/billing/lemon-squeezy) | Simplicity, automatic tax compliance | Merchant of Record | No (1 per plan) |
| [Paddle](/docs/next-supabase-turbo/billing/paddle) | B2B SaaS, automatic tax compliance | Merchant of Record | No (flat + per-seat only) |
**Merchant of Record** means Lemon Squeezy and Paddle handle VAT, sales tax, and compliance globally. With Stripe, you're responsible for tax collection (though Stripe Tax can help).
## Supported Pricing Models
Makerkit supports four billing models out of the box:
### Flat Subscriptions
Fixed monthly or annual pricing. The most common SaaS model.
```tsx
{
id: 'price_xxx',
name: 'Pro Plan',
cost: 29,
type: 'flat',
}
```
[Learn more about configuring flat subscriptions →](/docs/next-supabase-turbo/billing/billing-schema#flat-subscriptions)
### Per-Seat Billing
Charge based on team size. Makerkit automatically updates seat counts when members join or leave.
```tsx
{
id: 'price_xxx',
name: 'Team',
cost: 0,
type: 'per_seat',
tiers: [
{ upTo: 3, cost: 0 }, // First 3 seats free
{ upTo: 10, cost: 12 }, // $12/seat up to 10
{ upTo: 'unlimited', cost: 10 },
]
}
```
[Configure per-seat billing →](/docs/next-supabase-turbo/billing/per-seat-billing)
### Metered Usage
Charge based on consumption (API calls, storage, tokens). Report usage through the billing API.
```tsx
{
id: 'price_xxx',
name: 'API Requests',
cost: 0,
type: 'metered',
unit: 'requests',
tiers: [
{ upTo: 1000, cost: 0 },
{ upTo: 'unlimited', cost: 0.001 },
]
}
```
[Set up metered billing →](/docs/next-supabase-turbo/billing/metered-usage)
### One-Off Payments
Lifetime deals, add-ons, or credits. Stored in the `orders` table instead of `subscriptions`.
```tsx
{
paymentType: 'one-time',
lineItems: [{
id: 'price_xxx',
name: 'Lifetime Access',
cost: 299,
type: 'flat',
}]
}
```
[Configure one-off payments →](/docs/next-supabase-turbo/billing/one-off-payments)
### Credit-Based Billing
For AI SaaS and token-based systems. Combine subscriptions with a credits table for consumption tracking.
[Implement credit-based billing →](/docs/next-supabase-turbo/billing/credit-based-billing)
## Architecture Overview
The billing system uses a provider-agnostic architecture:
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Your App │────▶│ Gateway │────▶│ Provider │
│ (billing.config) │ (routes requests) │ (Stripe/LS/Paddle)
└─────────────────┘ └─────────────────┘ └─────────────────┘
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Database │◀────│ Webhook Handler│◀────│ Webhook │
│ (subscriptions) │ (processes events) │ (payment events)
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
**Package structure:**
- `@kit/billing` (core): Schema validation, interfaces, types
- `@kit/billing-gateway`: Provider routing, unified API
- `@kit/stripe`: Stripe-specific implementation
- `@kit/lemon-squeezy`: Lemon Squeezy-specific implementation
- `@kit/paddle`: Paddle-specific implementation (plugin)
This abstraction means your application code stays the same regardless of provider. The billing schema defines what you sell, and each provider package handles the API specifics.
## Database Schema
Billing data is stored in four main tables:
| Table | Purpose |
|-------|---------|
| `billing_customers` | Links accounts to provider customer IDs |
| `subscriptions` | Active and historical subscription records |
| `subscription_items` | Line items within subscriptions (for per-seat, metered) |
| `orders` | One-off payment records |
| `order_items` | Items within one-off orders |
All tables have Row Level Security (RLS) enabled. Users can only read their own billing data.
## Configuration Files
### billing.config.ts
Your pricing schema lives at `apps/web/config/billing.config.ts`:
```tsx
import { createBillingSchema } from '@kit/billing';
export default createBillingSchema({
provider: process.env.NEXT_PUBLIC_BILLING_PROVIDER,
products: [
{
id: 'starter',
name: 'Starter',
description: 'For individuals',
currency: 'USD',
plans: [/* ... */],
},
],
});
```
[Full billing schema documentation →](/docs/next-supabase-turbo/billing/billing-schema)
### Environment Variables
Each provider requires specific environment variables:
**Stripe:**
```bash
STRIPE_SECRET_KEY=sk_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_...
```
**Lemon Squeezy:**
```bash
LEMON_SQUEEZY_SECRET_KEY=...
LEMON_SQUEEZY_SIGNING_SECRET=...
LEMON_SQUEEZY_STORE_ID=...
```
**Paddle:**
```bash
PADDLE_API_KEY=...
PADDLE_WEBHOOK_SECRET_KEY=...
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=...
```
## Common Tasks
### Check if an account has a subscription
```tsx
import { createAccountsApi } from '@kit/accounts/api';
const api = createAccountsApi(supabaseClient);
const subscription = await api.getSubscription(accountId);
if (subscription?.status === 'active') {
// User has active subscription
}
```
### Create a checkout session
```tsx
import { createBillingGatewayService } from '@kit/billing-gateway';
const service = createBillingGatewayService(provider);
const { checkoutToken } = await service.createCheckoutSession({
accountId,
plan,
returnUrl: `${origin}/billing/return`,
customerEmail: user.email,
});
```
### Handle billing webhooks
Webhooks are processed at `/api/billing/webhook`. Extend the handler for custom logic:
```tsx
await service.handleWebhookEvent(request, {
onCheckoutSessionCompleted: async (subscription) => {
// Send welcome email, provision resources, etc.
},
onSubscriptionDeleted: async (subscriptionId) => {
// Clean up, send cancellation email, etc.
},
});
```
[Full webhook documentation →](/docs/next-supabase-turbo/billing/billing-webhooks)
## Next Steps
1. **[Configure your billing schema](/docs/next-supabase-turbo/billing/billing-schema)** to define your products and pricing
2. **Set up your payment provider:** [Stripe](/docs/next-supabase-turbo/billing/stripe), [Lemon Squeezy](/docs/next-supabase-turbo/billing/lemon-squeezy), or [Paddle](/docs/next-supabase-turbo/billing/paddle)
3. **[Handle webhooks](/docs/next-supabase-turbo/billing/billing-webhooks)** for payment events
4. **[Use the billing API](/docs/next-supabase-turbo/billing/billing-api)** to manage subscriptions programmatically
For advanced use cases:
- [Per-seat billing](/docs/next-supabase-turbo/billing/per-seat-billing) for team-based pricing
- [Metered usage](/docs/next-supabase-turbo/billing/metered-usage) for consumption-based billing
- [Credit-based billing](/docs/next-supabase-turbo/billing/credit-based-billing) for AI/token systems
- [Custom integrations](/docs/next-supabase-turbo/billing/custom-integration) for other payment providers

475
docs/billing/paddle.mdoc Normal file
View File

@@ -0,0 +1,475 @@
---
status: "published"
label: 'Paddle'
title: 'Configuring Paddle Billing | Next.js Supabase SaaS Kit Turbo'
order: 4
description: 'Complete guide to integrating Paddle billing with your Next.js Supabase SaaS application. Learn how to set up payment processing, webhooks, and subscription management with Paddle as your Merchant of Record.'
---
Paddle is a comprehensive billing solution that acts as a Merchant of Record (MoR), handling all payment processing, tax calculations, compliance, and regulatory requirements for your SaaS business.
This integration eliminates the complexity of managing global tax compliance, PCI requirements, and payment processing infrastructure.
## Overview
This guide will walk you through:
- Setting up Paddle for development and production
- Configuring webhooks for real-time billing events
- Creating and managing subscription products
- Testing the complete billing flow
- Deploying to production
## Limitations
Paddle currently supports flat and per-seat plans. Metered subscriptions are not supported with Paddle.
## Prerequisites
Before starting, ensure you have:
- A Paddle account (sandbox for development, live for production)
- Access to your application's environment configuration
- A method to expose your local development server (ngrok, LocalTunnel, Localcan, etc.)
## Step 0: Fetch the Paddle package from the plugins repository
The Paddle package is released as a plugin in the Plugins repository. You can fetch it by running the following command:
```bash
npx @makerkit/cli@latest plugins install
```
Please choose the Paddle plugin from the list of available plugins.
## Step 1: Registering Paddle
Now we need to register the services from the Paddle plugin.
### Install the Paddle package
Run the following command to add the Paddle package to our billing package:
```bash
pnpm --filter @kit/billing-gateway add "@kit/paddle@workspace:*"
```
### Registering the Checkout component
Update the function `loadCheckoutComponent` to include the `paddle` block,
which will dynamically import the Paddle checkout component:
```tsx {% title="packages/billing/gateway/src/components/embedded-checkout.tsx" %}
import { Suspense, lazy } from 'react';
import { Enums } from '@kit/supabase/database';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
type BillingProvider = Enums<'billing_provider'>;
// Create lazy components at module level (not during render)
const StripeCheckoutLazy = lazy(async () => {
const { StripeCheckout } = await import('@kit/stripe/components');
return { default: StripeCheckout };
});
const LemonSqueezyCheckoutLazy = lazy(async () => {
const { LemonSqueezyEmbeddedCheckout } =
await import('@kit/lemon-squeezy/components');
return { default: LemonSqueezyEmbeddedCheckout };
});
const PaddleCheckoutLazy = lazy(async () => {
const { PaddleCheckout } = await import(
'@kit/paddle/components'
);
return { default: PaddleCheckout };
});
type CheckoutProps = {
onClose: (() => unknown) | undefined;
checkoutToken: string;
};
export function EmbeddedCheckout(
props: React.PropsWithChildren<{
checkoutToken: string;
provider: BillingProvider;
onClose?: () => void;
}>,
) {
return (
<>
<Suspense fallback={<LoadingOverlay fullPage={false} />}>
<CheckoutSelector
provider={props.provider}
onClose={props.onClose}
checkoutToken={props.checkoutToken}
/>
</Suspense>
<BlurryBackdrop />
</>
);
}
function CheckoutSelector(
props: CheckoutProps & { provider: BillingProvider },
) {
switch (props.provider) {
case 'stripe':
return (
<StripeCheckoutLazy
onClose={props.onClose}
checkoutToken={props.checkoutToken}
/>
);
case 'lemon-squeezy':
return (
<LemonSqueezyCheckoutLazy
onClose={props.onClose}
checkoutToken={props.checkoutToken}
/>
);
case 'paddle':
return (
<PaddleCheckoutLazy
onClose={props.onClose}
checkoutToken={props.checkoutToken}
/>
)
default:
throw new Error(`Unsupported provider: ${props.provider as string}`);
}
}
function BlurryBackdrop() {
return (
<div
className={
'bg-background/30 fixed top-0 left-0 w-full backdrop-blur-sm' +
' !m-0 h-full'
}
/>
);
}
```
### Registering the Webhook handler
At `packages/billing/gateway/src/server/services/billing-event-handler
/billing-event-handler-factory.service.ts`, add the snippet below at the
bottom of the file:
```tsx {% title="packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts" %}
// Register Paddle webhook handler
billingWebhookHandlerRegistry.register('paddle', async () => {
const { PaddleWebhookHandlerService } = await import('@kit/paddle');
return new PaddleWebhookHandlerService(planTypesMap);
});
```
### Registering the Billing service
Finally, at `packages/billing/gateway/src/server/services/billing-event-handler
/billing-gateway-registry.ts`, add the snippet below at the
bottom of the file:
```tsx {% title="packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-registry.ts" %}
// Register Paddle billing strategy
billingStrategyRegistry.register('paddle', async () => {
const { PaddleBillingStrategyService } = await import('@kit/paddle');
return new PaddleBillingStrategyService();
});
```
## Step 2: Create Paddle Account
### Development Account (Sandbox)
1. Visit [Paddle Developer Console](https://sandbox-vendors.paddle.com/signup)
2. Complete the registration process
3. Verify your email address
4. Navigate to your sandbox dashboard
### Important Notes
- The sandbox environment allows unlimited testing without processing real payments
- All transactions in sandbox mode use test card numbers
- Webhooks and API calls work identically to production
- The Paddle payment provider currently only supports flat and per-seat plans (metered subscriptions are not supported)
## Step 3: Configure Billing Provider
### Database Configuration
Set Paddle as your billing provider in the database:
```sql
-- Update the billing provider in your configuration table
UPDATE public.config
set billing_provider = 'paddle';
```
### Environment Configuration
Add the following to your `.env.local` file:
```bash
# Set Paddle as the active billing provider
NEXT_PUBLIC_BILLING_PROVIDER=paddle
```
This environment variable tells your application to use Paddle-specific components and API endpoints for billing operations.
## Step 4: API Key Configuration
Paddle requires two types of API keys for complete integration:
### Server-Side API Key (Required)
1. In your Paddle dashboard, navigate to **Developer Tools** → **Authentication**
2. Click **Generate New API Key**
3. Give it a descriptive name (e.g., "Production API Key" or "Development API Key")
4. **Configure the required permissions** for your API key:
- **Write Customer Portal Sessions** - For managing customer billing portals
- **Read Customers** - For retrieving customer information
- **Read Prices** - For displaying pricing information
- **Read Products** - For product catalog access
- **Read/Write Subscriptions** - For subscription management
- **Read Transactions** - For payment and transaction tracking
5. Copy the generated key immediately (it won't be shown again)
6. Add to your `.env.local`:
```bash
PADDLE_API_KEY=your_server_api_key_here
```
**Security Note**: This key has access to the specified Paddle account permissions and should never be exposed to the client-side code. Only grant the minimum permissions required for your integration.
### Client-Side Token (Required)
1. In the same **Authentication** section, look for **Client-side tokens**
2. Click **New Client-Side Token**
3. Copy the client token
4. Add to your `.env.local`:
```bash
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=your_client_token_here
```
**Important**: This token is safe to expose in client-side code but should be restricted to your specific domains.
## Step 5: Webhook Configuration
Webhooks enable real-time synchronization between Paddle and your application for events like successful payments, subscription changes, and cancellations.
### Set Up Local Development Tunnel
First, expose your local development server to the internet:
#### Using ngrok (Recommended)
```bash
# Install ngrok if not already installed
npm install -g ngrok
# Expose port 3000 (default Next.js port)
ngrok http 3000
```
#### Using LocalTunnel
```bash
# Install localtunnel
npm install -g localtunnel
# Expose port 3000
lt --port 3000
```
### Configure Webhook Destination
1. In Paddle dashboard, go to **Developer Tools** → **Notifications**
2. Click **New Destination**
3. Configure the destination:
- **Destination URL**: `https://your-tunnel-url.ngrok.io/api/billing/webhook`
- **Description**: "Local Development Webhook"
- **Active**: ✅ Checked
### Select Webhook Events
Enable these essential events for proper billing integration:
### Retrieve Webhook Secret
1. After creating the destination, click on it to view details
2. Copy the **Endpoint Secret** (used to verify webhook authenticity)
3. Add to your `.env.local`:
```bash
PADDLE_WEBHOOK_SECRET_KEY=your_webhook_secret_here
```
### Test Webhook Connection
You can test the webhook endpoint by making a GET request to verify it's accessible:
```bash
curl https://your-tunnel-url.ngrok.io/api/billing/webhook
```
Expected response: `200 OK` with a message indicating the webhook endpoint is active.
## Step 6: Product and Pricing Configuration
### Create Products in Paddle
1. Navigate to **Catalog** → **Products** in your Paddle dashboard
2. Click **Create Product**
3. Configure your product:
**Basic Information:**
- **Product Name**: "Starter Plan", "Pro Plan", etc.
- **Description**: Detailed description of the plan features
- **Tax Category**: Select appropriate category (usually "Software")
**Pricing Configuration:**
- **Billing Interval**: Monthly, Yearly, or Custom
- **Price**: Set in your primary currency
- **Trial Period**: Optional free trial duration
### Configure Billing Settings
Update your billing configuration file with the Paddle product IDs:
```typescript
// apps/web/config/billing.config.ts
export const billingConfig = {
provider: 'paddle',
products: [
{
id: 'starter',
name: 'Starter Plan',
description: 'Perfect for individuals and small teams',
badge: 'Most Popular',
features: [
'Up to 5 projects',
'Basic support',
'1GB storage'
],
plans: [
{
name: 'Starter Monthly',
id: 'starter-monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'pri_starter_monthly_001', // Paddle Price ID
name: 'Starter',
cost: 9.99,
type: 'flat' as const,
},
],
}
]
}
// Add more products...
]
};
```
## Step 7: Checkout Configuration
### Default Payment Link Configuration
1. Go to **Checkout** → **Checkout Settings** in Paddle dashboard
2. Configure **Default Payment Link**: use`http://localhost:3000` - but when deploying to production, you should use your production domain.
3. Save the configuration
## Step 8: Testing the Integration
### Development Testing Checklist
**Environment Verification:**
- [ ] All environment variables are set correctly
- [ ] Webhook tunnel is active and accessible
- [ ] Destination was defined using the correct URL
- [ ] Database billing provider is set to 'paddle' in both DB and ENV
**Subscription Flow Testing:**
1. Navigate to your billing/pricing page (`/home/billing` or equivalent)
2. Click on a subscription plan
3. Complete the checkout flow using Paddle test cards
4. Verify successful redirect to success page
5. Check that subscription appears in user dashboard
6. Verify webhook events are received in your application logs
You can also test cancellation flows:
- cancel the subscription from the billing portal
- delete the account and verify the subscription is cancelled as well
### Test Card Numbers
[Follow this link to get the test card numbers](https://developer.paddle.com/concepts/payment-methods/credit-debit-card#test-payment-method)
### Webhook Testing
Monitor webhook delivery in your application logs:
```bash
# Watch your development logs
pnpm dev
# In another terminal, monitor webhook requests
tail -f logs/webhook.log
```
## Step 9: Production Deployment
### Apply for Live Paddle Account
1. In your Paddle dashboard, click **Go Live**
2. Complete the application process:
- Business information and verification
- Tax information and documentation
- Banking details for payouts
- Identity verification for key personnel
**Timeline**: Live account approval typically takes 1-3 business days.
### Production Environment Setup
Create production-specific configuration:
```bash
# Production environment variables
NEXT_PUBLIC_BILLING_PROVIDER=paddle
PADDLE_API_KEY=your_production_api_key
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=your_production_client_token
PADDLE_WEBHOOK_SECRET_KEY=your_production_webhook_secret
```
### Production Webhook Configuration
1. Create a new webhook destination for production
2. Set the destination URL to your production domain:
```
https://yourdomain.com/api/billing/webhook
```
3. Enable the same events as configured for development
4. Update your production environment with the new webhook secret
### Production Products and Pricing
1. Create production versions of your products in the live environment
2. Update your production billing configuration with live Price IDs
3. Test the complete flow on production with small-amount transactions
### Support Resources
Refer to the [Paddle Documentation](https://developer.paddle.com) for more information:
- **Paddle Documentation**: [https://developer.paddle.com](https://developer.paddle.com)
- **Status Page**: [https://status.paddle.com](https://status.paddle.com)

View File

@@ -0,0 +1,293 @@
---
status: "published"
label: "Per Seat Billing"
title: "Configure Per-Seat Billing for Team Subscriptions"
order: 6
description: "Implement per-seat pricing for your SaaS. Makerkit automatically tracks team members and updates seat counts with your billing provider when members join or leave."
---
Per-seat billing charges customers based on the number of users (seats) in their team. Makerkit handles this automatically: when team members are added or removed, the subscription is updated with the new seat count.
## How It Works
1. You define a `per_seat` line item in your billing schema
2. When a team subscribes, Makerkit counts current members and sets the initial quantity
3. When members join or leave, Makerkit updates the subscription quantity
4. Your billing provider (Stripe, Lemon Squeezy, Paddle) handles proration
No custom code required for basic per-seat billing.
## Schema Configuration
Define a per-seat line item in your billing schema:
```tsx {% title="apps/web/config/billing.config.ts" %}
import { createBillingSchema } from '@kit/billing';
export default createBillingSchema({
provider: process.env.NEXT_PUBLIC_BILLING_PROVIDER,
products: [
{
id: 'team',
name: 'Team',
description: 'For growing teams',
currency: 'USD',
features: [
'Unlimited projects',
'Team collaboration',
'Priority support',
],
plans: [
{
id: 'team-monthly',
name: 'Team Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_team_monthly', // Your Stripe Price ID
name: 'Team Seats',
cost: 0, // Base cost (calculated from tiers)
type: 'per_seat',
tiers: [
{ upTo: 3, cost: 0 }, // First 3 seats free
{ upTo: 10, cost: 15 }, // $15/seat for seats 4-10
{ upTo: 'unlimited', cost: 12 }, // Volume discount
],
},
],
},
],
},
],
});
```
## Pricing Tier Patterns
### Free Tier + Per-Seat
Include free seats for small teams:
```tsx
tiers: [
{ upTo: 5, cost: 0 }, // 5 free seats
{ upTo: 'unlimited', cost: 10 }, // $10/seat after
]
```
### Flat Per-Seat (No Tiers)
Simple per-seat pricing:
```tsx
tiers: [
{ upTo: 'unlimited', cost: 15 }, // $15/seat for all seats
]
```
### Volume Discounts
Reward larger teams:
```tsx
tiers: [
{ upTo: 10, cost: 20 }, // $20/seat for 1-10
{ upTo: 50, cost: 15 }, // $15/seat for 11-50
{ upTo: 100, cost: 12 }, // $12/seat for 51-100
{ upTo: 'unlimited', cost: 10 }, // $10/seat for 100+
]
```
### Base Fee + Per-Seat (Stripe Only)
Combine a flat fee with per-seat pricing:
```tsx
lineItems: [
{
id: 'price_base_fee',
name: 'Platform Fee',
cost: 49,
type: 'flat',
},
{
id: 'price_seats',
name: 'Team Seats',
cost: 0,
type: 'per_seat',
tiers: [
{ upTo: 5, cost: 0 },
{ upTo: 'unlimited', cost: 10 },
],
},
]
```
{% alert type="warning" title="Stripe only" %}
Multiple line items (flat + per-seat) only work with Stripe. Lemon Squeezy and Paddle support one line item per plan.
{% /alert %}
## Provider Setup
### Stripe
1. Create a product in Stripe Dashboard
2. Add a price with **Graduated pricing** or **Volume pricing**
3. Set the pricing tiers to match your schema
4. Copy the Price ID (e.g., `price_xxx`) to your line item `id`
**Stripe pricing types:**
- **Graduated**: Each tier applies to that range only (e.g., seats 1-5 at $0, seats 6-10 at $15)
- **Volume**: The price for all units is determined by the total quantity
### Lemon Squeezy
1. Create a product with **Usage-based pricing**
2. Configure the pricing tiers
3. Copy the Variant ID to your line item `id`
### Paddle
1. Create a product with quantity-based pricing
2. Configure as needed (Paddle handles proration automatically)
3. Copy the Price ID to your line item `id`
{% alert type="default" title="Paddle trial limitation" %}
Paddle doesn't support updating subscription quantities during a trial period. If using per-seat billing with Paddle trials, consider using Feature Policies to restrict invitations during trials.
{% /alert %}
## Automatic Seat Updates
Makerkit automatically updates seat counts when:
| Action | Effect |
|--------|--------|
| Team member accepts invitation | Seat count increases |
| Team member is removed | Seat count decreases |
| Team member leaves | Seat count decreases |
| Account is deleted | Subscription is canceled |
The billing provider handles proration based on your settings.
## Testing Per-Seat Billing
1. **Create a team subscription:**
- Sign up and create a team account
- Subscribe to a per-seat plan
- Verify the initial seat count matches team size
2. **Add a member:**
- Invite a new member to the team
- Have them accept the invitation
- Check Stripe/LS/Paddle: subscription quantity should increase
3. **Remove a member:**
- Remove a member from the team
- Check: subscription quantity should decrease
4. **Verify proration:**
- Check the upcoming invoice in your provider dashboard
- Confirm proration is calculated correctly
## Manual Seat Updates (Advanced)
In rare cases, you might need to manually update seat counts:
```tsx
import { createBillingGatewayService } from '@kit/billing-gateway';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function updateSeatCount(
subscriptionId: string,
subscriptionItemId: string,
newQuantity: number
) {
const supabase = getSupabaseServerClient();
// Get subscription to find the provider
const { data: subscription } = await supabase
.from('subscriptions')
.select('billing_provider')
.eq('id', subscriptionId)
.single();
if (!subscription) {
throw new Error('Subscription not found');
}
const service = createBillingGatewayService(
subscription.billing_provider
);
return service.updateSubscriptionItem({
subscriptionId,
subscriptionItemId,
quantity: newQuantity,
});
}
```
## Checking Seat Limits
To enforce seat limits in your application:
```tsx
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function canAddMember(accountId: string): Promise<boolean> {
const supabase = getSupabaseServerClient();
const api = createAccountsApi(supabase);
// Get current subscription
const subscription = await api.getSubscription(accountId);
if (!subscription) {
return false; // No subscription
}
// Get per-seat item
const { data: seatItem } = await supabase
.from('subscription_items')
.select('quantity')
.eq('subscription_id', subscription.id)
.eq('type', 'per_seat')
.single();
// Get current member count
const { count: memberCount } = await supabase
.from('accounts_memberships')
.select('*', { count: 'exact', head: true })
.eq('account_id', accountId);
// Check if under limit (if you have a max seats limit)
const maxSeats = 100; // Your limit
return (memberCount ?? 0) < maxSeats;
}
```
## Common Issues
### Seat count not updating
1. Check that the line item has `type: 'per_seat'`
2. Verify the subscription is active
3. Check webhook logs for errors
4. Ensure the subscription item ID is correct in the database
### Proration not working as expected
Configure proration behavior in your billing provider:
- **Stripe:** Customer Portal settings or API parameters
- **Lemon Squeezy:** Product settings
- **Paddle:** Automatic proration
### "Minimum quantity" errors
Some plans require at least 1 seat. Ensure your tiers start at a valid minimum.
## Related Documentation
- [Billing Schema](/docs/next-supabase-turbo/billing/billing-schema) - Define pricing plans
- [Stripe Setup](/docs/next-supabase-turbo/billing/stripe) - Configure Stripe
- [Billing API](/docs/next-supabase-turbo/billing/billing-api) - Manual subscription updates
- [Team Accounts](/docs/next-supabase-turbo/api/team-account-api) - Team management

292
docs/billing/stripe.mdoc Normal file
View File

@@ -0,0 +1,292 @@
---
status: "published"
label: "Stripe"
title: "Configure Stripe Billing for Your Next.js SaaS"
description: "Complete guide to setting up Stripe payments in Makerkit. Configure subscriptions, one-off payments, webhooks, and the Customer Portal for your Next.js Supabase application."
order: 2
---
Stripe is the default billing provider in Makerkit. It offers the most flexibility with support for multiple line items, metered billing, and advanced subscription management.
## Prerequisites
Before you start:
1. Create a [Stripe account](https://dashboard.stripe.com/register)
2. Have your Stripe API keys ready (Dashboard → Developers → API keys)
3. Install the Stripe CLI for local webhook testing
## Step 1: Environment Variables
Add these variables to your `.env.local` file:
```bash
# Stripe API Keys
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
```
| Variable | Description | Where to Find |
|----------|-------------|---------------|
| `STRIPE_SECRET_KEY` | Server-side API key | Dashboard → Developers → API keys |
| `STRIPE_WEBHOOK_SECRET` | Webhook signature verification | Generated by Stripe CLI or Dashboard |
| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Client-side key (safe to expose) | Dashboard → Developers → API keys |
{% alert type="error" title="Never commit secret keys" %}
Add `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` to `.env.local` only. Never add them to `.env` or commit them to your repository.
{% /alert %}
## Step 2: Configure Billing Provider
Ensure Stripe is set as your billing provider:
```bash
NEXT_PUBLIC_BILLING_PROVIDER=stripe
```
And in the database:
```sql
UPDATE public.config SET billing_provider = 'stripe';
```
## Step 3: Create Products in Stripe
1. Go to Stripe Dashboard → Products
2. Click **Add product**
3. Configure your product:
- **Name**: "Pro Plan", "Starter Plan", etc.
- **Pricing**: Add prices for monthly and yearly intervals
- **Price ID**: Copy the `price_xxx` ID for your billing schema
**Important:** The Price ID (e.g., `price_1NNwYHI1i3VnbZTqI2UzaHIe`) must match the `id` field in your billing schema's line items.
## Step 4: Set Up Local Webhooks with Stripe CLI
The Stripe CLI forwards webhook events from Stripe to your local development server.
### Using Docker (Recommended)
First, log in to Stripe:
```bash
docker run --rm -it --name=stripe \
-v ~/.config/stripe:/root/.config/stripe \
stripe/stripe-cli:latest login
```
This opens a browser window to authenticate. Complete the login process.
Then start listening for webhooks:
```bash
pnpm run stripe:listen
```
Or manually:
```bash
docker run --rm -it --name=stripe \
-v ~/.config/stripe:/root/.config/stripe \
stripe/stripe-cli:latest listen \
--forward-to http://host.docker.internal:3000/api/billing/webhook
```
### Using Stripe CLI Directly
If you prefer installing Stripe CLI globally:
```bash
# macOS
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Listen for webhooks
stripe listen --forward-to localhost:3000/api/billing/webhook
```
### Copy the Webhook Secret
When you start listening, the CLI displays a webhook signing secret:
```
> Ready! Your webhook signing secret is whsec_xxxxxxxxxxxxx
```
Copy this value and add it to your `.env.local`:
```bash
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx
```
{% alert type="default" title="Re-run after restart" %}
The webhook secret changes each time you restart the Stripe CLI. Update your `.env.local` accordingly.
{% /alert %}
### Linux Troubleshooting
If webhooks aren't reaching your app on Linux, try adding `--network=host`:
```bash
docker run --rm -it --name=stripe \
-v ~/.config/stripe:/root/.config/stripe \
stripe/stripe-cli:latest listen \
--network=host \
--forward-to http://localhost:3000/api/billing/webhook
```
## Step 5: Configure Customer Portal
The Stripe Customer Portal lets users manage their subscriptions, payment methods, and invoices.
1. Go to Stripe Dashboard → Settings → Billing → Customer portal
2. Configure these settings:
**Payment methods:**
- Allow customers to update payment methods: ✅
**Subscriptions:**
- Allow customers to switch plans: ✅
- Choose products customers can switch between
- Configure proration behavior
**Cancellations:**
- Allow customers to cancel subscriptions: ✅
- Configure cancellation behavior (immediate vs. end of period)
**Invoices:**
- Allow customers to view invoice history: ✅
{% img src="/assets/images/docs/stripe-customer-portal.webp" width="2712" height="1870" /%}
## Step 6: Production Webhooks
When deploying to production, configure webhooks in the Stripe Dashboard:
1. Go to Stripe Dashboard → Developers → Webhooks
2. Click **Add endpoint**
3. Enter your webhook URL: `https://yourdomain.com/api/billing/webhook`
4. Select events to listen for:
**Required events:**
- `checkout.session.completed`
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
**For one-off payments (optional):**
- `checkout.session.async_payment_failed`
- `checkout.session.async_payment_succeeded`
5. Click **Add endpoint**
6. Copy the signing secret and add it to your production environment variables
{% alert type="warning" title="Use a public URL" %}
Webhook URLs must be publicly accessible. Vercel preview deployments with authentication enabled won't work. Test by visiting the URL in an incognito browser window.
{% /alert %}
## Free Trials Without Credit Card
Allow users to start a trial without entering payment information:
```bash
STRIPE_ENABLE_TRIAL_WITHOUT_CC=true
```
When enabled, users can start a subscription with a trial period and won't be charged until the trial ends. They'll need to add a payment method before the trial expires.
You must also set `trialDays` in your billing schema:
```tsx
{
id: 'pro-monthly',
name: 'Pro Monthly',
paymentType: 'recurring',
interval: 'month',
trialDays: 14, // 14-day free trial
lineItems: [/* ... */],
}
```
## Migrating Existing Subscriptions
If you're migrating to Makerkit with existing Stripe subscriptions, you need to add metadata to each subscription.
Makerkit expects this metadata on subscriptions:
```json
{
"accountId": "uuid-of-the-account"
}
```
**Option 1: Add metadata manually**
Use the Stripe Dashboard or a migration script to add the `accountId` metadata to existing subscriptions.
**Option 2: Modify the webhook handler**
If you can't update metadata, modify the webhook handler to look up accounts by customer ID:
```tsx {% title="packages/billing/stripe/src/services/stripe-webhook-handler.service.ts" %}
// Instead of:
const accountId = subscription.metadata.accountId as string;
// Query your database:
const { data: customer } = await supabase
.from('billing_customers')
.select('account_id')
.eq('customer_id', subscription.customer)
.single();
const accountId = customer?.account_id;
```
## Common Issues
### Webhooks not received
1. **Check the CLI is running:** `pnpm run stripe:listen` should show "Ready!"
2. **Verify the secret:** Copy the new webhook secret after each CLI restart
3. **Check the account:** Ensure you're logged into the correct Stripe account
4. **Check the URL:** The webhook endpoint is `/api/billing/webhook`
### "No such price" error
The Price ID in your billing schema doesn't exist in Stripe. Verify:
1. You're using test mode keys with test mode prices (or live with live)
2. The Price ID is copied correctly from Stripe Dashboard
### Subscription not appearing in database
1. Check webhook logs in Stripe Dashboard → Developers → Webhooks
2. Look for errors in your application logs
3. Verify the `accountId` is correctly passed in checkout metadata
### Customer Portal not loading
1. Ensure the Customer Portal is configured in Stripe Dashboard
2. Check that the customer has a valid subscription
3. Verify the `customerId` is correct
## Testing Checklist
Before going live:
- [ ] Test subscription checkout with test card `4242 4242 4242 4242`
- [ ] Verify subscription appears in user's billing section
- [ ] Test subscription upgrade/downgrade via Customer Portal
- [ ] Test subscription cancellation
- [ ] Verify webhook events are processed correctly
- [ ] Test with failing card `4000 0000 0000 0002` to verify error handling
- [ ] For trials: test trial expiration and conversion to paid
## Related Documentation
- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Architecture and concepts
- [Billing Schema](/docs/next-supabase-turbo/billing/billing-schema) - Configure your pricing
- [Webhooks](/docs/next-supabase-turbo/billing/billing-webhooks) - Custom webhook handling
- [Metered Usage](/docs/next-supabase-turbo/billing/metered-usage) - Report usage to Stripe
- [Per-Seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing