Usage-Based Billing

Metered billing based on API usage with Stripe.

Dependencies#

npm install stripe

Environment Variables#

# .env.local STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_... STRIPE_METERED_PRICE_ID=price_metered_...

Database Schema#

1// prisma/schema.prisma 2model User { 3 id String @id @default(cuid()) 4 clerkId String @unique 5 email String @unique 6 7 // Stripe 8 stripeCustomerId String? @unique 9 subscriptionId String? 10 subscriptionItemId String? // For metered billing 11 12 // Usage tracking 13 usageRecords UsageRecord[] 14 15 createdAt DateTime @default(now()) 16 updatedAt DateTime @updatedAt 17} 18 19model UsageRecord { 20 id String @id @default(cuid()) 21 userId String 22 user User @relation(fields: [userId], references: [id]) 23 24 type String // api_call, storage, compute, etc. 25 quantity Int 26 metadata Json? 27 28 timestamp DateTime @default(now()) 29 30 // Stripe sync 31 reportedToStripe Boolean @default(false) 32 stripeRecordId String? 33 34 @@index([userId, type, timestamp]) 35 @@index([reportedToStripe]) 36}

Create Metered Subscription#

1// app/api/subscriptions/metered/route.ts 2import { auth } from '@clerk/nextjs/server'; 3import { NextRequest, NextResponse } from 'next/server'; 4import { stripe } from '@/lib/stripe'; 5import { prisma } from '@/lib/prisma'; 6 7export async function POST(request: NextRequest) { 8 const { userId } = await auth(); 9 if (!userId) { 10 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 11 } 12 13 const user = await prisma.user.findUnique({ 14 where: { clerkId: userId }, 15 }); 16 17 if (!user) { 18 return NextResponse.json({ error: 'User not found' }, { status: 404 }); 19 } 20 21 // Get or create customer 22 let customerId = user.stripeCustomerId; 23 24 if (!customerId) { 25 const customer = await stripe.customers.create({ 26 email: user.email, 27 metadata: { userId: user.id }, 28 }); 29 customerId = customer.id; 30 } 31 32 // Create subscription with metered price 33 const subscription = await stripe.subscriptions.create({ 34 customer: customerId, 35 items: [ 36 { 37 price: process.env.STRIPE_METERED_PRICE_ID, 38 }, 39 ], 40 payment_behavior: 'default_incomplete', 41 expand: ['latest_invoice.payment_intent'], 42 }); 43 44 // Save subscription item ID for reporting usage 45 const subscriptionItemId = subscription.items.data[0].id; 46 47 await prisma.user.update({ 48 where: { id: user.id }, 49 data: { 50 stripeCustomerId: customerId, 51 subscriptionId: subscription.id, 52 subscriptionItemId, 53 }, 54 }); 55 56 return NextResponse.json({ 57 subscriptionId: subscription.id, 58 clientSecret: (subscription.latest_invoice as any)?.payment_intent?.client_secret, 59 }); 60}

Usage Tracking Service#

