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