Skip to content

REST API reference

The control plane exposes a versioned REST API under /v1. This page covers the cross-cutting conventions; the full interactive specification is embedded at the bottom and is generated from api/openapi/control-plane.yaml, the source of truth.

Base URL and versioning

All endpoints live under /v1 (for example http://localhost:8080/v1/vms). Breaking changes go to a new major path (/v2); /v1 is never modified in a breaking way. Health probes (/healthz, /readyz) live outside /v1 so Kubernetes probes are not coupled to the API version.

Authentication

Both schemes use the Authorization: Bearer <token> header; the server picks the verifier by token shape:

Credential Shape How to get one
JWT access token a signed JWT (no prefix), 15 min default TTL POST /v1/auth/login
API token otx_<base64url> otherix config add cluster, or POST /v1/users/me/api-tokens
# Login -> access token
TOKEN=$(curl -s -X POST http://localhost:8080/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@example.com","password":"..."}' | jq -r .access_token)

curl -s http://localhost:8080/v1/vms -H "Authorization: Bearer $TOKEN"

Anonymous endpoints (POST /v1/auth/login, POST /v1/auth/refresh, GET /v1/ca, POST /v1/nodes/join) declare security: []. See Users and RBAC for roles and scopes.

Error envelope

Every error response has the same shape:

{ "error": { "code": "validation_failed", "message": "...", "details": {} } }

code is a stable snake_case string; message is human-readable; details is optional. The full catalog is in Error codes.

404, not 403, for invisibility

A resource that exists but is not visible to the caller returns 404 not_found, never 403. Existence is never leaked.

Pagination

List endpoints use opaque cursor pagination:

  • Query: limit (1..200, default 50) and cursor (opaque base64).
  • Response: { "data": [...], "meta": { "next_cursor": "<string|null>" } }.

There is no total count. A null next_cursor means the last page. Treat the cursor as opaque.

Idempotency

Every mutating request (POST/PATCH/DELETE) accepts an optional Idempotency-Key header (up to 255 chars, 24 h TTL):

  • Same key + same body within the TTL replays the original response.
  • Same key + different body returns 409 idempotency_key_mismatch.
curl -s -X POST http://localhost:8080/v1/vms \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H 'Content-Type: application/json' \
  -d '{ "name": "demo", "image_url": "...", "architecture": "amd64" }'

Carve-out

POST /v1/auth/login and POST /v1/auth/refresh do not accept an Idempotency-Key - replaying a cached token pair would break refresh-token rotation.

Async operations

Operations that touch a node or take more than a moment are asynchronous:

  1. The request returns 202 Accepted with { "task_id": "...", "status": "pending", "links": { "self": "/v1/tasks/{id}" } }.
  2. Poll GET /v1/tasks/{task_id} until status reaches success, failed, or cancelled. POST /v1/tasks/{id}/cancel is best-effort.

The otherix CLI hides this behind --wait. VM create/delete, the VM lifecycle verbs (start/stop/poweroff/reboot), and storage-pool scans are async; pause, resume, reset, and console-token issuance are synchronous 200s. See Desired vs observed state.

Conventions

  • Paths are plural nouns; VM sub-operations are sub-resources (/v1/vms/{id}/start). {id} for VMs and nodes is the name (UUID literals are rejected with 400).
  • Datetimes are RFC 3339; UUIDs are format: uuid.
  • operationId is <resource>.<action> (users.list, apiTokens.createMe).

Resources

Tag Guide
auth, users, api-tokens Users and RBAC
nodes, join-tokens Join a node
vms Create and manage VMs
storage-pools Storage pools
networks Networks
tasks (async operations, above)

Browse the full API

The complete specification is embedded below. You can also browse it locally with Swagger UI and Redoc:

make api-preview      # Swagger UI on :8081, Redoc on :8082
make api-preview-stop