Files
myeasycms-v2/docs/billing/metered-usage.mdoc
Giancarlo Buomprisco 7ebff31475 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
2026-03-24 13:40:38 +08:00

400 lines
9.8 KiB
Plaintext

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