50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
paymentsMay 26, 2026·9 min read

Metered Billing with Stripe: Usage-Based Pricing That Actually Works

Usage-based pricing sounds simple until you're debugging why a customer got charged for 10,000 API calls they swear they didn't make.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Metered Billing with Stripe: Usage-Based Pricing That Actually Works

Flat-rate SaaS pricing is comfortable. $29/month, $99/month, done. Everyone knows where they stand. But at some point you build something where charging a flat rate feels wrong — either you're leaving money on the table with heavy users, or you're scaring off light users who don't know if they'll get value. That's when usage-based pricing starts looking attractive.

We've implemented metered billing a few times now, and the first time we did it, we got several things subtly wrong. Not wrong enough to blow up immediately, but wrong enough to cause weird edge cases at 2am when a customer emails you about their invoice. This post is the thing we wish existed before we started.

How Stripe Metered Billing Actually Works

Stripe's metered billing model has a few moving parts you need to understand before writing any code. At the core, you have a Price with `billing_scheme: 'per_unit'` and `usage_type: 'metered'`. Your customer subscribes to that price, and throughout the billing period you report usage events. At the end of the period, Stripe tallies the usage and charges accordingly.

The key concept here is the subscription item. When a customer subscribes, each price in their subscription has a corresponding subscription item. That subscription item ID is what you'll use to report usage — not the customer ID, not the subscription ID. The subscription item ID. Get this wrong and your usage reports go nowhere.

  • Subscription — the customer's overall billing relationship
  • Subscription Item — one per price in the subscription (this is what you report usage against)
  • Usage Record — an event you report, tied to a subscription item
  • Billing Period — Stripe tallies usage and charges at the end of each period

Setting Up the Metered Price

First, create your metered price. You can do this in the Stripe dashboard or via API. Here's how to do it in code, which is better because you can version-control it and reproduce it across environments:

import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// Create a product first (or use an existing one)
const product = await stripe.products.create({
  name: 'API Calls',
  description: 'Pay per API call',
});

// Create the metered price
const price = await stripe.prices.create({
  product: product.id,
  currency: 'usd',
  billing_scheme: 'per_unit',
  unit_amount: 1, // $0.01 per unit (in cents)
  recurring: {
    interval: 'month',
    usage_type: 'metered',
    aggregate_usage: 'sum', // 'sum' | 'max' | 'last_during_period' | 'last_ever'
  },
});

console.log('Price ID:', price.id); // Save this — you'll need it

The `aggregate_usage` field is worth thinking about carefully. `sum` adds up all usage records — good for API calls or storage consumption. `max` takes the highest value reported — good for seat counts. `last_during_period` takes the most recent value — also useful for seats or active users. `last_ever` is rarely what you want. For most API-style billing, `sum` is correct.

Creating Subscriptions and Storing the Subscription Item ID

When a customer subscribes, you'll get back a subscription object with an `items` array. You need to store the subscription item ID alongside the customer in your database. This is step one of where people go wrong — they store the subscription ID but not the item ID, then have to look it up every time they want to report usage.

// After customer completes checkout, handle the subscription
async function handleSubscriptionCreated(subscriptionId: string, userId: string) {
  const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
    expand: ['items.data.price'],
  });

  // Find the metered price item
  const meteredItem = subscription.items.data.find(
    (item) => item.price.recurring?.usage_type === 'metered'
  );

  if (!meteredItem) {
    throw new Error('No metered item found in subscription');
  }

  // Store both IDs — you'll need the item ID for usage reporting
  await db.update(users)
    .set({
      stripeSubscriptionId: subscriptionId,
      stripeSubscriptionItemId: meteredItem.id, // <-- don't skip this
      stripePriceId: meteredItem.price.id,
      subscriptionStatus: subscription.status,
    })
    .where(eq(users.id, userId));
}

Reporting Usage: The Part That Matters Most

Now for the actual usage reporting. Every time your customer does something billable, you call `stripe.subscriptionItems.createUsageRecord()`. The question is when and how often to call it.

There are two approaches: real-time reporting (call Stripe on every billable event) and batched reporting (accumulate locally, flush to Stripe periodically). Real-time is simpler to reason about but adds latency to every request and will get you rate-limited if you have any kind of scale. Batching is better for production, but you need to handle failures.

