SaaS Usage-Based Billing in 2026: Metered Stripe Subscriptions, Aggregation, and Overage Charges
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 |
See Also
- 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
Working With Viprasol
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.
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 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
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.