Usage and quotas
Count usage per tenant and meter with idempotent increments, enforce quotas per tenant or plan in RLS and RPCs, and report usage to Stripe meters.
The usage block counts what each tenant uses (API calls, seats, generated
documents) and enforces quotas on it. Counters are kept per tenant, meter and
UTC day, so one meter can have a daily quota for one plan and a monthly one
for another.
better-supabase sql add usage # adds tenant and access as well| Table | Holds |
|---|---|
usage_counters | value and reported_value per (organization_id, meter, day) |
usage_events | One row per idempotency key, so a retried increment counts once |
usage_quotas | limit and period (day, week, month, year or billing) per tenant or per plan |
Members with usage.read (the default admin role) can read the counters,
the tenant's quotas and the status that current and overview return.
Other members get a forbidden result with the hint USAGE_FORBIDDEN. Recording usage (record, consume and the batch
variants) needs usage.record (also in the default admin role) or the
service role, so a member can't inflate or use up the tenant's quota from
the browser. Only the service role writes quotas.
Quotas
A quota row names either a tenant or a plan. A tenant row overrides the plan
rows. The plan * applies to every tenant; with the
entitlements module installed, any other plan
matches tenants with that entitlement key, and the highest limit wins. When
entitlements.source is a plan catalog, a quota's plan also matches the
tenant's active plan key (better_supabase.tenant_plans(tenant)), so
('pro', 'api_calls', 50000, 'month') applies to every tenant on pro.
insert into better_supabase.usage_quotas (plan, meter, "limit", period) values
('*', 'api_calls', 1000, 'month'),
('pro', 'api_calls', 100000, 'month');
insert into better_supabase.usage_quotas (organization_id, meter, "limit", period)
values ('8d1c...', 'api_calls', 250000, 'month');A null limit is unlimited. It wins over every other plan row, so an
enterprise plan row with a null limit lifts the * limit for those
tenants, and a tenant row with a null limit lifts the plan limits for one
tenant. consume and within_quota always pass for an unlimited meter, and
current returns unlimited: true with no limit or remaining. Unlike a
meter with no quota row at all, the unlimited quota still sets the meter's
period.
insert into better_supabase.usage_quotas (plan, meter, "limit", period)
values ('enterprise', 'api_calls', null, 'month');Billing periods
Calendar periods reset at the start of the UTC day, week, month or year.
Products billed through Stripe usually reset quotas with the invoice instead,
which starts on the subscription's own day. Give such quotas the period
billing and write a usage_billing_period(tenant) function (in the hooks
schema, public by default) that returns the current window:
create function public.usage_billing_period(tenant uuid)
returns table (starts_at timestamptz, ends_at timestamptz)
language sql stable security definer set search_path = '' as $$
select to_timestamp(s.current_period_start), to_timestamp(s.current_period_end)
from stripe.subscriptions s
join better_supabase.billing_customers c on c.stripe_customer_id = s.customer
where c.organization_id = tenant and s.status in ('active', 'trialing')
order by s.created desc
limit 1
$$;Any source works: a column on your subscriptions table, or a fixed anchor
day. Counters are kept per UTC day, so a window counts the whole days from
starts_at up to the day of ends_at. Without the function, or when it
returns no row, a billing quota falls back to the calendar month.
The function only returns the current window, so a day reported after its
period ended (an overage report that ran late, say) counts in the period that
held it, taking earlier periods to be as long as the current one.
usage_status and current return startsAt and resetsAt for the window,
and retryAfter counts down to its end.
The meter catalog
options.meters lists the meters with a unit, category and label for
display. With it, record_usage and consume_quota refuse other meter names
(USAGE_METER_UNKNOWN), usage_status returns the meter's fields, and
usage.meters() (usage_meters()) reads the catalog for a usage page:
usage: {
options: {
meters: {
"ai.tokens": { unit: "tokens", category: "ai", label: "AI tokens" },
"api.requests": { unit: "requests", category: "api" },
"storage.bytes": { unit: "bytes", category: "storage" },
},
},
},A catalog that changes without a deploy (meters an admin adds, products
from a plan table) can live in a table instead. Point options.meters at it
with table and the column names; unit, category, label and active
are optional, and only rows where active is true are meters:
usage: {
options: {
meters: {
table: "public.meters",
key: "slug", // default key
unit: "unit",
label: "title",
active: "is_active",
},
},
},usage_meters() then reads the table on every call, so a new row is a
meter right away.
Weighted quantities
record and consume take a quantity, so a meter can count something
other than calls. For credits, record the credits a call costs, such as
tokens * creditsPerToken, on a credits meter with a quota in credits;
the counter, the quota and the Stripe report all work on that number. Keep
the raw units on their own meter when you also want to show them.
Quantities, counters and limits are numeric, so a meter can count
fractions, such as GB-hours or credits with decimals, against a fractional
quota:
await usage.record(organizationId, "gb_hours", { quantity: 0.25 });reportUsageToStripe sends the change as it is; when your Stripe meter
expects whole numbers, record in whole units (tokens, cents) instead.
within_quota takes a whole quantity (1 by default), as policies check
rows.
Use within_quota in a policy to stop inserts once the quota is used up:
create policy "projects_insert" on public.projects for insert to authenticated
with check (better_supabase.within_quota(organization_id, 'projects'));Any member of the tenant gets the answer, so the policy works for members
without usage.read. For a caller outside the tenant within_quota returns
false, so nobody can probe another tenant's usage with it.
Recording and consuming
import { createUsage, sqlTransport } from "better-supabase/blocks/usage";
const usage = createUsage({ transport: sqlTransport(postgres.asUser(claims)) });
// Counts the call and fails when it would go over the quota.
const consumed = await usage.consume(organizationId, "documents", {
idempotencyKey: requestId,
});
if (!consumed.ok) return problemResponse(consumed.error);
await usage.record(organizationId, "api_calls"); // metering only, no check
const { used, limit, remaining, resetsAt } = await usage
.current(organizationId, "documents")
.orThrow();consume locks the day's counter, so two concurrent calls can't both take the
last unit. When the quantity doesn't fit, nothing is recorded and the result
is a quota_exceeded DbError with status 429,
the meter, its limit and retryAfter, the seconds until the period
resets. problemResponse turns it into Problem Details with a Retry-After
header. In SQL, consume_quota raises it with SQLSTATE BSQ29, so an RPC
that calls it fails the same way.
Every meter of a tenant
A usage page that shows every meter calls overview instead of current
once per meter. It returns the status of each meter in the catalog, each
meter a quota applies to and each meter the tenant has used, sorted by
meter, in one request (usage_overview(tenant) in SQL). Like current, it
needs usage.read.
const meters = await usage.overview(organizationId).orThrow();
// [{ meter: "api_calls", used: 420, limit: 1000, remaining: 580, unlimited: false, ... }]Several meters at once
recordMany and consumeMany record several meters in one transaction, all
or none, such as the input and output tokens of one model call.
consumeMany checks each meter's quota first; when one doesn't fit, nothing
is recorded and the result is quota_exceeded. The idempotency key covers
the batch, and source, metadata and actor apply to every entry:
await usage
.consumeMany(
organizationId,
[
{ meter: "input_tokens", quantity: inputTokens },
{ meter: "output_tokens", quantity: outputTokens },
],
{ idempotencyKey: requestId, source: "feature:chat" },
)
.orThrow();
// { recorded: true, today: { input_tokens: 1200, ... }, used: { input_tokens: 48000, ... } }record, consume and the batches return today, the UTC day's usage, and
used, the usage in the quota's current period (the month without a quota),
the same number current returns.
In SQL, record_usage_batch(tenant, entries, idempotency_key, check) takes
entries as a JSON array of { meter, quantity }.
Usage history
A usage page shows who and what used a meter. Set
sql.modules.usage.options.history to true and every recorded quantity also
goes to usage_history, with the user who used it and the source and
metadata you pass:
await usage.consume(organizationId, "tokens", {
quantity: tokens,
source: "feature:summary",
metadata: { documentId },
});
const entries = await usage
.history(organizationId, { meter: "tokens", limit: 50 })
.orThrow();
// [{ id, meter, quantity, actor, source, metadata, recordedAt }], newest first
const byUser = await usage.breakdown(organizationId, "tokens").orThrow();
// [{ actor, source, quantity }] in the quota's current window, largest firstThe actor is the caller's user id. A service transport recording on a user's
behalf passes actor; other callers can't set it. Reading the history and
the breakdown needs usage.read, and history pages with cursor (the
last entry's id). better_supabase.purge_usage_history(older_than, batch)
deletes old entries (400 days by default), and
better_supabase.purge_usage_events(older_than, batch) deletes idempotency
keys (30 days by default); schedule both like the other purges. A retry
that arrives after its key is purged counts again. Without the option, both reads return [].
Reporting to Stripe
reportUsageToStripe sends the usage not yet reported as
Stripe meter events,
one per counter and change. Run it from a job or a cron route with a
service-role transport:
import {
reportUsageToStripe,
sqlTransport,
} from "better-supabase/blocks/usage";
export async function GET() {
const { reported, skipped } = await reportUsageToStripe({
transport: sqlTransport(postgres),
stripe: { secretKey: env.STRIPE_SECRET_KEY },
eventName: (meter) => (meter === "api_calls" ? "api_requests" : undefined),
});
return Response.json({ reported, skipped });
}The event identifier is the tenant, meter, day and new total, so a run that
fails between sending and marking sends the same event again and Stripe drops
the duplicate. The Stripe customer comes from tenant_stripe_customer() in
the entitlements module unless you pass customer, which reads it from
anywhere, such as your own customers table:
customer: async (organizationId) =>
(await db.billingCustomers.findFirst({ where: { organizationId } }).orThrow())
?.stripeCustomerId,When a plan includes an allowance and Stripe bills only what goes over it,
pass overage: true: in each quota window, usage up to the tenant's quota
is marked reported without an event, and only the units above it are sent.
A meter without a quota sends everything, and a meter with an unlimited quota
sends nothing. unreported_usage returns each
counter's included quota and window_before (the earlier days' usage in
that window) for the calculation; usage that arrives late on an earlier day
is counted with that day.
Counters without a
customer or an event name are skipped and stay unreported. They don't hold up
the rest: once a run meets a meter without an event name or a tenant without
a customer, it asks unreported_usage again with that meter in skip_meters
or that tenant in skip_tenants, so batch still fills with counters it can
send. stripe is an
optional peer; pass a Stripe client instead of secretKey to use your own.
Functions
| Function | Granted to | Returns |
|---|---|---|
record_usage(tenant, meter, quantity, key, source, metadata, actor) | authenticated, service_role | { recorded, used } |
consume_quota(tenant, meter, quantity, key, source, metadata, actor) | authenticated, service_role | the same, or raises BSQ29 |
within_quota(tenant, meter, quantity) | authenticated, service_role | whether quantity more fits, false outside the tenant |
record_usage_batch(tenant, entries, key, check, source, metadata, actor) | authenticated, service_role | { recorded, used } with used per meter, or raises BSQ29 |
usage_status(tenant, meter) | authenticated, service_role | { meter, used, limit, remaining, unlimited, period, starts_at, resets_at }, plus the catalog fields, for usage.read |
usage_overview(tenant) | authenticated, service_role | usage_status of every meter of the tenant, by meter, for usage.read |
usage_meters() | everyone | the meter catalog |
usage_history(tenant, meter, max_rows, before_id) | authenticated, service_role | the history entries, for usage.read |
usage_breakdown(tenant, meter) | authenticated, service_role | [{ actor, source, quantity }] in the current window |
usage_window(tenant, period) | service_role | the current window's starts_at and ends_at |
unreported_usage(max_rows, skip_meters, skip_tenants) | service_role | counters with value > reported_value |
mark_usage_reported(tenant, meter, day, value) | service_role | whether the counter exists |
Last updated on
Settings
Per-user and per-organization settings with a Standard Schema per key, defaults, typed get and set, and matching pg_jsonschema checks.
Billing
Stripe customers per tenant, Checkout and the customer portal, seat sync from membership events, and Stripe webhook handling that links customers and refreshes sessions.