> ## Documentation index
> Fetch the complete documentation index at: https://rolter-ai-rolter.docs7.io/llms.txt
> Ask it with GET https://rolter-ai-rolter.docs7.io/ask?q=<keywords or question>. Short keywords rank best.
> Use these to discover all available pages before exploring further.

# Managing Budgets and Rate Limits over the API

> List, create, edit in place and delete spend budgets and rate limits through the control plane. PATCH changes a cap without a window where the scope has none.

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](/security/budgets-and-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:

```json
{
  "id": "2f1c9a4e-7b3d-4e8a-9c21-5d6f0a1b2c3d",
  "scope_type": "project",
  "scope_id": "6d0f1f9c-6f0e-4d1a-9d3f-2a5d8f3a1b22",
  "limit_usd": "500.0000",
  "period": "30d",
  "unpriced_policy": null,
  "created_at": "2026-09-01T09:03:11.482Z"
}
```

| 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](#periods) for the values the gateway reads                                                                                        |
| `unpriced_policy` | string or null   | this budget's own `ignore`, `warn` or `block` answer to [unpriced traffic](/security/budgets-and-rate-limits#unpriced-traffic); `null` inherits the deployment setting |

### Create

```bash
curl -s -X POST https://control.example.com/api/v1/budgets \
  -H "Authorization: Bearer $ROLTER_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"scope_type": "project", "scope_id": "'"$PROJECT_ID"'", "limit_usd": "500", "period": "30d"}'
```

`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_usd` has to be a number from 0 to 99999999.9999, the most the `numeric(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.
- `period` has to be one of the values in [Periods](#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.

```bash
curl -s -X PATCH https://control.example.com/api/v1/budgets/$BUDGET_ID \
  -H "Authorization: Bearer $ROLTER_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"limit_usd": "750", "unpriced_policy": "block"}'
```

The response is the updated row. A few rules:

- `unpriced_policy: null` drops the override, so the budget inherits the deployment setting again. Leaving the field out does not touch it.
- `limit_usd` has to be a number from 0 to 99999999.9999, the most the `numeric(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.
- `period` has to be one of the values in [Periods](#periods).
- The scope is not editable. A budget on a different scope is a different budget, so sending `scope_type` or `scope_id` is a `400`.
- 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 a `500.0000` budget.

<Note>
  **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.

</Note>

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.

```json
{
  "status": "ok",
  "budgets": [
    {
      "id": "2f1c9a4e-7b3d-4e8a-9c21-5d6f0a1b2c3d",
      "spent_usd": "212.5000",
      "resets_at": "2026-08-01T00:00:00Z"
    }
  ]
}
```

`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

```json
{
  "id": "8f2a41c7-9b3d-4c55-bb1e-71a0c2e4d9f3",
  "scope_type": "virtual_key",
  "scope_id": "c41d7e02-3a8b-4f19-8f2c-5d9e1a7b3c04",
  "rpm": 600,
  "tpm": null,
  "created_at": "2026-09-01T09:04:02.117Z"
}
```

`rpm` caps requests per minute and `tpm` tokens per minute. `null` means that dimension is not capped.

### Create

```bash
curl -s -X POST https://control.example.com/api/v1/rate-limits \
  -H "Authorization: Bearer $ROLTER_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"scope_type": "virtual_key", "scope_id": "'"$KEY_ID"'", "rpm": 600}'
```

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         |

```bash
# raise the token cap and stop capping requests
curl -s -X PATCH https://control.example.com/api/v1/rate-limits/$LIMIT_ID \
  -H "Authorization: Bearer $ROLTER_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tpm": 200000, "rpm": null}'
```

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:

```json
{
  "scope_type": "project",
  "scope_id": "6d0f1f9c-6f0e-4d1a-9d3f-2a5d8f3a1b22",
  "changes": {
    "limit_usd": { "from": "500.0000", "to": "750.0000" },
    "unpriced_policy": { "from": null, "to": "block" }
  }
}
```

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.