// Real-time reporting — simple but not always practical
async function reportUsage(userId: string, quantity: number = 1) {
  const user = await db.query.users.findFirst({
    where: eq(users.id, userId),
    columns: { stripeSubscriptionItemId: true },
  });

  if (!user?.stripeSubscriptionItemId) {
    // User might be on free tier — handle gracefully
    return;
  }

  await stripe.subscriptionItems.createUsageRecord(
    user.stripeSubscriptionItemId,
    {
      quantity,
      timestamp: Math.floor(Date.now() / 1000), // Unix timestamp
      action: 'increment', // 'increment' | 'set'
    }
  );
}

// Usage in your API route or server action
export async function POST(request: Request) {
  const user = await getAuthenticatedUser(request);
  
  // Do the actual work
  const result = await processApiCall(request);
  
  // Report usage asynchronously — don't await this in the critical path
  reportUsage(user.id, 1).catch((err) => {
    // Log but don't fail the request
    console.error('Usage reporting failed:', err);
    // Consider storing failed reports for retry
  });
  
  return Response.json(result);
}
Don't let usage reporting failures fail your users' requests. Log them, retry them, but never let a Stripe API hiccup mean your customer can't use your product.

Batching Usage for Scale

If you're doing any real volume, you want to batch usage reports. The pattern: track usage in your database or Redis as it happens, then flush to Stripe every few minutes with a cron job.

// Step 1: Track usage locally (fast, reliable)
async function trackUsage(userId: string, quantity: number = 1) {
  // Increment in your DB atomically
  await db
    .insert(usageBuffer)
    .values({
      userId,
      quantity,
      recordedAt: new Date(),
      flushedToStripe: false,
    });
}

// Step 2: Flush to Stripe periodically (cron job, every 5 minutes)
export async function flushUsageToStripe() {
  // Get all unflushed records, grouped by user
  const pendingUsage = await db
    .select({
      userId: usageBuffer.userId,
      totalQuantity: sql<number>`sum(${usageBuffer.quantity})`,
    })
    .from(usageBuffer)
    .where(eq(usageBuffer.flushedToStripe, false))
    .groupBy(usageBuffer.userId);

  for (const { userId, totalQuantity } of pendingUsage) {
    const user = await db.query.users.findFirst({
      where: eq(users.id, userId),
      columns: { stripeSubscriptionItemId: true },
    });

    if (!user?.stripeSubscriptionItemId) continue;

    try {
      await stripe.subscriptionItems.createUsageRecord(
        user.stripeSubscriptionItemId,
        {
          quantity: totalQuantity,
          timestamp: Math.floor(Date.now() / 1000),
          action: 'increment',
        }
      );

      // Mark as flushed
      await db
        .update(usageBuffer)
        .set({ flushedToStripe: true })
        .where(
          and(
            eq(usageBuffer.userId, userId),
            eq(usageBuffer.flushedToStripe, false)
          )
        );
    } catch (err) {
      console.error(`Failed to flush usage for user ${userId}:`, err);
      // Leave unflushed — will retry next cycle
    }
  }
}

Giving Customers Visibility Into Their Usage

Usage-based billing creates anxiety. If customers can't see what they're consuming, they'll either under-use your product (scared of surprise invoices) or blow up at you when the bill arrives. You need to show them their current usage.

Stripe has an endpoint for this: `stripe.subscriptionItems.listUsageRecordSummaries()`. This returns the current period's usage. Combine this with the price data and you can show customers a real-time cost estimate.

async function getCurrentUsage(userId: string) {
  const user = await db.query.users.findFirst({
    where: eq(users.id, userId),
    columns: {
      stripeSubscriptionItemId: true,
      stripePriceId: true,
    },
  });

  if (!user?.stripeSubscriptionItemId) {
    return { usage: 0, estimatedCost: 0 };
  }

  const summaries = await stripe.subscriptionItems.listUsageRecordSummaries(
    user.stripeSubscriptionItemId,
    { limit: 1 } // Get current period only
  );

  const currentPeriod = summaries.data[0];
  if (!currentPeriod) {
    return { usage: 0, estimatedCost: 0 };
  }

  const price = await stripe.prices.retrieve(user.stripePriceId);
  const unitAmount = price.unit_amount ?? 0; // in cents
  const totalUsage = currentPeriod.total_usage;
  const estimatedCost = (totalUsage * unitAmount) / 100; // convert to dollars

  return {
    usage: totalUsage,
    estimatedCost,
    periodStart: new Date(currentPeriod.period.start * 1000),
    periodEnd: new Date(currentPeriod.period.end * 1000),
  };
}

