API quotas
Usage plans that throttle and cap a meter per API key, agent, person, group, or organization, counted in the database and enforced in your handlers.
A public API needs limits per customer: the free tier gets a thousand calls a day, a partner's integration gets more, and one broken script retrying in a loop must not slow everyone else down. API gateways solve this with usage plans keyed by an API key, apart from everything the product knows about who is calling.
Better IAM decides it by who the caller is: the API key they present, the agent acting for them, the person, their , or the whole organization. A usage plan throttles and caps one meter, your request handlers count use against it, and the counters live in the IAM database, so every server instance sees the same totals.
await iam.api.quotas.createPlan(admin, {
tenantId,
name: 'free',
meter: 'requests',
throttle: { ratePerSecond: 5, burst: 20 },
limits: [
{ period: 'day', limit: 1_000 },
{ period: 'month', limit: 20_000 },
],
default: true, // everyone in the organization without a plan of their own
alertThresholds: [80, 100],
});
// In a request handler:
const decision = await iam.quotas.enforce({ headers: request.headers, tenantId, meter: 'requests' });
// decision.limits: [{ period: 'day', limit: 1000, used: 42, remaining: 958, resetAt }, ...]enforce throws QUOTA_EXCEEDED (429) when the call would go over, carrying
retryAfterMs. consume returns the same decision with allowed: false instead of throwing. A refused call counts
nothing.
Plans
A plan counts one meter: a name you choose for what is being counted, such as requests, exports, emails, or
tokens. It limits the meter in two ways, either or both:
- A throttle is a token bucket that refills at
ratePerSecondand holds at mostbursttokens, so short bursts pass and sustained rates are capped. A refused call learns how long until enough tokens are back. - Period limits allow at most one limit each per
minute,hour,day,week, andmonth. Windows are fixed: minutes and hours are UTC; days, weeks (Monday to Sunday), and months start at local midnight in the plan'stimeZone, daylight saving time included. A refused call learns when the window ends.
iam.api.quotas.createPlan (iam:quotas:manage) takes:
Prop
Type
A plan needs a throttle, limits, or both; anything else is INVALID_INPUT. A second plan with the same name, or a
second default plan for a meter, is a CONFLICT.
scope: 'tenant' suits an organization's pooled allowance: one monthly total for everyone the plan covers, whoever
spends it.
Count use in your handlers
Three calls on iam.quotas work in process for the request's , which must be a
session of the tenant (ACCESS_DENIED otherwise):
iam.quotas.enforce({ token | headers, tenantId, meter, cost? })counts use and throwsQUOTA_EXCEEDEDwhen the plan does not allow it.iam.quotas.consume(...)takes the same input and returns the decision, withallowed: falsewhen refused.iam.quotas.status({ token | headers, tenantId, meter })reports what is left without counting, for a "usage this month" display.
cost (a whole number from 1 to 1,000,000,000; 1 by default) is for calls that count more than one unit: a batch of 50
messages, a large export. A cost above a plan's burst or above a period limit can never succeed, so such refusals
carry no retryAfterMs.
Every call returns a decision:
Prop
Type
status never refuses: it answers allowed: true and reports remaining for each window and the bucket's current
tokens, so read those rather than allowed.
Answer refusals
enforce throws QuotaExceededError (exported by better-iam/server), an IAM error with code QUOTA_EXCEEDED, status
429, retryAfterMs, and the full decision. The error handlers of the Express, Hono, and Fastify adapters
(Node frameworks) answer it like any other refusal, with a
429 and the JSON error envelope, but without a Retry-After header. When clients should back off precisely, answer
the refusal yourself:
export async function GET(request: Request) {
const decision = await iam.quotas.consume({ headers: request.headers, tenantId, meter: 'requests' });
if (!decision.allowed) {
const headers = new Headers();
if (decision.retryAfterMs !== undefined)
headers.set('retry-after', String(Math.ceil(decision.retryAfterMs / 1000)));
return Response.json(
{ error: { code: 'QUOTA_EXCEEDED', message: `The ${decision.meter} quota is used up (${decision.reason})` } },
{ status: 429, headers },
);
}
return Response.json(await search(request));
}Who gets which plan
Plans are assigned to API keys (subjectType: 'apiKey', with the key's credentialId), identities
(subjectType: 'identity': people, , and agents), and groups
(subjectType: 'group'). For each meter the most specific plan applies:
- a plan assigned to the API key the request presents;
- a plan assigned to the agent acting in a delegated session (assigned as an identity, with the agent's ID);
- a plan assigned to the identity;
- a plan assigned to one of the identity's groups (the highest
prioritywins, then the name); - the tenant's
defaultplan for the meter.
A meter no plan covers is unlimited: consume answers allowed: true with plan: null.
await iam.api.quotas.assign(admin, { tenantId, plan: 'partner', subjectType: 'apiKey', subjectId: key.credentialId });
await iam.api.quotas.assign(admin, { tenantId, plan: 'internal', subjectType: 'group', subjectId: staffGroupId });Assigning replaces the subject's plan for the same meter; unassign({ tenantId, meter, subjectType, subjectId })
removes it. An unknown plan, a subject outside the tenant, a deleted identity, or an ID that is not an API key is
NOT_FOUND.
With scope: 'subject', whose counters a call uses follows how the plan was found. A plan assigned to an API key counts
per key and one assigned to an agent per agent. Plans found through the identity, a group, or the default count per
identity, so every member of a group gets an allowance of their own, and a person's keys without a plan of their own
share the person's counters.
Other callers
- Over HTTP.
quotas.consumecounts the caller's own use, for example an API gateway forwarding its client's credential.quotas.statusreads what is left. Both are also on the typed client and need only a session of the tenant. - Work your code attributes itself.
iam.quotas.consumeFor({ tenantId, identityId, apiKeyId?, meter, cost? })counts for a subject your code identified, such as a background export. It takes no credential;apiKeyId, when given, must be one of that identity's API keys. Its alert events are recorded with the actordeployment-operator.
Manage plans
| Method | Permission | Audited as |
|---|---|---|
createPlan, updatePlan, deletePlan | iam:quotas:manage on iam/quotas/{plan} | quota:plan-create, quota:plan-update, quota:plan-delete |
assign | iam:quotas:manage on iam/quotas/{plan} | quota:assign |
unassign | iam:quotas:manage on iam/quotas | quota:unassign |
listPlans, listAssignments | iam:quotas:read on iam/quotas | iam:quotas:read |
getPlan, usage | iam:quotas:read on iam/quotas/{plan} | iam:quotas:read |
reset | iam:quotas:manage on iam/quotas/{plan} | quota:reset |
updatePlankeeps the fields you leave out;throttle: nullanddescription: nullclear them, andlimitsreplaces the whole list. The meter cannot change, and windows in progress keep their counts under the new limits.usagelists every subject's use of a plan in the current windows, the most used first. Subjects are namedidentity:{id},key:{credentialId}, ortenantfor a tenant-scoped plan.resetstarts onesubject's counters and throttle over (named asusagenames it), or everyone's when you leavesubjectout.- Deleting a plan removes its assignments and counters.
Alerts
alertThresholds record quota:threshold once per window, when use first reaches that share of a period limit. Each
window's first refusal records quota:exceeded with outcome deny. Both carry the plan, meter, period, limit, and
subject (quota:threshold also the threshold and the use). Throttle refusals are not recorded.
Subscribe a webhook to quota:* to email a customer at 80% of their monthly allowance,
or to page someone when an integration starts getting refused.
Storage and cleanup
Counters are stored in the database, so every server instance sees the same totals and the same token bucket. Each
consumption is one short transaction. A window's counter is kept until a day after the window ends, and a throttle
bucket until it would have refilled; the , iam.sweepExpired(),
deletes them after that (scheduled jobs).
Quotas limit how much a caller may use right now; they record no billing usage. To charge for the same calls, record
them with iam.billing.record as well, and use billing budgets to cap money rather
than calls.
Next steps
Better IAM is created by Sean Filimon
Last updated
Billing and spendnew
Usage meters, rate cards, and spend by person, team, department, project and organization, with budgets, plans and subscriptions, and Stripe-style invoicing.
Secrets and keysnew
Tenant keys, a secrets vault, tokenization of sensitive data, and a private certificate authority, all decided by the same policies and audit trail.