Skip to main content

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#

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.

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.

CodeStatusWhen
name_taken409Another 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_field400One 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_body400The 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_query400The 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.
referenced409A 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_mismatch409A 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_admin409Revoking the organization’s last admin grant. See RBAC.
last_superadmin409Demoting, deactivating or deleting the last active superadmin. See RBAC.
authorization_server_unavailable502An 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_unavailable503A 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_credentials401Sign-in with an email or password that does not match.
unauthenticated401The request carries no session, or one that is invalid or expired.
open_mode_no_session401A 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_disabled403The organization signs in through SSO only, so a password sign-in is refused.
mfa_enrolment_required403The organization requires a second factor, the account has none, and this control plane cannot enrol one because ROLTER_KEK is not set.
too_many_attempts429Too many failed sign-ins for the account. The Retry-After header says how many seconds to wait.
no_local_password409A password change on an account that has no local password because it signs in through single sign-on only.
invalid_exchange_code400The SSO sign-in code is unknown, already used or expired; sign in again.
invalid_time_bound400A since or until query value is not a time the endpoint accepts. The body names the parameter in field.
invalid_cursor400A pagination cursor that is malformed or carries no readable timestamp.
no_such_endpoint404The path is not mounted on this control plane, for example a store-backed endpoint on a deployment without a database.
request_timeout504The request ran past the control plane’s request time limit. Streams, such as the playground proxy, are not bounded.
store_timeout504The 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_unsupported406GET /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_inexpressible406GET /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.
internal500An 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:

Statuserror.codeMeaning
502analytics_query_failedClickHouse refused, errored or was unreachable. The message never carries driver detail.
504analytics_query_timeoutThe query ran past the control plane’s 15s request timeout. Retry with a narrower window.

An unconfigured ClickHouse is still a 503.