Stripe Metered Billing for SaaS Boilerplates 2026
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 specifically —
stripe.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 Records | Stripe Meters API | |
|---|---|---|
| Event ingestion | One record per API call | Event stream (aggregate server-side) |
| Real-time usage query | ❌ Not available | ✅ listEventSummaries() |
| Idempotency | Manual | Built-in via identifier field |
| AI token billing | ❌ No model tracking | ✅ Model-aware, auto markup |
| Aggregation types | Sum, max, last | Sum, count, last |
| Dashboard visibility | Basic | Full 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.
Designing Your Pricing Model Around Meters
Meter implementation is mechanical. The harder question is what unit to meter. Usage-based pricing works best when the unit of value is clear to both you and your customer — the moment your customer sees the meter, they should understand why they're paying for it and be able to predict their bill.
Common metering units that work well: API calls (visible, countable, directly correlated to value), active users per month (per-seat with grace), AI token consumption (cost-driven, transparent), documents processed or records transformed (output-driven), and emails sent (volume-driven). Common metering units that create confusion: "credits" with no clear definition, "compute units" without explanation, and hybrid combinations where customers can't predict their costs.
The best usage-based pricing models are self-reinforcing: customers who use the product more, pay more, and they're willing to pay more because they're getting more value. Customers who use less, pay less, which reduces churn in low-engagement periods. The churn reduction benefit is often underestimated — flat-rate subscriptions churn heavily from users who have a bad month. Usage-based subscriptions naturally scale with customers' own business cycles.
Graduated Pricing With Meters
Stripe Meters support graduated pricing, where different usage tiers have different per-unit rates. This lets you offer volume discounts without negotiating custom contracts:
You configure the graduated tiers in the Stripe price object attached to the meter. For example, a tiered API pricing model might charge $0.01 per call for the first 10,000 calls, $0.007 for calls 10,001-100,000, and $0.005 above 100,000. Stripe handles the tier calculation automatically at invoice time based on the aggregated meter events for the billing period.
Graduated pricing creates a natural alignment between customer success and revenue: your highest-value customers pay the most in aggregate while benefiting from the lowest per-unit rates, which rewards their loyalty without requiring manual negotiation.
Real-Time Usage Dashboards
Users on metered plans want to know where they stand before the invoice arrives. A usage dashboard showing current-period consumption against their included quota is not optional — its absence is a support ticket driver.
Stripe's listEventSummaries API returns aggregated meter events for a given period and customer. Poll this on dashboard load (with server-side caching — cache the response for 5-10 minutes to avoid hammering the Stripe API). Display the current period total alongside the customer's plan limit.
For products where usage spikes are possible (AI generation, batch processing), add a usage alert: email the customer at 80% of their included quota and again at 100%. This is not only good customer service — it's a natural upsell trigger. The customer who hits 100% and sees a graceful "you've reached your limit, upgrade to continue" message is in the perfect mindset to upgrade.
Per-Seat Plus Metered: Hybrid Pricing
Many B2B SaaS products combine a per-seat base price with metered overages for high-consumption features. A team of 5 users pays $50/month (per-seat), but each user also gets 1,000 AI generation credits included, with overages billed at $0.01 per credit.
Stripe supports this by attaching multiple prices to a subscription: a flat recurring price for seats and a metered price for the usage component. Both appear on the same invoice. The customer sees a clear line item for each.
Implementing this requires tracking both dimensions: the seat count (managed in your subscription metadata via Stripe's quantity parameter) and the usage consumption (via meter events). Boilerplates that support per-seat billing typically handle the seat dimension already — the metered overage layer is additive. See Stripe vs Lemon Squeezy for SaaS for how each provider handles complex pricing models like these, and best SaaS boilerplates 2026 for which starters ship with the most complete billing infrastructure to build these patterns on top of.
Every SaaS boilerplate makes architectural decisions that become increasingly expensive to reverse as your codebase grows. The best time to evaluate whether a boilerplate's decisions match your product requirements is before you've written any custom business logic on top of it.