Usage-Based Billing
Meter and charge for usage with Stripe: report events to Stripe Billing Meters, gate features on plan limits, and reconcile metered invoices back into Supabase via webhooks in a Next.js 16 app.
Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.
SaaS-Builder-Vorlagen mit KI-Orchestrierung.
Usage-based billing means you charge for what customers actually consume: API calls, rendered minutes, rows processed, tokens spent. Stripe Billing Meters do the aggregation, proration, and invoicing, so your job is narrow: report an event when something billable happens, gate features when a plan runs out, and keep a local copy of the numbers so your app is not calling Stripe on every page load. This guide wires all four pieces together in a Next.js 16 app.
What You're Building
Four moving parts have to agree with each other for metered billing to work:
- A meter in Stripe that defines the billable unit and how to aggregate it.
- A metered price attached to a subscription, linked to that meter.
- A report step in your app that pushes a usage event to the meter every time the billable action succeeds.
- A reconcile step that reads finalized invoices back out of Stripe via webhooks and mirrors them into PostgreSQL via Supabase, so your dashboards and plan gates read from local data.
Get any one of these wrong and you either undercharge (money leaks) or overcharge (customers churn). The rest of this guide builds them in order.
Create the Meter and a Metered Price
A meter defines what you count and how. The event_name is the string your app will send with each usage report. The customer_mapping tells Stripe which payload key holds the customer id, and value_settings tells it which key holds the amount. This is a one-time setup, so run it as a script rather than wiring it into your app.
// scripts/create-meter.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
async function main() {
const meter = await stripe.billing.meters.create({
display_name: "API Requests",
event_name: "api_requests",
default_aggregation: { formula: "sum" },
customer_mapping: {
type: "by_id",
event_payload_key: "stripe_customer_id",
},
value_settings: {
event_payload_key: "value",
},
});
const price = await stripe.prices.create({
currency: "usd",
unit_amount: 2, // $0.02 per request
recurring: {
interval: "month",
usage_type: "metered",
meter: meter.id,
},
product_data: { name: "Metered API access" },
});
console.log("STRIPE_METER_ID:", meter.id);
console.log("STRIPE_PRICE_ID:", price.id);
}
main();The default_aggregation.formula accepts sum, count, or last. Use sum when each event carries a quantity, count when every event is worth exactly one unit, and last for gauge-style values like seats where you want the most recent reading in the period. The price ties itself to the meter through recurring.meter, and usage_type: "metered" tells Stripe to bill from reported usage instead of a fixed quantity.
Run it once and copy the two ids it prints into your environment:
npx tsx scripts/create-meter.tsSave STRIPE_METER_ID and STRIPE_PRICE_ID into .env.local. The price id goes onto the subscription when a customer signs up; the meter id is what you report usage against and query later.
The Stripe Client
Every server file needs the same configured Stripe instance, so create one module and import it everywhere. Throwing at import time if the key is missing catches a misconfigured environment during boot instead of at the first payment.
// lib/stripe.ts
import Stripe from "stripe";
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error("STRIPE_SECRET_KEY is not set");
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);Pinning an explicit apiVersion here is worth doing once your integration is stable, because Stripe ships breaking changes behind versioned releases. Leaving it unset uses your account's default version, which is fine while you build. Pin it the day you go live and upgrade deliberately.
Report Usage From Your Next.js Route
The report step lives inside the route handler that does the billable work. Do the work first, and only report usage after it succeeds, so a failed request never bills the customer. The identifier field makes the report safe to retry: Stripe deduplicates events with the same identifier, so a network retry does not double-charge.
// app/api/search/route.ts
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { getCustomer } from "@/lib/auth";
import { assertWithinLimit, UsageLimitError } from "@/lib/gate";
import { runSearch } from "@/lib/search";
export async function POST(request: Request) {
const customer = await getCustomer(request);
if (!customer) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
await assertWithinLimit(customer);
} catch (err) {
if (err instanceof UsageLimitError) {
return NextResponse.json(
{ error: "Usage limit reached", used: err.used, limit: err.limit },
{ status: 429 },
);
}
throw err;
}
const { query } = (await request.json()) as { query: string };
const results = await runSearch(query);
// Report one billable unit only after the work succeeded.
await stripe.billing.meterEvents.create({
event_name: "api_requests",
payload: {
stripe_customer_id: customer.stripeCustomerId,
value: "1",
},
identifier: crypto.randomUUID(),
});
return NextResponse.json({ results });
}The value is always a string, even though it represents a number. If a single call did three units of work you would send value: "3" and let the meter's sum aggregation add them up. For retry safety in a background job, replace crypto.randomUUID() with a stable id derived from the job (the same id on every attempt) so the dedup actually fires.
Read Current Usage Per Customer
To gate features and render an in-app usage bar, you need the running total for the current billing period. Stripe aggregates this for you through event summaries, so you never sum raw events yourself. The window is defined by the subscription period, passed as Unix timestamps.
// lib/usage.ts
import { stripe } from "@/lib/stripe";
export async function getCurrentUsage(
meterId: string,
stripeCustomerId: string,
periodStart: number,
periodEnd: number,
): Promise<number> {
const summaries = await stripe.billing.meters.listEventSummaries(meterId, {
customer: stripeCustomerId,
start_time: periodStart,
end_time: periodEnd,
});
return summaries.data.reduce(
(total, summary) => total + summary.aggregated_value,
0,
);
}Each summary bucket carries an aggregated_value for a slice of the window, so summing the buckets gives the period total. The start_time and end_time must be minute-aligned Unix seconds. Pull those bounds from the subscription, which is where the reconcile step below stores them.
Gate Features on Plan Limits
Plan limits are static config that rarely changes, so it is a good fit for the "use cache" directive. Caching the lookup means Next.js 16 serves the limit without re-running the function on every request.
// lib/plan-limits.ts
export type PlanId = "free" | "pro" | "scale";
export async function getPlanLimit(plan: PlanId): Promise<number> {
"use cache";
const limits: Record<PlanId, number> = {
free: 100,
pro: 10_000,
scale: 100_000,
};
return limits[plan];
}The gate itself compares live usage against the plan limit and throws a typed error when the customer is out. Reading the limit and the usage in parallel keeps the check fast. A custom error class lets the route handler map the failure to a 429 response cleanly instead of parsing error strings.
// lib/gate.ts
import { getPlanLimit, type PlanId } from "@/lib/plan-limits";
import { getCurrentUsage } from "@/lib/usage";
const METER_ID = process.env.STRIPE_METER_ID!;
export type Customer = {
stripeCustomerId: string;
plan: PlanId;
periodStart: number;
periodEnd: number;
};
export class UsageLimitError extends Error {
constructor(
public used: number,
public limit: number,
) {
super(`Usage limit reached: ${used}/${limit}`);
this.name = "UsageLimitError";
}
}
export async function assertWithinLimit(customer: Customer): Promise<void> {
const [limit, used] = await Promise.all([
getPlanLimit(customer.plan),
getCurrentUsage(
METER_ID,
customer.stripeCustomerId,
customer.periodStart,
customer.periodEnd,
),
]);
if (used >= limit) {
throw new UsageLimitError(used, limit);
}
}This gate runs inside the route handler, not in proxy.ts. Next.js 16 renames middleware.ts to proxy.ts, but neither is the right place for a database and Stripe round-trip on every request. Keep the check in server code where you already have the customer loaded, and let the route decide what to do when the limit is hit.
Store Subscriptions and Invoices in Supabase
Your app needs two local tables: one mapping users to their Stripe customer, plan, and billing period, and one mirroring finalized invoices. Row-level security keeps each user reading only their own subscription, while the invoices table has no read policy at all because only the webhook (using the service role) writes to it.
-- supabase/migrations/0001_metered_billing.sql
create table subscriptions (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id),
stripe_customer_id text not null unique,
stripe_subscription_id text not null,
plan text not null default 'free',
status text not null default 'active',
current_period_start timestamptz not null,
current_period_end timestamptz not null,
updated_at timestamptz not null default now()
);
alter table subscriptions enable row level security;
create policy "read own subscription"
on subscriptions for select
using (auth.uid() = user_id);
create table metered_invoices (
id text primary key, -- Stripe invoice id
stripe_customer_id text not null,
amount_due integer not null, -- cents
amount_paid integer not null,
usage_quantity integer not null,
status text not null,
period_start timestamptz not null,
period_end timestamptz not null,
created_at timestamptz not null default now()
);
alter table metered_invoices enable row level security;The webhook writes with the service role key, which bypasses row-level security, so the write path stays server-only. Never ship that key to the browser. Put it behind a server-only helper.
// lib/supabase-admin.ts
import { createClient } from "@supabase/supabase-js";
export function createServiceClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } },
);
}Reconcile Metered Invoices via Webhooks
At the end of each billing period Stripe finalizes an invoice with the aggregated usage priced in. A webhook is how you hear about it. The route reads the raw request body, verifies the signature so nobody can forge billing events, and routes each event type to a handler. Use constructEventAsync because it works in every runtime, including the Web Crypto environments where the synchronous version does not.
// app/api/webhooks/stripe/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { stripe } from "@/lib/stripe";
import { reconcileInvoice, syncSubscription } from "@/lib/reconcile";
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature,
webhookSecret,
);
} catch (err) {
const message = err instanceof Error ? err.message : "Invalid signature";
return NextResponse.json({ error: message }, { status: 400 });
}
switch (event.type) {
case "invoice.paid":
case "invoice.finalized":
await reconcileInvoice(event.data.object);
break;
case "customer.subscription.updated":
await syncSubscription(event.data.object);
break;
default:
break;
}
return NextResponse.json({ received: true });
}Reading the raw text body matters: Stripe signs the exact bytes it sent, so parsing the JSON first would break verification. reconcileInvoice sums the line quantities into the local invoices table, and syncSubscription copies the plan and current period back onto the subscription row so the gate has fresh bounds to read.
// lib/reconcile.ts
import type Stripe from "stripe";
import { createServiceClient } from "@/lib/supabase-admin";
export async function reconcileInvoice(invoice: Stripe.Invoice) {
if (!invoice.id) return;
const customerId =
typeof invoice.customer === "string"
? invoice.customer
: invoice.customer?.id;
if (!customerId) return;
const usageQuantity = invoice.lines.data.reduce(
(total, line) => total + (line.quantity ?? 0),
0,
);
const supabase = createServiceClient();
await supabase.from("metered_invoices").upsert({
id: invoice.id,
stripe_customer_id: customerId,
amount_due: invoice.amount_due,
amount_paid: invoice.amount_paid,
usage_quantity: usageQuantity,
status: invoice.status ?? "unknown",
period_start: new Date(invoice.period_start * 1000).toISOString(),
period_end: new Date(invoice.period_end * 1000).toISOString(),
});
}
export async function syncSubscription(subscription: Stripe.Subscription) {
const customerId =
typeof subscription.customer === "string"
? subscription.customer
: subscription.customer.id;
// In current Stripe API versions the billing period lives on the
// subscription item, not on the subscription itself.
const item = subscription.items.data[0];
const supabase = createServiceClient();
await supabase
.from("subscriptions")
.update({
plan: subscription.metadata.plan ?? "free",
status: subscription.status,
current_period_start: new Date(
item.current_period_start * 1000,
).toISOString(),
current_period_end: new Date(item.current_period_end * 1000).toISOString(),
updated_at: new Date().toISOString(),
})
.eq("stripe_customer_id", customerId);
}One detail catches most integrations upgrading to a recent Stripe API version: current_period_start and current_period_end were removed from the Subscription object and moved onto each subscription item. If your code reads subscription.current_period_end it silently returns undefined and every period bound becomes wrong. Read subscription.items.data[0].current_period_end instead. Using upsert on the invoice keyed by its id also makes the handler idempotent, so a replayed webhook overwrites rather than duplicates.
Test the Whole Loop Locally
You do not need a full billing cycle to test this. The Stripe CLI forwards live events to your local route and can trigger any event type on demand. Run the listener, copy the signing secret it prints into STRIPE_WEBHOOK_SECRET, then fire an invoice event.
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger invoice.paidThe webhook route should verify the signature, hit reconcileInvoice, and land a row in metered_invoices. Then hit /api/search a few times over the free-plan limit and confirm you get a 429 with the used and limit values in the body. That is the full loop (report, aggregate, gate, reconcile) proven without waiting for Stripe to close a real invoice.
Quality Gates Before You Ship
Metered billing is the kind of code where a type error means real money moves incorrectly, so the gates are not optional. Run the type check and a clean build before every commit.
npx tsc --noEmit
npm run buildAsk Claude Code to run both and fix what breaks:
claude "run tsc --noEmit and fix any type errors in the billing code, then confirm the build passes"The type checker is what catches the subscription-period mistake, the string-versus-number value on meter events, and the nullable invoice fields before they reach production. Zero type errors and a clean build are the minimum bar here, not a nice-to-have.
Ship It With a Harness
A metered billing loop has a lot of parts that all have to agree: the meter, the price, the report call, the gate, the webhook, and the local mirror. Wiring each one by hand is exactly where integrations leak money, and a single wrong field (a period read off the subscription, a random identifier on every retry) is enough to over-bill or under-bill silently. The $29 Code Kit is a one-time purchase (no subscription) that wraps Claude Code in a harness: it plans the feature, builds it, evaluates the output, tests it against a real browser, and runs quality gates so every change exits with zero type errors, zero lint errors, and a clean build. Claude Code itself runs on a paid Anthropic plan. The Kit is the pipeline that turns one prompt into a billing flow you can actually put in front of paying customers.
Posted by @speedy_devv
Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.
SaaS-Builder-Vorlagen mit KI-Orchestrierung.
Marketplace Payments
Build a two-sided marketplace with Stripe Connect in Next.js 16: onboard sellers with Express accounts, take split payments with application fees, and sync Connect webhooks into Supabase.
Was es 2026 kostet, ein SaaS mit Claude Code zu bauen
Eine echte Kostenaufstellung fürs Bauen eines SaaS mit Claude Code: der $20-pro-Monat-Plan, wann du Max brauchst, Token-Kosten, das optionale $29-Kit und wie es sich gegen einen Freelancer für $5.000+ schlägt. Klare Zahlen, kein Hype.