1// lib/services/usage-service.ts 2import { prisma } from '@/lib/prisma'; 3import { stripe } from '@/lib/stripe'; 4 5export type UsageType = 'api_call' | 'storage_gb' | 'compute_minutes'; 6 7export async function recordUsage( 8 userId: string, 9 type: UsageType, 10 quantity: number, 11 metadata?: Record<string, any> 12) { 13 // Record locally first 14 const record = await prisma.usageRecord.create({ 15 data: { 16 userId, 17 type, 18 quantity, 19 metadata, 20 }, 21 }); 22 23 return record; 24} 25 26export async function getUsageSummary(userId: string, period: 'day' | 'month') { 27 const startDate = new Date(); 28 29 if (period === 'day') { 30 startDate.setHours(0, 0, 0, 0); 31 } else { 32 startDate.setDate(1); 33 startDate.setHours(0, 0, 0, 0); 34 } 35 36 const records = await prisma.usageRecord.groupBy({ 37 by: ['type'], 38 where: { 39 userId, 40 timestamp: { gte: startDate }, 41 }, 42 _sum: { 43 quantity: true, 44 }, 45 }); 46 47 return records.reduce( 48 (acc, record) => ({ 49 ...acc, 50 [record.type]: record._sum.quantity || 0, 51 }), 52 {} as Record<UsageType, number> 53 ); 54} 55 56export async function reportUsageToStripe() { 57 // Get unreported usage records 58 const records = await prisma.usageRecord.findMany({ 59 where: { reportedToStripe: false }, 60 include: { user: true }, 61 orderBy: { timestamp: 'asc' }, 62 take: 1000, 63 }); 64 65 // Group by user 66 const byUser = records.reduce( 67 (acc, record) => { 68 const key = record.userId; 69 if (!acc[key]) { 70 acc[key] = { 71 user: record.user, 72 records: [], 73 total: 0, 74 }; 75 } 76 acc[key].records.push(record); 77 acc[key].total += record.quantity; 78 return acc; 79 }, 80 {} as Record<string, { user: any; records: any[]; total: number }> 81 ); 82 83 // Report to Stripe 84 for (const [userId, data] of Object.entries(byUser)) { 85 if (!data.user.subscriptionItemId) { 86 continue; 87 } 88 89 try { 90 // Create usage record in Stripe 91 const stripeRecord = await stripe.subscriptionItems.createUsageRecord( 92 data.user.subscriptionItemId, 93 { 94 quantity: data.total, 95 timestamp: 'now', 96 action: 'increment', 97 } 98 ); 99 100 // Mark as reported 101 await prisma.usageRecord.updateMany({ 102 where: { 103 id: { in: data.records.map((r) => r.id) }, 104 }, 105 data: { 106 reportedToStripe: true, 107 stripeRecordId: stripeRecord.id, 108 }, 109 }); 110 } catch (error) { 111 console.error(`Failed to report usage for user ${userId}:`, error); 112 } 113 } 114}

API Usage Middleware#

1// lib/middleware/track-usage.ts 2import { NextRequest, NextResponse } from 'next/server'; 3import { recordUsage } from '@/lib/services/usage-service'; 4 5export function withUsageTracking( 6 handler: (req: NextRequest, ctx: any) => Promise<Response> 7) { 8 return async (req: NextRequest, ctx: any) => { 9 const startTime = Date.now(); 10 11 // Execute the handler 12 const response = await handler(req, ctx); 13 14 // Track usage 15 const userId = ctx.user?.id; 16 if (userId) { 17 const duration = Date.now() - startTime; 18 19 await recordUsage(userId, 'api_call', 1, { 20 endpoint: req.nextUrl.pathname, 21 method: req.method, 22 status: response.status, 23 duration, 24 }); 25 } 26 27 return response; 28 }; 29}

Cron Job for Stripe Reporting#

1// app/api/cron/report-usage/route.ts 2import { NextRequest, NextResponse } from 'next/server'; 3import { reportUsageToStripe } from '@/lib/services/usage-service'; 4 5export async function POST(request: NextRequest) { 6 // Verify cron secret 7 const authHeader = request.headers.get('authorization'); 8 if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { 9 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 10 } 11 12 try { 13 await reportUsageToStripe(); 14 return NextResponse.json({ success: true }); 15 } catch (error) { 16 console.error('Usage reporting failed:', error); 17 return NextResponse.json({ error: 'Failed' }, { status: 500 }); 18 } 19}
1// vercel.json 2{ 3 "crons": [ 4 { 5 "path": "/api/cron/report-usage", 6 "schedule": "0 * * * *" 7 } 8 ] 9}

Usage Dashboard Component#

