Budgets and rate limits stored in the database are managed through the control plane. Each one hangs off a scope: an org, team, project, virtual key, business unit, customer or user, named by scope_type and scope_id. What the caps do once they reach the gateway is covered in Budgets & Rate Limits; this page is the API.
| Method | Path | What it does |
|---|---|---|
GET | /api/v1/budgets?scope_type=…&scope_id=… | list the budgets on one scope |
GET | /api/v1/budgets/spend?scope_type=…&scope_id=… | spend so far against each of those budgets |
GET | /api/v1/orgs/{org_id}/budgets | list the budgets across one organization |
GET | /api/v1/orgs/{org_id}/budgets/spend | spend so far against all of those budgets |
POST | /api/v1/budgets | create a budget |
PATCH | /api/v1/budgets/{id} | change a budget in place |
DELETE | /api/v1/budgets/{id} | delete a budget |
GET | /api/v1/rate-limits?scope_type=…&scope_id=… | list the rate limits on one scope |
GET | /api/v1/orgs/{org_id}/rate-limits | list the rate limits across one organization |
POST | /api/v1/rate-limits | create a rate limit |
PATCH | /api/v1/rate-limits/{id} | change a rate limit in place |
DELETE | /api/v1/rate-limits/{id} | delete a rate limit |
scope_type is one of org, team, project, virtual_key, business_unit, customer or user. Both list routes require both query parameters and answer 400 without them.
The organization-wide lists answer “which caps exist here?” in one request. They return every budget (or rate limit) set at the organization or at anything inside it: its teams, projects and virtual keys, and its business units and customers. Each row carries its own scope_type and scope_id. The rows are the ones the per-scope read would have returned: a caller who holds a role only on one project gets that project’s caps, its team’s and the organization’s, and nothing from a sibling project. A caller with no role anywhere in the organization gets 403; one with a role below it gets the rows they may read, which can be none. The dashboard’s Budgets & Limits screen reads these when no single scope is picked.
Budgets#
A budget row looks like this:
| Field | Type | Meaning |
|---|---|---|
limit_usd | decimal, as text | the cap for one window, in the deployment’s base currency despite the name. Stored as numeric(12,4), so it comes back with four decimal places |
period | string | the window, as stored. See Periods for the values the gateway reads |
unpriced_policy | string or null | this budget’s own ignore, warn or block answer to unpriced traffic; null inherits the deployment setting |
Create#
period defaults to 30d and unpriced_policy to null when omitted. A create is checked the same way as an edit, and a refused body is a 400 whose message names the field:
limit_usdhas to be a number from 0 to 99999999.9999, the most thenumeric(12,4)column holds.NaN, infinities and negative numbers are refused: the gateway would read the first as no cap at all and a negative one as a cap already spent.periodhas to be one of the values in Periods.
Periods#
The gateway resets a budget on a calendar window in UTC. There are no rolling windows, so a 7d or 1w budget does not exist.
| Window | Accepted values | Resets |
|---|---|---|
| daily | daily, 1d, 24h | every day at 00:00 UTC |
| monthly | monthly, 30d | on the 1st of each month at 00:00 UTC |
| total | total, lifetime, all | never, so the cap is a lifetime one |
The values are case-insensitive, and the row keeps the spelling you sent. 30d is the calendar month, not the last thirty days; it is the default because budgets have always been stored with it. The dashboard offers the three windows by name and sends daily, monthly or total.
Anything else is refused with a 400 that lists the accepted values. A budget stored by an older release may still hold another value, such as the 7d the dashboard used to suggest. The gateway enforces such a budget as monthly, and nothing rewrites the row for you: GET /api/v1/config/problems lists each one by id and scope, and the dashboard marks its card. Edit the budget and pick a period to clear it.
Edit in place#
PATCH /api/v1/budgets/{id} changes limit_usd, period and unpriced_policy on the existing row. Send only the fields you want to change; a field you leave out keeps its value.
The response is the updated row. A few rules:
unpriced_policy: nulldrops the override, so the budget inherits the deployment setting again. Leaving the field out does not touch it.limit_usdhas to be a number from 0 to 99999999.9999, the most thenumeric(12,4)column holds. Zero is allowed and freezes the scope, because no request is under a cap of zero. More decimal places than four are rounded, so a value that rounds up to 100000000 is refused too.periodhas to be one of the values in Periods.- The scope is not editable. A budget on a different scope is a different budget, so sending
scope_typeorscope_idis a400. - A body that changes nothing returns the row as it is and writes nothing: no config version bump and no audit row. That covers an empty body
{}and one that re-sends the values the row already holds, such as"limit_usd": "500.0"on a500.0000budget.
Why edit rather than delete and recreate. A delete followed by a create leaves the scope with no budget between the two calls, and each call bumps the config version, so a gateway polling in that gap loads the scope uncapped. A PATCH is a single write and a single version bump: a gateway sees the old cap or the new one, never neither.
The row keeps its id and created_at. Spend counters in Redis are keyed by scope and period window rather than by budget row, so raising or lowering limit_usd keeps the spend already counted this period. Changing period moves the budget onto that window’s counter.
The organization-wide lists name a key’s cap: a row whose scope_type is virtual_key carries scope_name, the key’s name (its prefix when it has none), and it is null on every other scope. Keys have no list of their own across an organization, so without it a key’s cap could only be told apart by the start of its id.
Spend so far#
GET /api/v1/budgets/spend?scope_type=…&scope_id=… returns, for each budget on the scope, what the gateway has counted against it in the current window. It reads the same Redis counter the gateway checks on every request, so the figure is the one a 402 is decided on. It needs the same permission as listing the budgets.
GET /api/v1/orgs/{org_id}/budgets/spend is the same read for every budget in the organization the caller may read (exactly the rows of the organization-wide list), in one request and one Redis round trip, so a screen that lists many caps does not issue one spend read per scope. It answers with the same body and the same status and null rules.
resets_at is the start of the next window in UTC, and null for a budget that never resets. An absent counter is a window nothing has been spent in, so it reads "0.0000".
status says whether the figures can be trusted. ok means the counters were read. not_configured means the control plane has no ROLTER_REDIS_URL, and unavailable means Redis did not answer within two seconds. In both of those spent_usd is null for every budget, never zero: a budget that cannot be read is unknown, not untouched. The control plane must point at the same Redis as the gateways, or it reads counters nobody writes.
Caps on a person#
scope_type: "user" with the person’s id caps everything they minted, across every key. A person belongs to no org, so the cap is managed through the place it is narrowed to: send within_org_id or within_project_id (not both) on create, and the caller needs the same role there as for any other cap at that place. Without either, the cap counts the person’s keys everywhere and only a superadmin can create, edit, read or delete it. Both fields are on the returned row, GET /api/v1/budgets?scope_type=user&scope_id=… takes them as query parameters to read one narrowing, and the organization-wide lists include the user caps narrowed to that org or to one of its projects. An unknown user id is a 404, and within_* on any other scope is a 400.
GET /api/v1/me/budgets returns the calling account’s own user budgets and rate limits with the spend so far, using the same status and spent_usd rules as /api/v1/budgets/spend. It takes the account from the session, so there is no parameter to ask for someone else’s, and any signed-in role may call it.
Minting a key with a cap#
A key’s cap is an ordinary budget or rate limit whose scope is the key. You do not have to create the key and then make a second call: POST /api/v1/projects/{project_id}/virtual-keys and POST /api/v1/me/projects/{project_id}/virtual-keys accept a budget ({"limit_usd": "50", "period": "monthly"}) and a rate_limit ({"rpm": 30, "tpm": 20000}) alongside the name and expiry. They are validated by the rules on this page before the key exists and written in the same transaction as the key, so a refused cap leaves no key behind. Rotating the key copies them onto the new key, and deleting the key deletes them.
Rate limits#
rpm caps requests per minute and tpm tokens per minute. null means that dimension is not capped.
Create#
At least one of rpm and tpm has to be set, and a cap you set has to be at least 1. The gateway reads a cap of zero or below as “not capped”, so {"rpm": 0} would store a limit that looks like a hard stop and admits everything; it is refused with a 400 naming the field. A body with neither cap is refused for the same reason. Leave a field out, or send null, to leave that dimension uncapped.
Edit in place#
PATCH /api/v1/rate-limits/{id} works the same way as for budgets, with one difference: each cap can be lifted.
| You send | Effect |
|---|---|
| field omitted | the cap is left unchanged |
a number, ≥ 1 | the cap is set to it |
null | the cap is lifted |
The edit is refused with 400 when it would leave the rate limit with no cap at all, because such a limit admits every request. Delete it instead. The check runs against the row as it stands when the edit is applied, so two edits sent at once that each lift a different cap cannot together leave none: whichever lands second is refused. A cap of zero or below is also refused: the gateway reads it as “not capped”, so storing one would look like a limit and do nothing.
Authorization#
Each action takes the matching capability on the budget or rate_limit resource, checked at the scope the row lives on:
| Action | Minimum role on the scope |
|---|---|
| list | viewer |
| create, edit, delete | admin |
GET /api/v1/rbac/matrix publishes these rows, and GET /api/v1/rbac/effective reports whether the calling account holds them at a given scope. A caller without the capability is refused with 403; an unknown id is 404.
Audit log#
Every write leaves one row in the org’s audit log:
| Action | detail |
|---|---|
budget.create, rate_limit.create | the scope and the values the row was created with |
budget.update, rate_limit.update | the scope and a changes object, one entry per field that moved |
budget.delete, rate_limit.delete | empty |
A changes entry carries the value before and after:
A field sent with the value it already had does not appear in changes, and an edit where every field sent already had its value writes no audit row at all. The from values are read under the edit’s own row lock, so two edits of the same row in quick succession each record what they replaced.
Propagation#
Every create, delete and edit that changes something bumps the config version in the same transaction, so a gateway polling /internal/snapshot picks the change up on its next poll. With Redis configured, the control plane also announces the new version, and subscribed gateways refetch straight away.