Back to Blog

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.

Viprasol Tech Team
February 11, 2027
14 min read

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

ModelDescriptionStripe feature
Pay-per-useEvery unit billed (no base fee)Metered price
Included + overageN units included, charge above NGraduated tiers
Prepaid creditsBuy credits, consume themCredits + meters
HybridFlat seat fee + metered API callsMultiple 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

ComponentTimelineCost (USD)
Local usage tracking schema0.5 day$300โ€“$500
Stripe meter setup + price config0.5โ€“1 day$400โ€“$800
Batch usage reporter worker1โ€“2 days$800โ€“$1,600
Customer usage dashboard1โ€“2 days$800โ€“$1,600
Usage threshold alerts0.5 day$300โ€“$500
Full usage-based billing system2โ€“3 weeks$12,000โ€“$20,000

See Also


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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

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.