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#
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:
Authentication#
Every operation except the probes, the schema endpoints, the login and SSO routes and the invitation-acceptance links takes a 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#
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.
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:
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. |
last_superadmin | 409 | Demoting, deactivating or deleting the last active superadmin. See 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. |
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. |
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. |
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.