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.