> ## 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.

# Control-Plane OpenAPI Schema and API Reference

> The rolter control plane serves an OpenAPI 3.1 description of its management API at GET /openapi.json and renders it interactively at GET /docs — generate a client instead of hand-writing one.

The rolter control plane describes itself. `GET /openapi.json` returns an
OpenAPI 3.1 document covering its whole management API — tenancy, users and
RBAC, providers, routes, virtual keys, budgets and prices, guardrails, MCP,
alerting, SSO, SCIM and the deployment settings the gateway reads back through
`/internal/snapshot`. `GET /docs` renders the same document as an interactive
reference you can read and try calls from in a browser.

This is the same pair the gateway serves for its OpenAI/Anthropic-compatible
surface, so a deployment has two schemas: one for the traffic your applications
send, and one for the API your tooling manages the deployment with.

## Fetching the schema

```bash
curl -s http://<your-control-plane-host>:4001/openapi.json > rolter-control.json
```

Both endpoints are unauthenticated. The document names endpoints and payload
shapes and carries no tenant data, and a client that cannot read the schema
before it holds a credential cannot generate against it either.

The `info.version` field tracks the running binary's version, so a schema
fetched from a live control plane always matches what that process serves.

## Browsing it

Open `http://<your-control-plane-host>:4001/docs`. The reference is rendered by
Scalar from a JavaScript bundle embedded in the rolter binary — it makes no
requests to a CDN, loads no remote fonts, and works in an air-gapped
deployment, exactly like the gateway's `/docs`.

Operations are grouped by tag: `tenancy`, `users`, `access-control`,
`providers`, `routes`, `virtual-keys`, `governance`, `guardrails`, `mcp`,
`alerting`, `analytics`, `settings`, `sso`, `scim` and the rest.

## Generating a client

