The Problem (The “Why”)
Usage-based billing is notoriously complex to implement correctly. Unlike simple subscription models, you need to track user consumption (API calls, storage, seats), aggregate that data accurately, and report it to Stripe at the right intervals—all while handling edge cases like billing period boundaries, proration, and failed charges. Many developers struggle with the synchronization between their application’s metering system and Stripe’s billing engine. Get it wrong, and you’ll either undercharge customers (losing revenue) or overcharge them (losing trust). This tutorial solves the specific problem of creating a reliable metering and reporting pipeline that keeps your usage data in sync with Stripe’s invoicing system, ensuring accurate billing every time.
Tech Stack & Prerequisites
- Node.js v20+ and npm/pnpm
- Next.js 14+ (App Router)
- Stripe Account (Test mode keys)
- Database (PostgreSQL, MySQL, or MongoDB for usage tracking)
- Prisma or another ORM (optional but recommended)
- stripe npm package v14+
- Basic understanding of Next.js API routes and server actions
Step-by-Step Implementation
Step 1: Setup
Initialize your Next.js project and install dependencies:
npx create-next-app@latest stripe-usage-billing
cd stripe-usage-billing
npm install stripe
npm install @prisma/client
npm install -D prisma
Initialize Prisma for usage tracking:
npx prisma init
Create a schema in prisma/schema.prisma to track usage events:
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(cuid())
email String @unique
stripeCustomerId String? @unique
stripeSubscriptionId String?
usageRecords UsageRecord[]
createdAt DateTime @default(now())
}
model UsageRecord {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
quantity Int // Number of units consumed (e.g., API calls)
timestamp DateTime @default(now())
reportedToStripe Boolean @default(false)
stripeUsageRecordId String?
@@index([userId, timestamp])
@@index([reportedToStripe])
}
Run migrations:
npx prisma migrate dev --name init
npx prisma generate
Step 2: Configuration
Create a .env.local file for secure environment variables:
# .env.local
STRIPE_SECRET_KEY=sk_test_your_secret_key_here
STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
Create a Stripe client utility in lib/stripe.ts:
// lib/stripe.ts
import Stripe from 'stripe';
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error('STRIPE_SECRET_KEY is not defined');
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2024-11-20.acacia',
typescript: true,
});
Create a Prisma client utility in lib/prisma.ts:
// lib/prisma.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
Step 3: Core Logic
3.1: Create a Stripe Product with Metered Pricing
First, set up your product in Stripe Dashboard or via code:
// scripts/setup-stripe-product.ts
import { stripe } from '../lib/stripe';
async function setupProduct() {
// Create a product
const product = await stripe.products.create({
name: 'API Usage',
description: 'Pay-as-you-go API access',
});
// Create a metered price (usage-based)
const price = await stripe.prices.create({
product: product.id,
currency: 'usd',
recurring: {
interval: 'month',
usage_type: 'metered', // This is key for usage-based billing
aggregate_usage: 'sum', // How to aggregate usage (sum, last_during_period, max, last_ever)
},
billing_scheme: 'per_unit',
unit_amount: 10, // $0.10 per API call (in cents)
});
console.log('Product ID:', product.id);
console.log('Price ID:', price.id);
// Save these IDs - you'll need them!
}
setupProduct();
Run this once: npx tsx scripts/setup-stripe-product.ts
3.2: Create a Subscription for a User
Create an API route to handle subscription creation in app/api/subscriptions/create/route.ts:
// app/api/subscriptions/create/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';
export async function POST(request: NextRequest) {
try {
const { userId, email, priceId } = await request.json();
// Get or create Stripe customer
let user = await prisma.user.findUnique({ where: { id: userId } });
let customerId = user?.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email,
metadata: { userId },
});
customerId = customer.id;
user = await prisma.user.update({
where: { id: userId },
data: { stripeCustomerId: customerId },
});
}
// Create subscription with metered pricing
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [
{
price: priceId, // Your metered price ID from setup
},
],
payment_behavior: 'default_incomplete',
payment_settings: { save_default_payment_method: 'on_subscription' },
expand: ['latest_invoice.payment_intent'],
});
// Save subscription ID to database
await prisma.user.update({
where: { id: userId },
data: { stripeSubscriptionId: subscription.id },
});
const invoice = subscription.latest_invoice as Stripe.Invoice;
const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent;
return NextResponse.json({
subscriptionId: subscription.id,
clientSecret: paymentIntent?.client_secret,
});
} catch (error) {
console.error('Subscription creation error:', error);
return NextResponse.json(
{ error: 'Failed to create subscription' },
{ status: 500 }
);
}
}
3.3: Track Usage Events
Create a utility function to record usage in lib/usage-tracking.ts:
// lib/usage-tracking.ts
import { prisma } from './prisma';
export async function trackUsage(userId: string, quantity: number = 1) {
try {
const usageRecord = await prisma.usageRecord.create({
data: {
userId,
quantity,
timestamp: new Date(),
},
});
return usageRecord;
} catch (error) {
console.error('Failed to track usage:', error);
throw error;
}
}
Use this in your API routes whenever a user consumes resources:
// app/api/your-feature/route.ts
import { trackUsage } from '@/lib/usage-tracking';
export async function POST(request: NextRequest) {
const userId = 'user_123'; // Get from auth session
// Your API logic here...
// Track this API call
await trackUsage(userId, 1);
return NextResponse.json({ success: true });
}
3.4: Report Usage to Stripe
Create a cron job or scheduled task to report usage in app/api/cron/report-usage/route.ts:
// app/api/cron/report-usage/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';
export async function POST(request: NextRequest) {
// Verify this is called by your cron service (e.g., Vercel Cron)
const authHeader = request.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
// Get all unreported usage records
const unreportedUsage = await prisma.usageRecord.findMany({
where: { reportedToStripe: false },
include: { user: true },
});
// Group by user and sum quantities
const usageByUser = unreportedUsage.reduce((acc, record) => {
if (!acc[record.userId]) {
acc[record.userId] = {
user: record.user,
totalQuantity: 0,
recordIds: [],
};
}
acc[record.userId].totalQuantity += record.quantity;
acc[record.userId].recordIds.push(record.id);
return acc;
}, {} as Record<string, any>);
const results = [];
// Report to Stripe for each user
for (const userId in usageByUser) {
const { user, totalQuantity, recordIds } = usageByUser[userId];
if (!user.stripeSubscriptionId) {
console.log(`User ${userId} has no subscription, skipping`);
continue;
}
// Get the subscription item ID (metered price)
const subscription = await stripe.subscriptions.retrieve(
user.stripeSubscriptionId
);
const subscriptionItem = subscription.items.data[0]; // First item is our metered price
// Create usage record in Stripe
const usageRecord = await stripe.subscriptionItems.createUsageRecord(
subscriptionItem.id,
{
quantity: totalQuantity,
timestamp: Math.floor(Date.now() / 1000), // Unix timestamp
action: 'increment', // or 'set' to replace the current usage
}
);
// Mark records as reported
await prisma.usageRecord.updateMany({
where: { id: { in: recordIds } },
data: {
reportedToStripe: true,
stripeUsageRecordId: usageRecord.id,
},
});
results.push({
userId,
quantity: totalQuantity,
usageRecordId: usageRecord.id,
});
}
return NextResponse.json({
success: true,
reported: results.length,
results,
});
} catch (error) {
console.error('Usage reporting error:', error);
return NextResponse.json(
{ error: 'Failed to report usage' },
{ status: 500 }
);
}
}
Set up a cron job in vercel.json (for Vercel deployments):
{
"crons": [
{
"path": "/api/cron/report-usage",
"schedule": "0 * * * *"
}
]
}
Add to .env.local:
CRON_SECRET=your_random_secret_string_here
3.5: Handle Webhooks for Invoice Events
Create a webhook handler in app/api/webhooks/stripe/route.ts:
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';
import Stripe from 'stripe';
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (!signature || !process.env.STRIPE_WEBHOOK_SECRET) {
return NextResponse.json({ error: 'No signature' }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
// Handle relevant events
switch (event.type) {
case 'invoice.payment_succeeded':
const invoice = event.data.object as Stripe.Invoice;
console.log(`Payment succeeded for invoice ${invoice.id}`);
// Update your database, send confirmation email, etc.
break;
case 'invoice.payment_failed':
const failedInvoice = event.data.object as Stripe.Invoice;
console.log(`Payment failed for invoice ${failedInvoice.id}`);
// Handle failed payment - notify user, suspend service, etc.
break;
case 'customer.subscription.deleted':
const subscription = event.data.object as Stripe.Subscription;
await prisma.user.updateMany({
where: { stripeSubscriptionId: subscription.id },
data: { stripeSubscriptionId: null },
});
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
}
Step 4: Testing
4.1: Test Usage Tracking Locally
Create a test script in scripts/test-usage.ts:
// scripts/test-usage.ts
import { trackUsage } from '../lib/usage-tracking';
import { prisma } from '../lib/prisma';
async function test() {
// Create a test user
const user = await prisma.user.create({
data: {
email: 'test@example.com',
stripeCustomerId: 'cus_test123',
},
});
console.log('Created user:', user.id);
// Track some usage
for (let i = 0; i < 5; i++) {
await trackUsage(user.id, 1);
console.log(`Tracked usage event ${i + 1}`);
}
// Check the records
const records = await prisma.usageRecord.findMany({
where: { userId: user.id },
});
console.log(`Total usage records: ${records.length}`);
console.log('Total quantity:', records.reduce((sum, r) => sum + r.quantity, 0));
}
test();
Run: npx tsx scripts/test-usage.ts
4.2: Test Stripe Integration with Test Clocks
Use Stripe’s Test Clocks to simulate billing periods:
// scripts/test-with-clock.ts
import { stripe } from '../lib/stripe';
async function testWithClock() {
// Create a test clock
const testClock = await stripe.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000),
});
// Create a customer on this test clock
const customer = await stripe.customers.create({
email: 'clock-test@example.com',
test_clock: testClock.id,
});
console.log('Test clock ID:', testClock.id);
console.log('Customer ID:', customer.id);
// Advance the clock to test billing
// await stripe.testHelpers.testClocks.advance(testClock.id, {
// frozen_time: Math.floor(Date.now() / 1000) + 86400 * 30, // 30 days
// });
}
testWithClock();
4.3: Test Webhook Locally
Use Stripe CLI to forward webhooks:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Copy the webhook signing secret to your .env.local as STRIPE_WEBHOOK_SECRET.
Trigger test events:
stripe trigger invoice.payment_succeeded
stripe trigger invoice.payment_failed
Common Errors & Troubleshooting
1. Error: “No subscription items found”
Cause: Trying to report usage to a subscription that doesn’t have any items, or the subscription was deleted.
Fix: Always verify the subscription exists and has items before reporting usage:
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
if (!subscription || subscription.items.data.length === 0) {
throw new Error('Subscription has no items');
}
2. Error: “Usage record timestamp is too far in the past”
Cause: Stripe only accepts usage records with timestamps within the current billing period. If you try to report old usage after the billing period ended, it fails.
Fix: Report usage frequently (hourly or daily). Implement a buffer check:
const oneDayAgo = Math.floor(Date.now() / 1000) - 86400;
if (timestamp < oneDayAgo) {
console.warn('Usage record too old, skipping');
return;
}
3. Error: “Duplicate idempotency key”
Cause: Stripe uses idempotency keys to prevent duplicate requests. If you retry the same request with the same idempotency key but different parameters, it fails.
Fix: Generate unique idempotency keys for usage records:
const usageRecord = await stripe.subscriptionItems.createUsageRecord(
subscriptionItem.id,
{
quantity: totalQuantity,
timestamp: Math.floor(Date.now() / 1000),
},
{
idempotencyKey: `usage_${userId}_${Date.now()}`, // Unique key
}
);
Security Checklist
- Never expose Stripe Secret Keys in client-side code or public repositories
- Validate webhook signatures using
stripe.webhooks.constructEvent()to prevent spoofed events - Use environment variables for all sensitive keys (STRIPE_SECRET_KEY, DATABASE_URL, CRON_SECRET)
- Authenticate cron endpoints with a secret token to prevent unauthorized usage reporting
- Validate user ownership before tracking usage – ensure the authenticated user owns the resource they’re consuming
- Rate limit usage tracking endpoints to prevent abuse and inflated billing
- Use HTTPS only in production for all Stripe API calls
- Implement proper error logging but never log full Stripe objects (they contain sensitive data)
- Set up Stripe’s fraud prevention tools like Radar for payment protection
- Regularly audit usage records for anomalies that might indicate metering bugs or fraud

Maha is a detail-oriented software developer with strong expertise in frontend technologies and user-focused design. She specializes in building responsive, high-performance web applications that deliver seamless user experiences.



