Implement Metered Usage Billing for APIs and SaaS
Charge customers based on actual usage with metered billing. Learn how to configure usage-based pricing and report usage to Stripe in your Tanstack Start 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
- Customer subscribes to a metered plan
- Your application tracks usage and reports it to the billing provider
- At the end of each billing period, the provider invoices based on total usage
- Makerkit stores usage data in
subscription_itemsfor reference
Schema Configuration
Define a metered line item in your billing schema:
apps/web/src/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'.
Stripe Implementation
Stripe uses Billing Meters for metered billing.
1. Create a Meter in Stripe
- Go to Stripe Dashboard → Billing → Meters
- Click Create meter
- Configure:
- Event name:
api_requests(you'll use this in your code) - Aggregation: Sum (most common)
- Value key:
value(default)
- Event name:
2. Create a Metered Price
- Go to Products → Your Product
- Add a price with Usage-based pricing
- Select your meter
- Configure tier pricing
3. Report Usage
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
apps/web/src/routes/api/data.ts
import { createFileRoute } from '@tanstack/react-router';import { reportApiUsage } from '#/lib/billing';export const Route = createFileRoute('/api/data')({ server: { handlers: { GET: async ({ 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 Response.json(data); }, }, },});For high-volume APIs, batch usage reports:
// lib/usage-buffer.tsconst 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 minutesetInterval(async () => { for (const [accountId, quantity] of usageBuffer.entries()) { if (quantity > 0) { await reportApiUsage(accountId, quantity); usageBuffer.set(accountId, 0); } }}, 60000);Querying Usage
Stripe requires a time filter (startTime and endTime):
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}`);Combining Metered + Flat Pricing
Charge a base fee plus usage:
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 }, ], },]Displaying Usage to Users
Show customers their current usage:
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
- Create a metered subscription
- Report some usage:# Stripe CLIstripe billing_meters create_event \--event-name api_requests \--payload customer=cus_xxx,value=100
- Check usage in dashboard
- Create an invoice to see charges:stripe invoices create --customer cus_xxxstripe invoices finalize inv_xxx
Common Issues
Usage not appearing
- Verify the meter event name matches
- Check that customer ID is correct
- Look for errors in your application logs
- Check Stripe Dashboard → Billing → Meters → Events
Incorrect charges
- Verify your tier configuration in Stripe matches your schema
- Check if using graduated vs. volume pricing
- Review the invoice line items in Stripe Dashboard
Related Documentation
- Billing Schema - Configure pricing
- Billing API - Full API reference
- Credit-Based Billing - Alternative usage model
- Stripe Setup - Provider configuration