Any OpenAPI generator works. For example, with
[`openapi-generator`](https://openapi-generator.tech/):

```bash
openapi-generator generate \
  -i http://localhost:4001/openapi.json \
  -g python \
  -o ./rolter-control-client
```

## Authentication

Every operation except the probes, the schema endpoints, the login and SSO
routes and the invitation-acceptance links takes a bearer token:

```http
Authorization: Bearer <token>
```

The token is a session token from `POST /api/v1/auth/login`, or the deployment
admin token. `/scim/v2/*` takes a SCIM token instead, and `/internal/*` takes
the internal token — the document declares one `bearerAuth` scheme covering all
of them, because they occupy the same header.

Authorization is enforced per handler against the RBAC capability matrix, which
`GET /api/v1/rbac/matrix` publishes: holding a valid token is not the same as
being allowed to make a given call.

## What the schema does and does not promise

<Note>
  Every endpoint the control plane mounts is in the document. A test walks every
  route registration in the control-plane crate and fails the build when one is
  missing, so the path list cannot quietly fall behind the code.
</Note>

Request and response **bodies** are typed where the underlying handler has a
stable shape — organizations, teams, projects, business units and customers,
users and memberships, providers and provider groups, routes and route targets,
virtual keys, budgets, rate limits and model prices all resolve to named
entries under `components/schemas`.

Endpoints whose payload is genuinely dynamic — the settings singletons,
guardrail configurations, SCIM envelopes and analytics result rows — are
described as open JSON objects for now. The call, its path parameters and its
error response are still fully described; only the body's inner shape is left
loose. Treat a generated model for those endpoints as a starting point rather
than a contract.

Errors use one envelope. `message` is always there; `code` is added when the
refusal has a stable name a client can branch on (`name_taken`, `invalid_field`,
`invalid_body`, `store_timeout`, ...), and `field` names the field an
`invalid_field` refusal is about. A refusal with no stable code, such as a `401`,
`403` or `404`, carries the message alone:

```json
{ "error": { "message": "name must be 1-256 visible characters", "code": "invalid_field", "field": "name" } }
```

A write the current state does not allow answers `409` with a message that says
what to change first. Deleting a provider that a route target or a provider
group member still references is one: `DELETE /api/v1/providers/{id}` is
refused, the provider is left in place, and the message names each route and
group that uses it, as in
`provider 'openai' is used by route 'gpt-4o', provider group 'llama-fleet'; remove it from them before deleting it`.
Operations that can refuse this way list the `409` in the schema.

### Stable error codes

`message` is English text for the person reading it and may change between
releases. `code` is part of the API and is never renamed, so branch on it — the
dashboard uses it to show the refusal in the reader's language. The status code
of each refusal is listed below.

Credential-sealing requests without a configured `ROLTER_KEK` return `503` and
`error.code: "kek_unavailable"`, not a field-validation `400`. Configure the
existing deployment key on the control plane and restart it before retrying;
do not generate a replacement key for a store that already contains sealed
secrets. This applies to provider keys, MCP credentials, SSO client-secret
writes, connector/channel credentials and second-factor enrolment. Existing
sign-in and policy lockout refusals keep their own contracts.

| Code                               | Status | When                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| ---------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `name_taken`                       | `409`  | Another record already holds the name, slug, email or key: a provider, group or route name taken in the deployment, a user email, a label key on the same subject, a saved view name. Any other write that hits a uniqueness rule (two organizations with one slug, or a write that lost a race with another one) answers the same way, with a generic message.                                                                                                                                        |
| `invalid_field`                    | `400`  | One field of the request is refused: empty, not numeric, a malformed slug or email, an unknown role, control characters, or a `project_id` / `customer_id` outside the organization. The body also carries `field`, the path of the field (`slug`, `metadata.team`, `tags[2]`). Settings endpoints name the offending row of a list or map the same way (`injected_headers.x-team`, `forwarded_headers[2]`, `allowed_origins[1]`), so a client can mark that row instead of matching the message text. |
| `invalid_body`                     | `400`  | The request body is not the JSON the endpoint takes: malformed JSON, a missing required field, or a value of the wrong type. It names no `field`, since the parser cannot place the fault reliably; read the `message`.                                                                                                                                                                                                                                                                                |
| `invalid_query`                    | `400`  | The query string does not deserialize into the parameters the list takes: a non-numeric `limit`, a malformed id. It names no `field`; read the `message`. A cursor or filter value that parses but is refused is `invalid_field` naming it.                                                                                                                                                                                                                                                            |
| `referenced`                       | `409`  | A delete refused while other records still depend on the one being removed, such as a project or team that still owns a project-scoped provider or provider group. Move or delete those first.                                                                                                                                                                                                                                                                                                         |
| `scope_mismatch`                   | `409`  | A route or provider group would use a provider scoped to a different project, or an org-wide provider would be scoped to one project while routes of other projects use it. Use org-wide providers, or ones scoped to the same project.                                                                                                                                                                                                                                                                |
| `last_org_admin`                   | `409`  | Revoking the organization's last admin grant. See [RBAC](/security/rbac).                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `last_superadmin`                  | `409`  | Demoting, deactivating or deleting the last active superadmin. See [RBAC](/security/rbac).                                                                                                                                                                                                                                                                                                                                                                                                             |
| `authorization_server_unavailable` | `502`  | An MCP authorization server could not be reached or did not answer while refreshing, exchanging or redeeming a session. The message is fixed and never names the token endpoint; retry later. See [Sessions renew themselves](/security/mcp-oauth#sessions-renew-themselves).                                                                                                                                                                                                                          |
| `kek_unavailable`                  | `503`  | A request that seals a credential (provider keys, MCP credentials, SSO client secrets, connector and alert-channel credentials, second-factor enrolment) reached a control plane with no `ROLTER_KEK`. See the paragraph above.                                                                                                                                                                                                                                                                        |
| `invalid_credentials`              | `401`  | Sign-in with an email or password that does not match.                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `unauthenticated`                  | `401`  | The request carries no session, or one that is invalid or expired.                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `open_mode_no_session`             | `401`  | A per-user endpoint under `/api/v1/me/` was called on a control plane running in open mode (no `ROLTER_ADMIN_TOKEN`), which has no account to act as.                                                                                                                                                                                                                                                                                                                                                  |
| `password_login_disabled`          | `403`  | The organization signs in through SSO only, so a password sign-in is refused.                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `mfa_enrolment_required`           | `403`  | The organization requires a second factor, the account has none, and this control plane cannot enrol one because `ROLTER_KEK` is not set.                                                                                                                                                                                                                                                                                                                                                              |
| `too_many_attempts`                | `429`  | Too many failed sign-ins for the account. The `Retry-After` header says how many seconds to wait.                                                                                                                                                                                                                                                                                                                                                                                                      |
| `no_local_password`                | `409`  | A password change on an account that has no local password because it signs in through single sign-on only.                                                                                                                                                                                                                                                                                                                                                                                            |
| `invalid_exchange_code`            | `400`  | The SSO sign-in code is unknown, already used or expired; sign in again.                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `invalid_time_bound`               | `400`  | A `since` or `until` query value is not a time the endpoint accepts. The body names the parameter in `field`.                                                                                                                                                                                                                                                                                                                                                                                          |
| `invalid_cursor`                   | `400`  | A pagination `cursor` that is malformed or carries no readable timestamp.                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `no_such_endpoint`                 | `404`  | The path is not mounted on this control plane, for example a store-backed endpoint on a deployment without a database.                                                                                                                                                                                                                                                                                                                                                                                 |
| `request_timeout`                  | `504`  | The request ran past the control plane's request time limit. Streams, such as the playground proxy, are not bounded.                                                                                                                                                                                                                                                                                                                                                                                   |
| `store_timeout`                    | `504`  | The database gave up on a statement or on a lock it was waiting for. It may be waiting on another change to the same record; try again.                                                                                                                                                                                                                                                                                                                                                                |
| `snapshot_format_unsupported`      | `406`  | `GET /internal/snapshot` serves no snapshot format the caller states in `formats` (absent means format 1), also when its `version` is current. `served` and `requested` give both ranges. See [Upgrading](/deployment/upgrading#upgrade-order).                                                                                                                                                                                                                                                        |
| `snapshot_format_inexpressible`    | `406`  | `GET /internal/snapshot` could serve the caller's format, but the config holds a deployment-wide setting that format cannot carry. Upgrade the gateway, or stop using the setting.                                                                                                                                                                                                                                                                                                                     |
| `internal`                         | `500`  | An unexpected server error. The message is fixed and never carries internal detail.                                                                                                                                                                                                                                                                                                                                                                                                                    |

```json
{
  "error": {
    "message": "slug must match ^[a-z0-9][a-z0-9-]{0,62}$ (…)",
    "code": "invalid_field",
    "field": "slug"
  }
}
```

A few validation refusals still carry no `code` yet; treat a `400` or `409`
without one by its status and `message`.

## Analytics read errors

The reads that query ClickHouse — `/api/v1/analytics/*`, `/api/v1/health/*`,
`/api/v1/mcp/logs*` and `/api/v1/me/usage` — answer two distinct errors when the
query does not come back, so a client can tell a slow query from a broken
database:

| Status | `error.code`              | Meaning                                                                                   |
| ------ | ------------------------- | ----------------------------------------------------------------------------------------- |
| `502`  | `analytics_query_failed`  | ClickHouse refused, errored or was unreachable. The message never carries driver detail.  |
| `504`  | `analytics_query_timeout` | The query ran past the control plane's 15s request timeout. Retry with a narrower window. |

An unconfigured ClickHouse is still a `503`.
