How to Integrate Stripe Usage-Based Billing with Next.js for SaaS Applications

How to Integrate Stripe Usage-Based Billing with Next.js for SaaS Applications

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:

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

bash
npx prisma init

Create a schema in prisma/schema.prisma to track usage events:

prisma
// 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:

bash
npx prisma migrate dev --name init
npx prisma generate

Step 2: Configuration

Create a .env.local file for secure environment variables:

bash
# .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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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):

json
{
  "crons": [
    {
      "path": "/api/cron/report-usage",
      "schedule": "0 * * * *"
    }
  ]
}

Add to .env.local:

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

typescript
// 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:

typescript
// 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:

typescript
// 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:

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

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

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

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

typescript
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

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top