--- title: "Control plane API" description: "Base URL, operator authentication, workspace scoping, pagination, and error shapes for the Obol control plane HTTP API." --- The control plane is a FastAPI service that owns tenants, virtual keys, policy, approvals, connections, and audit. Everything a human or a dashboard does to configure Obol happens here. It is a different surface from the gateway: the gateway serves `/v1/*` and `/mcp` to agents, and the control plane serves `/api/v1/*` to operators. This reference is written from the FastAPI route definitions and the frozen contracts in `packages/proto`. There is no committed OpenAPI document for the control plane, so every endpoint below is documented by hand. ## Base URL Operator routes are mounted under `/api/v1`. On the hosted platform the control plane API is `https://control.tryobol.dev`. Examples use the production Control origin. Hosted API access is available after your workspace is enabled; the service is still being commissioned. ```bash Base URL export OBOL_CONTROL_URL=https://control.tryobol.dev curl "$OBOL_CONTROL_URL/api/v1/me" -H "Authorization: Bearer $OBOL_SESSION_TOKEN" ``` An [Enterprise self-host](/get-started/self-hosting) deployment substitutes its own host: the control plane listens on port 8000 and publishes itself at `OBOL_PUBLIC_URL`. Two routes sit outside `/api/v1`: | Path | Purpose | |---|---| | `GET /healthz` | Liveness. No authentication. | | `/internal/*` | Gateway-to-control service traffic, authenticated by a service JWT. Not an operator surface. | Every response under `/api/v1` carries `Cache-Control: no-store`. Some operator projections embed single-use federated handoff handles, and applying the header to the whole prefix means the next projection that carries something secret-adjacent gets it too. `/healthz` and `/internal` are deliberately untouched. ## Authentication Three authentications exist in Obol and none of them substitutes for another. | Credential | Authenticates | Used at | |---|---|---| | Operator session token | A human in a browser | `/api/v1/*` on control | | Virtual key (`ob_live_…`, `ob_test_…`) | An agent | `/v1/*` and `/mcp` on the gateway | | Service JWT | Gateway to control, control to gateway | `/internal/*` | An operator session token is **never** an `ob_live_…` key, and a virtual key is never accepted at `/api/v1`. See [Virtual keys](/security/virtual-keys) for the agent-facing credential. ### Operator sessions Send the session token as a bearer token: ```bash Operator request curl "$OBOL_CONTROL_URL/api/v1/workspaces" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" ``` Control verifies the token itself. It fetches the issuer's published JWKS from `{CLERK_ISSUER}/.well-known/jwks.json`, caches the keys for `CLERK_JWKS_TTL_S` seconds (default 600), and validates: - `RS256` signature against the key named by the token's `kid`. - `iss` equal to the configured `CLERK_ISSUER`. - `exp`, `iss`, and `sub` present, with 30 seconds of clock leeway. - `azp` in `CLERK_AUTHORIZED_PARTIES` when that setting is non-empty. Pinning the authorized party stops a token minted for another app on the same identity-provider instance from being replayed at control. Verification needs no provider secret, so no identity-provider API key is read on this path. The seam is OIDC-shaped: replacing the provider is a configuration change and a different issuer, not a redesign. The verified `sub` is mapped onto control's own `User` row, created on first sign-in. `Operator.user_id` is control's id (`usr_…`), never the identity provider's subject. A subject, email, or workspace claimed by the browser is input, never authority. With no issuer configured, operator routes answer `503` rather than serving unauthenticated traffic. A control plane that silently opens because an environment variable was unset is the worst available outcome, so this fails closed. | Condition | Status | Body | |---|---|---| | No `Authorization` header, or a non-bearer scheme | `401` | `{"detail": "session required"}` | | Token fails any verification check | `401` | `{"detail": "session not accepted"}` | | `CLERK_ISSUER` unset | `503` | `{"detail": "dashboard identity is not configured"}` | The `401` message is single-valued on purpose: a caller learns the session was not accepted, never which check rejected it. ## Workspace scoping `workspace_id` is the tenant boundary. It is on every Postgres row and every Redis key, and it is in the path of every scoped operator route: `/api/v1/workspaces/{workspace_id}/…`. Authorization runs in two steps on every scoped route. Control joins its own `Membership` rows to the workspace. A workspace the operator is not a member of answers `404 {"detail": "workspace not found"}` — the same answer as a workspace that does not exist. A distinguishable "exists but forbidden" would turn the endpoint into an oracle for workspace ids across tenants. The membership's role must grant the action the route performs. A role that does not answers `403 {"detail": "action not permitted"}`. ### Roles and actions Roles are control-owned, stored on the membership row, and independent of Cedar. Cedar authorizes agents at the gateway; these roles authorize humans at control. | Action | viewer | developer | admin | owner | |---|---|---|---|---| | `workspace.read` | Yes | Yes | Yes | Yes | | `audit.read` | Yes | Yes | Yes | Yes | | `usage.read` | Yes | Yes | Yes | Yes | | `key.manage` | — | Yes | Yes | Yes | | `policy.draft` | — | Yes | Yes | Yes | | `connection.manage` | — | — | Yes | Yes | | `policy.publish` | — | — | Yes | Yes | | `routing.publish` | — | — | Yes | Yes | | `pricing.write` | — | — | Yes | Yes | | `approval.decide` | — | — | Yes | Yes | | `membership.manage` | — | — | — | Yes | | `approval.policy.manage` | — | — | — | Yes | ### Frozen workspaces A frozen workspace still serves reads. Any action outside `workspace.read`, `audit.read`, and `usage.read` answers `409 {"detail": "workspace is frozen"}`, including routes that would not have written anything. ### Finding your workspaces `GET /api/v1/me` returns the signed-in operator, every workspace they are a member of with their role in each, and a `default_workspace_id`. It provisions a workspace on first sign-in so the dashboard has somewhere to land, and it is idempotent. It is the only endpoint that writes as a side effect of a read. See [Operator](/api-reference/operator) for its full response, and [Tenancy and identity](/concepts/tenancy-and-identity) for the model behind it. ## Pagination Listing endpoints use keyset pagination with opaque cursors, ordered newest first. Pass the `next_cursor` from a response back as `cursor` to fetch the next page. A `null` or absent `next_cursor` means the listing is exhausted. Page size. Bounds are per-endpoint; see the endpoint's own documentation. Opaque keyset cursor from a previous response. Never construct one by hand — the encoding is not part of the contract, and a malformed cursor is rejected rather than interpreted. Cursors are base64url-encoded JSON, unpadded, matching `[A-Za-z0-9_-]+`. The shared implementation in `app/services/pagination.py` caps them at 2048 characters, validates a version field, rejects duplicate keys in the decoded object, and rejects any cursor whose key set does not match the expected shape exactly. Approval listings use a narrower cursor of their own, bounded to 1024 characters by the query parameter. Two cursor kinds exist: - **Time cursors** carry a UTC timestamp and a row id, and page a `(created_at DESC, id DESC)` keyset. Used by audit, receipts, and approvals. - **Group cursors** carry a grouping key and the last value emitted. Used by usage summaries. An invalid cursor is a `422`, never a silent reset to page one. ### Time windows Endpoints that take a window accept `from` and `to` as RFC 3339 timestamps with an explicit offset or `Z`. The window is **strict and half-open**: `from` is inclusive, `to` is exclusive, and `from` must be strictly before `to`. Both must be supplied together. Timestamps in responses are UTC RFC 3339 with a `Z` suffix. ## Errors Most control-plane failures are FastAPI `HTTPException`s and serialize as a `detail` field: ```json Error detail { "detail": "workspace not found" } ``` Request-validation failures from Pydantic, and query-validation failures raised by hand, serialize `detail` as a list of field errors: ```json Validation error { "detail": [ { "loc": ["query", "cursor"], "msg": "invalid cursor", "type": "value_error" } ] } ``` The application also registers exception handlers that wrap uncaught service errors in the shared error envelope: ```json Error envelope { "error": { "code": "invalid_request", "message": "prefix must be ob_live or ob_test" } } ``` | Service error | Status | `code` | |---|---|---| | `GatewayError` | `502` | `upstream_error` | | `ConnectError` | Carried on the error | `invalid_request` | | `KeyServiceError` | Carried on the error | `invalid_request` | | `PolicyError` | Carried on the error | `invalid_request` | | `HotPathError` | `503` | `not_ready` | Routes that catch these service errors themselves — the key and policy routes do — re-raise them as `HTTPException`, so those responses arrive in the `detail` shape. The envelope handlers cover what reaches the application uncaught. ### Status codes | Status | Meaning | |---|---| | `200` | Success. | | `201` | Created. Used by workspace creation and key mint. | | `400` | Malformed request the route rejected before validation. | | `401` | No session, or a session that did not verify. | | `403` | Verified operator whose role does not grant the action. | | `404` | No such resource, or not visible to this operator. | | `409` | Conflict: frozen workspace, duplicate slug or name, or a stale optimistic-concurrency token. | | `422` | Request or query validation failed. Also carries policy compile diagnostics on publish. | | `502` | Upstream gateway call failed. | | `503` | Identity not configured, hot path unavailable, or the policy compiler unavailable or over its concurrency budget. | ### Optimistic concurrency Mutations that revise shared state require the caller to name the version they are replacing, so a concurrent edit is a conflict rather than a silent overwrite. | Endpoint | Confirmation field | |---|---| | `POST /workspaces/{id}/policy/publish` | `expected_draft_hash`, optional `expected_revision_id` | | `PUT /workspaces/{id}/approval-policies/{policy_id}` | `expected_revision` | | `POST /workspaces/{id}/approval-policies/{policy_id}/disable` | `expected_revision` | | `POST /workspaces/{id}/approvals/{approval_id}/votes` | `expected_revision` | A mismatch is a `409` with no writes. ## Audit Every operator mutation writes an `audit_log` row in the same transaction as the act, naming the operator, the workspace, the action, and the target. Secret material never reaches the audit detail: key audit rows carry `last4`, never plaintext.