1// components/UsageDashboard.tsx 2'use client'; 3 4import { useState, useEffect } from 'react'; 5import { BarChart, Activity, Database, Cpu } from 'lucide-react'; 6 7interface UsageData { 8 api_call: number; 9 storage_gb: number; 10 compute_minutes: number; 11} 12 13interface UsageLimit { 14 api_call: number; 15 storage_gb: number; 16 compute_minutes: number; 17} 18 19export function UsageDashboard() { 20 const [usage, setUsage] = useState<UsageData | null>(null); 21 const [limits, setLimits] = useState<UsageLimit | null>(null); 22 23 useEffect(() => { 24 fetch('/api/usage') 25 .then((res) => res.json()) 26 .then((data) => { 27 setUsage(data.usage); 28 setLimits(data.limits); 29 }); 30 }, []); 31 32 if (!usage || !limits) { 33 return <div>Loading...</div>; 34 } 35 36 const metrics = [ 37 { 38 name: 'API Calls', 39 icon: Activity, 40 current: usage.api_call, 41 limit: limits.api_call, 42 unit: 'calls', 43 }, 44 { 45 name: 'Storage', 46 icon: Database, 47 current: usage.storage_gb, 48 limit: limits.storage_gb, 49 unit: 'GB', 50 }, 51 { 52 name: 'Compute', 53 icon: Cpu, 54 current: usage.compute_minutes, 55 limit: limits.compute_minutes, 56 unit: 'minutes', 57 }, 58 ]; 59 60 return ( 61 <div className="space-y-6"> 62 <h2 className="text-xl font-semibold">Usage This Month</h2> 63 64 <div className="grid md:grid-cols-3 gap-6"> 65 {metrics.map((metric) => { 66 const percentage = (metric.current / metric.limit) * 100; 67 const isWarning = percentage > 80; 68 const isDanger = percentage > 95; 69 70 return ( 71 <div 72 key={metric.name} 73 className="p-6 bg-white dark:bg-gray-900 rounded-xl border" 74 > 75 <div className="flex items-center gap-3 mb-4"> 76 <metric.icon className="w-5 h-5 text-gray-500" /> 77 <span className="font-medium">{metric.name}</span> 78 </div> 79 80 <div className="text-3xl font-bold mb-2"> 81 {metric.current.toLocaleString()} 82 <span className="text-sm font-normal text-gray-500 ml-1"> 83 / {metric.limit.toLocaleString()} {metric.unit} 84 </span> 85 </div> 86 87 <div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden"> 88 <div 89 className={`h-full rounded-full transition-all ${ 90 isDanger 91 ? 'bg-red-500' 92 : isWarning 93 ? 'bg-yellow-500' 94 : 'bg-green-500' 95 }`} 96 style={{ width: `${Math.min(percentage, 100)}%` }} 97 /> 98 </div> 99 100 <p className="text-sm text-gray-500 mt-2"> 101 {percentage.toFixed(1)}% used 102 </p> 103 </div> 104 ); 105 })} 106 </div> 107 </div> 108 ); 109}

Usage API Route#

1// app/api/usage/route.ts 2import { auth } from '@clerk/nextjs/server'; 3import { NextResponse } from 'next/server'; 4import { getUsageSummary } from '@/lib/services/usage-service'; 5import { getUserSubscription } from '@/lib/subscription'; 6 7export async function GET() { 8 const { userId } = await auth(); 9 if (!userId) { 10 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 11 } 12 13 const [usage, subscription] = await Promise.all([ 14 getUsageSummary(userId, 'month'), 15 getUserSubscription(userId), 16 ]); 17 18 return NextResponse.json({ 19 usage, 20 limits: subscription?.limits || { 21 api_call: 1000, 22 storage_gb: 5, 23 compute_minutes: 60, 24 }, 25 }); 26}

Pricing Configuration#

1// lib/pricing.ts 2export const USAGE_PRICING = { 3 api_call: { 4 name: 'API Calls', 5 tiers: [ 6 { upTo: 10000, pricePerUnit: 0 }, // Free tier 7 { upTo: 100000, pricePerUnit: 0.0001 }, // $0.10 per 1000 8 { upTo: 1000000, pricePerUnit: 0.00005 }, // $0.05 per 1000 9 { upTo: null, pricePerUnit: 0.00002 }, // $0.02 per 1000 10 ], 11 }, 12 storage_gb: { 13 name: 'Storage', 14 tiers: [ 15 { upTo: 5, pricePerUnit: 0 }, // 5GB free 16 { upTo: null, pricePerUnit: 0.10 }, // $0.10 per GB 17 ], 18 }, 19 compute_minutes: { 20 name: 'Compute', 21 tiers: [ 22 { upTo: 60, pricePerUnit: 0 }, // 60 min free 23 { upTo: null, pricePerUnit: 0.01 }, // $0.01 per minute 24 ], 25 }, 26}; 27 28export function calculateCost( 29 type: keyof typeof USAGE_PRICING, 30 quantity: number 31): number { 32 const pricing = USAGE_PRICING[type]; 33 let remaining = quantity; 34 let cost = 0; 35 let previousLimit = 0; 36 37 for (const tier of pricing.tiers) { 38 const tierLimit = tier.upTo ?? Infinity; 39 const tierQuantity = Math.min(remaining, tierLimit - previousLimit); 40 41 if (tierQuantity > 0) { 42 cost += tierQuantity * tier.pricePerUnit; 43 remaining -= tierQuantity; 44 } 45 46 previousLimit = tierLimit; 47 if (remaining <= 0) break; 48 } 49 50 return cost; 51}