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.
Base URL
An Enterprise self-host 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: 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. An operator session token is never an ob_live_… key, and a virtual key is never accepted at /api/v1. See Virtual keys for the agent-facing credential.

Operator sessions

Send the session token as a bearer token:
Operator request
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.
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.
1

Membership

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

Action

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.

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 for its full response, and 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.
integer
Page size. Bounds are per-endpoint; see the endpoint’s own documentation.
string
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 HTTPExceptions and serialize as a detail field:
Error detail
Request-validation failures from Pydantic, and query-validation failures raised by hand, serialize detail as a list of field errors:
Validation error
The application also registers exception handlers that wrap uncaught service errors in the shared error envelope:
Error envelope
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

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