The Edge Cases That Will Bite You

A few things we learned by running into them:

  • Subscription item IDs change when a customer upgrades/downgrades — you need to update your stored ID when handling the customer.subscription.updated webhook
  • Usage records can't be deleted — if you report incorrect usage, you'll need to create a negative usage record to offset it (Stripe supports negative quantities with action: 'increment')
  • Free tier users — always check if the user has a subscription item before trying to report usage, or you'll be throwing errors for every free tier action
  • Timestamp precision matters — if you report usage twice with the same timestamp and quantity, Stripe may deduplicate it. Use unique timestamps or the 'set' action carefully
  • Canceled subscriptions — you have until the end of the billing period to report final usage after a cancellation, but set a hard cutoff in your code to avoid accidentally reporting usage after cancellation
Always handle the case where a user has no subscription item ID. Not every user has paid, and your usage reporting code will run for all of them.

Tiered Pricing on Top of Metered Billing

Flat-rate metered billing ($0.01 per call forever) works but doesn't reward heavy users or let you structure pricing attractively. Stripe supports tiered pricing where unit costs decrease as usage increases. You set this up on the price with `billing_scheme: 'tiered'`.

For example: first 1,000 calls free, 1,001-10,000 at $0.01 each, 10,001+ at $0.005 each. Your usage reporting code doesn't change at all — you still just report how many units were used. Stripe handles the tier math at billing time. This is one of those things where Stripe's abstraction actually saves you a headache.

A common pattern we see in our peal.dev templates that ship with payments: combine a flat monthly base fee (for access, support, whatever) with a metered price for usage. Customer pays $9/month to be a subscriber, plus $0.01 per API call over 500. This gives you predictable base revenue while keeping heavy users paying proportionally.

Webhooks You Can't Skip

Metered billing adds a few webhooks you need to handle beyond the standard subscription events:

  • customer.subscription.updated — catch subscription item ID changes when customers switch plans
  • invoice.created — fired before Stripe finalizes the invoice, your last chance to add extra line items or apply credits
  • invoice.finalized — the invoice is locked, usage for the period is finalized
  • invoice.payment_failed — your dunning flow starts here
  • customer.subscription.deleted — stop accepting usage from this customer

The `invoice.created` webhook is particularly useful. When Stripe creates the invoice at the end of a billing period, you have a short window (usually 1 hour) to add custom line items. This is where you can add things like overage fees, credits, or usage from systems Stripe doesn't know about. After that window, the invoice finalizes and you've lost your chance.

Testing Without Getting Surprised in Production

Stripe's test mode works fine for metered billing, but the billing period behavior can be confusing. In test mode, you can use `stripe.subscriptions.update()` with `trial_end: 'now'` to fast-forward through a billing period and trigger an invoice — useful for testing your invoice webhook handling without waiting a month.

Also: test with the Stripe CLI webhook forwarding (`stripe listen --forward-to localhost:3000/api/webhooks/stripe`) and actually trigger usage records, then advance the subscription. Don't just mock the webhook payload — test the whole flow. We've caught more bugs by doing full end-to-end tests in test mode than by any amount of unit testing the webhook handler in isolation.

Build a /api/admin/usage-test endpoint in development that creates a usage record and advances the billing period. Run it before every deploy that touches billing code. Future you will thank present you.

Usage-based billing is worth the complexity when it matches how your users actually get value from your product. The code isn't that different from subscription billing — you're mostly adding the usage reporting layer and making sure you store the right IDs. Get the subscription item ID storage right, don't let reporting failures affect the user experience, give customers visibility into their usage, and handle the edge cases around plan changes. That covers 95% of what you'll run into.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders