Skip to main content

Stripe Metered Billing for SaaS Boilerplates 2026

·StarterPick Team
stripemetered-billingstripe-metersusage-based-billingsaas-boilerplate2026

Most SaaS boilerplates ship with flat-rate Stripe subscriptions: the user picks a plan, pays monthly, done. But in 2026, usage-based billing has moved from "nice to have" to table stakes for AI-adjacent SaaS, API products, and any tool where value delivered varies by customer.

This guide covers the Stripe Meters API — the newer, purpose-built approach to metered billing introduced in 2024 and now GA. It replaces the legacy subscriptionItems.createUsageRecord pattern with a cleaner event-based system. The code works with any boilerplate: ShipFast, Makerkit, next-forge, or a plain Next.js app.

If you've seen our other Stripe guides: the usage-based billing overview covers general metered billing patterns. This guide goes deeper on the Stripe Meters API specificallystripe.billing.meters.create(), idempotency requirements, webhook handling for meter events, and the new AI-aware billing features Stripe launched in 2026.

Stripe Meters vs Legacy Usage Records

Before the Meters API, metered billing used subscriptionItems.createUsageRecord(). That API still works but has real limitations:

Legacy Usage RecordsStripe Meters API
Event ingestionOne record per API callEvent stream (aggregate server-side)
Real-time usage query❌ Not availablelistEventSummaries()
IdempotencyManualBuilt-in via identifier field
AI token billing❌ No model tracking✅ Model-aware, auto markup
Aggregation typesSum, max, lastSum, count, last
Dashboard visibilityBasicFull meter dashboard + alerts

The Meters API is not just a rename — it's a different architecture. Events are immutable records of customer actions. Stripe aggregates them server-side, attaches the aggregation to your subscription price, and bills at period end. You report usage at event time (not billing time), which is simpler and more accurate.

Step 1: Create a Meter

Do this once — either in the Stripe dashboard or via API. A meter defines what you're counting and how.

// scripts/create-meter.ts (run once during setup)
import Stripe from "stripe";

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

async function createApiCallMeter() {
  const meter = await stripe.billing.meters.create({
    display_name: "API Calls",
    event_name: "api_call", // This is what you'll reference when reporting usage
    default_aggregation: {
      formula: "sum", // sum | count | last
    },
    customer_mapping: {
      type: "by_id",
      event_payload_key: "stripe_customer_id",
    },
    value_settings: {
      event_payload_key: "value", // Key in your event payload that holds the usage amount
    },
  });

  console.log("Meter created:", meter.id);
  // Store meter.id in your config — you'll need it when creating prices
  return meter;
}

createApiCallMeter();

