Stripe Usage-Based Billing 2026: Meters & Meter Events Guide
Implement SaaS usage-based billing with Stripe: metered subscriptions, usage record aggregation, overage pricing tiers, real-time meter reporting, and billing period reconciliation.
SaaS Usage-Based Billing in 2026: Metered Stripe Subscriptions, Aggregation, and Overage Charges
Usage-based billing (UBB) charges customers for what they actually use — API calls, seats, GB of storage, messages sent. It's the fastest-growing pricing model in B2B SaaS because it aligns cost with value: customers start small and expand naturally without friction.
Implementing it correctly requires three layers: tracking usage in your own database, reporting usage records to Stripe at billing period end (or real-time), and handling overages, thresholds, and credits. This post covers the full stack using Stripe's Billing Meters API (the 2024+ recommended approach) and the older Usage Records API for compatibility.
Pricing Models Supported
| Model | Description | Stripe feature |
|---|---|---|
| Pay-per-use | Every unit billed (no base fee) | Metered price |
| Included + overage | N units included, charge above N | Graduated tiers |
| Prepaid credits | Buy credits, consume them | Credits + meters |
| Hybrid | Flat seat fee + metered API calls | Multiple line items |
Database: Track Usage Locally First
Always record usage in your own database before reporting to Stripe. This gives you:
- Audit trail independent of Stripe
- Ability to show real-time usage to customers
- Data for billing period reconciliation
CREATE TABLE usage_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id),
metric TEXT NOT NULL, -- 'api_calls', 'storage_gb', 'messages'
quantity NUMERIC NOT NULL,
idempotency_key TEXT NOT NULL UNIQUE, -- Prevent double-counting
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
reported_at TIMESTAMPTZ, -- NULL until reported to Stripe
stripe_event_id TEXT -- Stripe's meter event ID
);
CREATE INDEX idx_usage_events_workspace_metric
ON usage_events(workspace_id, metric, occurred_at DESC);
CREATE INDEX idx_usage_events_unreported
ON usage_events(reported_at)
WHERE reported_at IS NULL;
-- Aggregated view for billing period
CREATE VIEW current_period_usage AS
SELECT
workspace_id,
metric,
SUM(quantity) AS total_quantity,
COUNT(*) AS event_count,
MIN(occurred_at) AS first_event,
MAX(occurred_at) AS last_event
FROM usage_events
WHERE occurred_at >= date_trunc('month', now())
GROUP BY workspace_id, metric;
🚀 SaaS MVP in 8 Weeks — Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment — all handled by one senior team.
- Week 1–2: Architecture design + wireframes
- Week 3–6: Core features built + tested
- Week 7–8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
Recording Usage in Application Code
// lib/billing/usage-tracker.ts
import { db } from "@/lib/db";
import { createHash } from "crypto";
export async function recordUsage({
workspaceId,
metric,
quantity,
idempotencyKey,
}: {
workspaceId: string;
metric: string;
quantity: number;
idempotencyKey?: string;
}) {
// Deterministic idempotency key if not provided
const key = idempotencyKey ?? createHash("sha256")
.update(`${workspaceId}:${metric}:${Date.now()}:${Math.random()}`)
.digest("hex");
await db.usageEvent.upsert({
where: { idempotencyKey: key },
create: {
workspaceId,
metric,
quantity,
idempotencyKey: key,
},
update: {}, // Already recorded — idempotent
});
}
// Usage in API middleware
export async function apiMiddleware(req: Request, workspaceId: string) {
// Process request...
// Record usage after successful response
await recordUsage({
workspaceId,
metric: "api_calls",
quantity: 1,
idempotencyKey: `api:${req.headers.get("x-request-id")}`,
});
}
Stripe Billing Meters API (2024+ recommended)
Stripe's new Meters API replaces Usage Records for new integrations — it supports real-time reporting, deduplication, and better event model:
// lib/billing/stripe-meters.ts
import Stripe from "stripe";
import { db } from "@/lib/db";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-06-20",
});
// Create a meter in Stripe (one-time setup, do in stripe dashboard or script)
export async function setupMeters() {
const apiCallsMeter = await stripe.billing.meters.create({
display_name: "API Calls",
event_name: "api_calls",
default_aggregation: {
formula: "sum", // 'sum' | 'count' | 'last'
},
customer_mapping: {
event_payload_key: "stripe_customer_id",
type: "by_id",
},
});
console.log("Meter ID:", apiCallsMeter.id);
// Store this ID in your config/env
}
// Report a usage event to Stripe in real-time
export async function reportMeterEvent({
stripeCustomerId,
metricName,
quantity,
idempotencyKey,
occurredAt,
}: {
stripeCustomerId: string;
metricName: string;
quantity: number;
idempotencyKey: string;
occurredAt: Date;
}) {
const event = await stripe.billing.meterEvents.create(
{
event_name: metricName,
payload: {
stripe_customer_id: stripeCustomerId,
value: String(quantity),
},
timestamp: Math.floor(occurredAt.getTime() / 1000),
},
{
idempotencyKey, // Stripe deduplicates on this key
}
);
return event;
}

