Usage-Based Billing
Metered billing based on API usage with Stripe.
Dependencies#
npm install stripeEnvironment 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}