For AI token billing, you might create separate meters for input tokens and output tokens (since they're priced differently):

// Input token meter
const inputMeter = await stripe.billing.meters.create({
  display_name: "AI Input Tokens",
  event_name: "ai_input_tokens",
  default_aggregation: { formula: "sum" },
  customer_mapping: { type: "by_id", event_payload_key: "stripe_customer_id" },
  value_settings: { event_payload_key: "value" },
});

// Output token meter
const outputMeter = await stripe.billing.meters.create({
  display_name: "AI Output Tokens",
  event_name: "ai_output_tokens",
  default_aggregation: { formula: "sum" },
  customer_mapping: { type: "by_id", event_payload_key: "stripe_customer_id" },
  value_settings: { event_payload_key: "value" },
});

Step 2: Create a Metered Price

In the Stripe dashboard (or API), create a price attached to your meter:

// Create a metered price for $0.001 per API call
const price = await stripe.prices.create({
  currency: "usd",
  unit_amount: 1, // $0.001 (amounts are in smallest currency unit, so 1 = $0.001 with 3 decimal places)
  // OR use unit_amount_decimal for fractional amounts:
  unit_amount_decimal: "0.001",
  billing_scheme: "per_unit",
  recurring: {
    interval: "month",
    meter: "mtr_your_meter_id_here", // The meter ID from Step 1
    usage_type: "metered",
  },
  product: "prod_your_product_id",
});

Attach this price to your subscription when the customer subscribes:

// When creating/updating subscription to add metered billing
const subscription = await stripe.subscriptions.create({
  customer: stripeCustomerId,
  items: [
    {
      price: "price_flat_monthly_plan", // Your base plan price
    },
    {
      price: price.id, // The metered price — no quantity needed
    },
  ],
});

Step 3: Report Usage with Meter Events

This is the core of metered billing — reporting what customers actually use. Call this from your API handlers, middleware, or background jobs.

// lib/billing/meter-events.ts
import Stripe from "stripe";

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

interface ReportUsageOptions {
  stripeCustomerId: string;
  eventName: string; // Must match meter's event_name
  value: number; // The usage amount (e.g., number of tokens, API calls)
  idempotencyKey?: string; // Strongly recommended
  timestamp?: number; // Unix timestamp — defaults to now
}

export async function reportUsage({
  stripeCustomerId,
  eventName,
  value,
  idempotencyKey,
  timestamp,
}: ReportUsageOptions) {
  try {
    const event = await stripe.billing.meterEvents.create(
      {
        event_name: eventName,
        payload: {
          stripe_customer_id: stripeCustomerId,
          value: String(value), // Must be a string
        },
        timestamp: timestamp ?? Math.floor(Date.now() / 1000),
        identifier: idempotencyKey, // Stripe deduplicates on this field
      }
    );

    return event;
  } catch (error) {
    // Log but don't throw — failed usage reporting should not break the user's request
    console.error("Failed to report usage to Stripe:", error);
    // Consider queuing for retry
  }
}

Usage in your API route:

// app/api/generate/route.ts (Next.js app router)
import { reportUsage } from "@/lib/billing/meter-events";
import { auth } from "@/lib/auth";

export async function POST(request: Request) {
  const session = await auth();
  if (!session?.user) return new Response("Unauthorized", { status: 401 });

  const { prompt } = await request.json();

  // Call your AI provider
  const result = await generateText({ prompt });

  // Report usage after successful generation
  // Use a deterministic idempotency key based on the request
  const requestId = crypto.randomUUID();
  await reportUsage({
    stripeCustomerId: session.user.stripeCustomerId,
    eventName: "ai_output_tokens",
    value: result.usage.outputTokens,
    idempotencyKey: `gen_${requestId}_output`,
  });

  await reportUsage({
    stripeCustomerId: session.user.stripeCustomerId,
    eventName: "ai_input_tokens",
    value: result.usage.inputTokens,
    idempotencyKey: `gen_${requestId}_input`,
  });

  return Response.json({ text: result.text });
}

Step 4: Show Customers Their Usage

A key UX requirement for metered billing — customers need to see what they're being charged for. Stripe's listEventSummaries makes this easy:

// app/api/usage/route.ts
import Stripe from "stripe";
import { auth } from "@/lib/auth";

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

export async function GET() {
  const session = await auth();
  if (!session?.user) return new Response("Unauthorized", { status: 401 });

  // Get current billing period start/end from subscription
  const subscription = await stripe.subscriptions.retrieve(
    session.user.stripeSubscriptionId
  );

  const periodStart = subscription.current_period_start;
  const periodEnd = subscription.current_period_end;

  // Query usage for this billing period
  const summaries = await stripe.billing.meters.listEventSummaries(
    process.env.STRIPE_API_CALLS_METER_ID!,
    {
      customer: session.user.stripeCustomerId,
      start_time: periodStart,
      end_time: periodEnd,
    }
  );

  const totalUsage = summaries.data.reduce(
    (sum, s) => sum + (s.aggregated_value ?? 0),
    0
  );

  return Response.json({
    totalCalls: totalUsage,
    periodStart: new Date(periodStart * 1000).toISOString(),
    periodEnd: new Date(periodEnd * 1000).toISOString(),
    estimatedCost: totalUsage * 0.001, // $0.001 per call
  });
}

Step 5: Handle Webhooks for Metered Billing

Metered billing adds a few webhook events you need to handle:

// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";

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

export async function POST(request: Request) {
  const body = await request.text();
  const sig = request.headers.get("stripe-signature")!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return new Response("Webhook signature verification failed", { status: 400 });
  }

  switch (event.type) {
    case "invoice.created": {
      // Sent before invoice is finalized — this is when metered usage is tallied
      // Good place to send "your bill is being calculated" email
      const invoice = event.data.object as Stripe.Invoice;
      await handleInvoiceCreated(invoice);
      break;
    }

    case "invoice.finalized": {
      // Invoice is locked with the final amount — metered usage is now confirmed
      const invoice = event.data.object as Stripe.Invoice;
      await handleInvoiceFinalized(invoice);
      break;
    }

    case "invoice.payment_succeeded": {
      // Payment collected — reset any usage counters in your DB if you track them
      const invoice = event.data.object as Stripe.Invoice;
      await handleInvoicePaid(invoice);
      break;
    }

    case "invoice.payment_failed": {
      // Customer's payment method failed for their usage bill
      // You may want to limit API access until they update payment
      const invoice = event.data.object as Stripe.Invoice;
      await handlePaymentFailed(invoice);
      break;
    }

    case "billing.meter.error_report_triggered": {
      // Stripe could not process one of your meter events
      // This happens when the customer_id doesn't exist or event payload is malformed
      console.error("Meter event error:", event.data.object);
      break;
    }
  }

  return new Response("OK", { status: 200 });
}

Common Pitfalls

1. Idempotency Keys Are Mandatory

If you report usage and then retry on network failure, you'll double-charge the customer without idempotency keys. Always use the identifier field with a deterministic key:

// Bad: no idempotency — retries double-count usage
await stripe.billing.meterEvents.create({
  event_name: "api_call",
  payload: { stripe_customer_id: customerId, value: "1" },
});

// Good: idempotent on retry
await stripe.billing.meterEvents.create({
  event_name: "api_call",
  payload: { stripe_customer_id: customerId, value: "1" },
  identifier: `req_${requestId}`, // Unique per logical event, not per HTTP attempt
});

2. Timestamps Must Be in the Current Billing Period

Stripe rejects meter events with timestamps outside the customer's current billing period. Don't backfill usage from previous months using this API.

// Bad: backdating to last month
await stripe.billing.meterEvents.create({
  event_name: "api_call",
  payload: { stripe_customer_id: customerId, value: "100" },
  timestamp: Math.floor(new Date("2026-02-01").getTime() / 1000), // Last month — rejected
});

// Good: use current timestamp
await stripe.billing.meterEvents.create({
  event_name: "api_call",
  payload: { stripe_customer_id: customerId, value: "100" },
  timestamp: Math.floor(Date.now() / 1000),
});

3. Usage Reporting Delays Are Normal

Stripe processes meter events asynchronously. There can be a delay of up to a few minutes between reporting an event and seeing it reflected in listEventSummaries. Don't build real-time dashboards that poll this API — cache the summaries or maintain your own usage counter.

4. The Value Must Be a String

The value field in the event payload must be a string, not a number. Stripe will reject events with a numeric value.

// Wrong
payload: { stripe_customer_id: customerId, value: 100 }

// Correct
payload: { stripe_customer_id: customerId, value: "100" }

5. Test with Stripe's Meter Event Simulator

In test mode, Stripe provides a clock simulator you can use to fast-forward billing periods and test the full invoice cycle:

// Create a test clock for billing cycle testing
const testClock = await stripe.testHelpers.testClocks.create({
  frozen_time: Math.floor(Date.now() / 1000),
});

// Create customer attached to test clock
const customer = await stripe.customers.create({
  email: "test@example.com",
  test_clock: testClock.id,
});

// Advance time to end of billing period
await stripe.testHelpers.testClocks.advance(testClock.id, {
  frozen_time: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, // +30 days
});

Adding Metered Billing to Common Boilerplates

ShipFast

ShipFast wraps Stripe in libs/stripe.js. Add your meter event reporting function there and call it from your API routes. The existing webhook handler in app/api/webhook/stripe/route.ts just needs the new event cases added.

Makerkit

Makerkit's billing service is in packages/billing/stripe. Create a meter-events.ts module in that package and import it in your app's API routes. Makerkit already handles invoice webhooks — add the billing.meter.error_report_triggered case.

next-forge

next-forge's billing package lives in packages/billing. Add a report-usage.ts utility there and import it into the apps/api package where your metered endpoints live.

Any Boilerplate

The pattern is always: create meter (once) → attach metered price to subscription → call stripe.billing.meterEvents.create() when usage occurs → let Stripe handle aggregation and invoicing. The Stripe SDK is the same regardless of boilerplate.

Summary

Stripe Meters is the right tool for usage-based billing in 2026. The Meters API is cleaner than legacy usage records, supports real-time querying, handles idempotency via the identifier field, and has first-class support for AI token billing. Key implementation steps: create your meter, attach a metered price to subscriptions, report events at usage time with idempotency keys, and handle invoice.created / invoice.payment_failed in your webhook handler.

See also: best boilerplates with Stripe integration and Stripe vs Lemon Squeezy vs Polar for SaaS 2026.

Comments

Get the free Boilerplate Comparison Matrix

Side-by-side matrix of 20+ SaaS starters — pricing, stack, features, and what you actually get. Plus weekly boilerplate reviews.

No spam. Unsubscribe anytime.