This page documents the control-plane HTTP API that the web UI, the cb CLI and cb-agent
speak to. Every path below is derived from the FastAPI application in
apps/backend/src/app/; nothing here is aspirational.
Base URL and schema
Thing
Value
Route prefix
/api/v1 (API_PREFIX, changing it is untested)
OpenAPI schema
GET /api/openapi.json
Swagger UI
GET /api/docs
ReDoc
GET /api/redoc
Reported version
settings.app_version — the VERSION file, or APP_VERSION when set
The schema served at /api/openapi.json is the authoritative machine-readable contract; the
tables at the bottom of this page are that schema rendered for reading. To regenerate them, see
Keeping this page honest.
Authentication
Three credential types reach the same resolver
(app/core/security.py:resolve_optional_user_id_sync). All of them end in a user id; there is no
separate “API user” concept.
Credential
How it is presented
Notes
Session cookie
Cookie: cb_session=<jwt>
HttpOnly, SameSite=Strict, Secure whenever the request is TLS or arrives via a trusted proxy asserting X-Forwarded-Proto: https. Set by the login endpoints. Lifetime is the configured session timeout, default 24 h.
Bearer JWT
Authorization: Bearer <jwt>
The same token value as the cookie. Session JWTs carry the audience fastapi-users:auth, so a password-reset or MFA token cannot be replayed as a session.
API token / service account
Authorization: Bearer <token>
Created by POST /api/v1/auth/api-token or POST /api/v1/auth/service-account. Stored as a per-token salted HMAC-SHA256 ({salt_hex}:{hmac_hex}), never in clear. Supports expiry, rotation and revocation.
CB_API_TOKEN is deprecated and rejected: a request presenting it is answered 401 unless
CB_LEGACY_AUTH=true restores the old grant as a temporary rollback. Use a service account.
Roles and scopes
Roles form the hierarchy viewer < editor < admin, plus a read-only demo role
(app/core/rbac.py). Each role has a default scope set:
A token may be granted a narrower set. The grantable scopes — served to clients by
GET /api/v1/auth/scopes — are read:*, write:*, delete:*, admin:*, write:telemetry
and *:*.
CSRF
CSRFMiddleware enforces a double-submit check on POST, PUT, PATCH and DELETEwhen the
request carries a cb_session cookie. Send the value of the readable cb_csrf cookie back in
X-CSRF-Token. Requests authenticated by Authorization: Bearer alone carry no session cookie
and are therefore not subject to the check — which is what makes headless clients simple. The
session-establishing endpoints (/auth/login, /auth/register, /auth/demo, /auth/accept-invite,
/auth/vault-reset, /auth/force-change-password, /auth/mfa/verify) and /api/v1/health are
exempt by prefix.
Before first-run completes
While the deployment is unbootstrapped (auth_enabled is false), an admin-equivalent sentinel is
granted only to the first-run surface: everything under /api/v1/bootstrap and /api/v1/auth,
GET/HEAD on /api/v1/settings, and PATCH /api/v1/settings/oauth. Every other route answers
401 until an administrator exists. Creating that first administrator additionally requires the
setup token — see Configuration.
Error contract
Application errors return a JSON body with a machine-readable code
(app/schemas/errors.py, app/main.py):
{"error_code":"RESOURCE_NOT_FOUND","detail":"Hardware 42 not found"}
Field
When present
error_code
Errors raised as AppError and every unhandled 500
detail
Always. For a 422 it is the FastAPI validation-error list, not a string
fields
Field-level validation errors, where the endpoint supplies them
retry_after
429 only
The codes defined today are INTERNAL_SERVER_ERROR, VALIDATION_ERROR, RESOURCE_NOT_FOUND,
RATE_LIMITED and PERMISSION_DENIED.
A 422 additionally carries body: the first 500 characters of the offending request body.
Unhandled exceptions return {"error_code": "INTERNAL_SERVER_ERROR", "detail": "Internal server error"} — with a traceback field only when DEV_MODE=true, which must never be set in
production.
Deliberately gone
Path
Status
Why
/api/v1/tenants and /api/v1/tenants/{any} (all methods)
410 Gone
1.0 is single-tenant by decision — ADR-0003. The route stays mounted so a stale client gets an explicit answer rather than reawakening dormant tenant behaviour.
POST /api/v1/auth/forgot-password, POST /api/v1/auth/reset-password
410 Gone
There is no self-service password reset. Recovery is administrator-mediated (Users → Reset Password), or Reset With Vault Key for the case where no administrator can sign in.
Rate limits
Two independent mechanisms exist.
Per-route limits (slowapi) apply to the sensitive categories and are keyed by the trusted
client identity — the forwarded chain only when the socket peer is inside
CB_TRUSTED_PROXY_CIDRS, otherwise the socket peer itself. The active profile is
rate_limit_profile in settings:
Category
relaxed
normal (default)
strict
auth
20/minute
5/minute
3/minute
ip_check
30/minute
10/minute
5/minute
mfa_verify
10/15 minutes
5/15 minutes
3/15 minutes
scan
5/minute
1/minute
1/5 minutes
telemetry
30/minute
15/minute
5/minute
default
60/minute
30/minute
10/minute
Rate-limit storage must be shared Redis; the backend refuses to start when it resolves to
in-process memory. Getting CB_TRUSTED_PROXY_CIDRS wrong collapses every client behind your proxy
onto one bucket — see Remote Access.
TenantRateLimitMiddleware is a Redis sliding window of CB_RATE_LIMIT_RPM (default 600)
requests per 60 s keyed by tenant. Because 1.0 sets no tenant context, it is inert on the normal
path; it is retained as a compatibility shim. Health and metrics paths are skipped by name so a
Redis outage can never turn /livez into a 503 restart storm.
Destructive-action confirmation
High-impact operations require three request headers (app/core/destructive_actions.py):
Header
Value
x-cb-confirmation
The action name being confirmed
idempotency-key
At least 12 characters
x-cb-backup-verified
true — only where the action requires a verified backup
Denied attempts are written to the audit log.
Health and readiness
These four are the SRV-03 probe contract. GET and HEAD are both accepted.
Path
Meaning
/api/v1/livez
Liveness. Touches no dependency and takes no lock; 200 whenever the event loop runs, including through a database or Redis outage. This is the only probe a restart decision should turn on.
/api/v1/startupz
Startup. 503 with started: false until initialisation completes, 200 after — including while stopping, so a slow migration is not mistaken for a dead process.
/api/v1/readyz
Readiness. 200 with ready: true when the lifecycle state is ready and every dependency probe answers ok; 503 otherwise, including state: "stopping" during drain. The body also carries health (the derived health state — the only place a degraded server is distinguishable from a not-ready one), degraded (which optional dependencies are down), and writes_permitted — which is not advice, but what the write-admission guard is enforcing on this process right now.
/api/v1/health
Legacy shape, kept for the frontend poll and the installer’s readiness wait. New consumers should use the three above.
/api/v1/health returns the instance versiononly to an authenticated caller — the version
is deliberately withheld from anonymous callers as fingerprinting material.
Writes are refused when they cannot be served safely
Readiness is enforced, not merely advertised. A mutating request (POST, PUT, PATCH, DELETE)
to anything under /api/ is admitted only while the health state says a write can be persisted:
Condition
Response
A required dependency cannot answer — PostgreSQL unreachable, or a schema that does not match this build
503 with error_code: SERVICE_NOT_READY
The process is starting or draining
503 with error_code: SERVER_DRAINING while stopping
Both carry Retry-After: 5 and a health field naming the state. Reads, WebSocket sessions and the
four health endpoints above are deliberately not guarded — an established agent link is drained
by the lifespan rather than refused mid-frame, and health and diagnostics stay reachable in every
state, which is exactly when an operator needs them.
The lifecycle half of the guard only fires in a process whose ASGI lifespan actually drives the
state, so an embedded or test host that mounts the app without a lifespan is not permanently closed.
Prometheus metrics
GET /api/v1/metrics/metrics returns the Prometheus text exposition format. It requires
authentication (401 otherwise) and is excluded from the OpenAPI schema, so it does not appear in
the catalogue below. Contents and stability caveats: Metrics.
WebSocket endpoints
WebSocket routes carry no OpenAPI entry, so they are listed here explicitly.
Path
Auth
Purpose
/api/v1/agents/enroll
None — the Noise IK handshake is the authentication
Agent enrollment: handshake, hello, then hold open polling for the approval decision. Rate-gated per IP and globally before the first handshake byte is read.
/api/v1/agents/link
None — Noise IK handshake
The live agent link: heartbeats, telemetry, discovery results, probe dispatch, capability and update control frames.
/api/v1/agents/stream
Session
Agent presence fan-out to the UI.
/api/v1/discovery/stream
Session
Discovery job progress and results.
/api/v1/telemetry/stream
Session
Live hardware telemetry.
/api/v1/monitors/stream
Session
Monitor state changes and check results.
/api/v1/topology/stream
Session
Topology graph updates.
Session-authenticated sockets take the token from the cb_session cookie in the handshake, or —
/monitors, /telemetry and /agents/stream only — from a first text frame within 10 seconds.
Prefer the cookie: a token sent as a frame is visible to client code.
Close codes used: 1008 for unauthorized, wss_required, auth_timeout, ip_not_allowed,
clock_skew and malformed input; 1013 for rate limiting and the concurrent-pending-enrollment
cap; 1011 for an unexpected server error; 1000 for a clean, deliberate close.
When CB_WS_REQUIRE_WSS=true, a handshake that is not wss:// — and not asserted as HTTPS by a
peer inside CB_TRUSTED_PROXY_CIDRS — is refused. Connection caps are per-endpoint; see the
sizing profiles.
Two HTTP endpoints report socket state rather than opening one:
GET /api/v1/discovery/ws/status and GET /api/v1/topology/ws/status.
Static and unlisted routes
Excluded from the OpenAPI schema on purpose:
Path
Serves
/uploads, /user-icons, /branding
Uploaded files and branding assets from the data directory
/assets, /icons
Built frontend assets, when a frontend is bundled
/favicon.ico
Favicon, branding-aware
/install-agent.sh
The agent installer script cb-agent bootstraps from
/
The single-page app, when a frontend is bundled
The API and workers start without the bundled frontend; these mounts are skipped when the static
directory is absent.
Endpoint catalogue
Grouped by OpenAPI tag. Path parameters are shown in the FastAPI form. An operation appearing here
is not a promise that it is stable — see the banner at the top of this page.
Health probes carry no tag in the schema and are grouped under health below; each also accepts
HEAD, registered separately and left out of the schema so the four probes do not publish duplicate
operation ids — which is a generation error in every OpenAPI client generator. The twelve operations
under tenants and the two password-reset operations under auth are the
deliberately gone routes: they exist so a stale client receives 410 Gone
rather than silence.
The catalogue above is the live OpenAPI schema, rendered. To reproduce it after a route changes,
start the server and read the schema it serves:
# Native install: the backend listens on 127.0.0.1:8000 behind nginx.curl-s http://127.0.0.1:8000/api/openapi.json > openapi.json
# Through the published listener instead (any mode):curl-sk https://<your-host>/api/openapi.json > openapi.json
Every operation, path and summary in the tables comes from paths in that document; the tag
headings are its tags. Operations the schema marks include_in_schema=False — the Prometheus
endpoint and the static mounts — are documented in prose above instead, because they are real
routes that a generated table would silently omit.
Related
cb CLI Tool — the administrative surface that does not go through HTTP