💡 The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments — with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity — you own everything
Batch Reporting Worker (for async approach)
Real-time reporting adds latency to each API call. A background worker reports usage in batches every few minutes:
// workers/usage-reporter.ts
import { db } from "@/lib/db";
import { stripe } from "@/lib/stripe";
import { chunk } from "lodash-es";
const BATCH_SIZE = 100;
const REPORT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
export async function reportUnsentUsage() {
// Claim a batch of unreported events (advisory lock prevents double-report)
const events = await db.$queryRaw<{
id: string;
workspaceId: string;
metric: string;
quantity: number;
idempotencyKey: string;
occurredAt: Date;
stripeCustomerId: string;
}[]>`
SELECT u.id, u.workspace_id, u.metric, u.quantity,
u.idempotency_key, u.occurred_at, s.stripe_customer_id
FROM usage_events u
JOIN workspace_subscriptions s ON s.workspace_id = u.workspace_id
WHERE u.reported_at IS NULL
AND s.stripe_customer_id IS NOT NULL
ORDER BY u.occurred_at ASC
LIMIT ${BATCH_SIZE}
FOR UPDATE SKIP LOCKED
`;
if (events.length === 0) return;
const results = await Promise.allSettled(
events.map((event) =>
stripe.billing.meterEvents.create(
{
event_name: event.metric,
payload: {
stripe_customer_id: event.stripeCustomerId,
value: String(event.quantity),
},
timestamp: Math.floor(event.occurredAt.getTime() / 1000),
},
{ idempotencyKey: event.idempotencyKey }
).then((stripeEvent) => ({ event, stripeEventId: stripeEvent.id }))
)
);
// Mark successful reports
const successIds: string[] = [];
const stripeEventMap: Record<string, string> = {};
for (const result of results) {
if (result.status === "fulfilled") {
successIds.push(result.value.event.id);
stripeEventMap[result.value.event.id] = result.value.stripeEventId;
} else {
console.error("Failed to report usage event:", result.reason);
}
}
if (successIds.length > 0) {
await db.$executeRaw`
UPDATE usage_events
SET
reported_at = now(),
stripe_event_id = CASE id
${successIds.map((id) => `WHEN '${id}' THEN '${stripeEventMap[id]}'`).join(" ")}
END
WHERE id = ANY(${successIds}::uuid[])
`;
}
console.log(`Reported ${successIds.length}/${events.length} usage events`);
}
Stripe Product and Price Setup
// scripts/setup-stripe-products.ts
// One-time setup: creates products and prices in Stripe
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-06-20" });
async function setup() {
// Flat monthly seat fee
const seatProduct = await stripe.products.create({
name: "Pro Plan — Per Seat",
metadata: { type: "seat" },
});
const seatPrice = await stripe.prices.create({
product: seatProduct.id,
unit_amount: 1500, // $15/seat
currency: "usd",
recurring: { interval: "month" },
});
// Metered API calls (graduated tiers)
const apiProduct = await stripe.products.create({
name: "API Calls",
metadata: { type: "metered" },
});
const apiPrice = await stripe.prices.create({
product: apiProduct.id,
currency: "usd",
recurring: {
interval: "month",
usage_type: "metered",
aggregate_usage: "sum",
},
billing_scheme: "tiered",
tiers_mode: "graduated",
tiers: [
{ up_to: 10_000, unit_amount: 0 }, // First 10K free
{ up_to: 100_000, unit_amount: 5 }, // 10K–100K: $0.005 each
{ up_to: 1_000_000, unit_amount: 2 }, // 100K–1M: $0.002 each
{ up_to: "inf", unit_amount: 1 }, // >1M: $0.001 each
],
// Link to Stripe meter
currency_options: {},
});
console.log("Seat price ID:", seatPrice.id);
console.log("API price ID:", apiPrice.id);
}
Subscription with Multiple Price Items
// lib/billing/create-subscription.ts
export async function createSubscription({
stripeCustomerId,
seatCount,
seatPriceId,
apiCallsPriceId,
}: {
stripeCustomerId: string;
seatCount: number;
seatPriceId: string;
apiCallsPriceId: string;
}) {
const subscription = await stripe.subscriptions.create({
customer: stripeCustomerId,
items: [
{
price: seatPriceId,
quantity: seatCount, // Flat: $15 × seatCount/month
},
{
price: apiCallsPriceId, // Metered: no quantity here; reported via meter events
},
],
billing_cycle_anchor: "now",
proration_behavior: "create_prorations",
payment_behavior: "default_incomplete",
expand: ["latest_invoice.payment_intent"],
});
return subscription;
}
Real-Time Usage Display to Customers
// app/api/usage/current/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getWorkspaceContext } from "@/lib/auth/workspace-context";
import { db } from "@/lib/db";
import { stripe } from "@/lib/stripe";
import { PLANS } from "@/lib/billing/plans";
export async function GET(req: NextRequest) {
const ctx = await getWorkspaceContext();
if (!ctx) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const [localUsage, subscription] = await Promise.all([
// Local usage (real-time, includes unreported events)
db.usageEvent.groupBy({
by: ["metric"],
where: {
workspaceId: ctx.workspaceId,
occurredAt: { gte: startOfCurrentMonth() },
},
_sum: { quantity: true },
}),
// Stripe subscription for limits
db.workspaceSubscription.findFirst({
where: { workspaceId: ctx.workspaceId },
select: { plan: true, stripeSubscriptionId: true },
}),
]);
const plan = PLANS[subscription?.plan ?? "starter"];
const usageMap = Object.fromEntries(
localUsage.map((u) => [u.metric, u._sum.quantity ?? 0])
);
return NextResponse.json({
period: {
start: startOfCurrentMonth(),
end: endOfCurrentMonth(),
},
metrics: {
api_calls: {
used: usageMap["api_calls"] ?? 0,
included: plan.limits.apiCallsPerMonth,
overage: Math.max(0, (usageMap["api_calls"] ?? 0) - plan.limits.apiCallsPerMonth),
},
},
});
}
function startOfCurrentMonth() {
const d = new Date();
d.setDate(1); d.setHours(0, 0, 0, 0);
return d;
}
function endOfCurrentMonth() {
const d = new Date(startOfCurrentMonth());
d.setMonth(d.getMonth() + 1);
return d;
}
Usage Threshold Alerts
// lib/billing/usage-alerts.ts
const ALERT_THRESHOLDS = [0.8, 0.95, 1.0]; // 80%, 95%, 100%
export async function checkUsageThresholds(workspaceId: string) {
const { metrics } = await getCurrentUsage(workspaceId);
for (const [metric, data] of Object.entries(metrics)) {
if (data.included === -1) continue; // Unlimited
const ratio = data.used / data.included;
for (const threshold of ALERT_THRESHOLDS) {
if (ratio >= threshold) {
const alertKey = `${workspaceId}:${metric}:${threshold}:${currentMonth()}`;
// Only send each alert once per month
const alreadySent = await db.usageAlert.findUnique({
where: { alertKey },
});
if (!alreadySent) {
await db.usageAlert.create({ data: { alertKey } });
await sendUsageAlert({ workspaceId, metric, threshold, ratio });
}
}
}
}
}
Cost and Timeline
| Component | Timeline | Cost (USD) |
|---|---|---|
| Local usage tracking schema | 0.5 day | $300–$500 |
| Stripe meter setup + price config | 0.5–1 day | $400–$800 |
| Batch usage reporter worker | 1–2 days | $800–$1,600 |
| Customer usage dashboard | 1–2 days | $800–$1,600 |
| Usage threshold alerts | 0.5 day | $300–$500 |
| Full usage-based billing system | 2–3 weeks | $12,000–$20,000 |
- SaaS Customer Portal — Showing usage in the customer portal
- SaaS Dunning Management — Recovering failed payments when overage occurs
- Stripe Webhook Handling — Handling billing period close events
- SaaS Subscription Upgrade Downgrade — Changing plans with metered billing
What We Bring to the Table
We build usage-based billing systems for SaaS products — from simple API call metering through complex hybrid seat + usage pricing with credits, thresholds, and real-time dashboards. Our fintech team has shipped billing systems processing millions of usage events per day.
What we deliver:
- Local usage event recording with idempotency keys
- Stripe Billing Meters integration with real-time or batch reporting
- Graduated tier pricing configuration
- Customer-facing real-time usage dashboard
- Threshold alerts at 80%, 95%, and 100% of limits
Explore our SaaS development services or contact us to implement usage-based billing.
External Resources
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours — fast.
Free consultation • No commitment • Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow — automating repetitive tasks, qualifying leads, and responding across every channel your customers use.