Cloud OS API
Complete reference for the Quazzar Cloud OS REST API. All endpoints use the /api/ prefix and require authentication (session cookie, Authorization: Bearer, or X-API-Key) unless marked public; anonymous requests get 401. System-level changes — app install/uninstall/start/stop, domains, system service actions, updates, Docker cleanup, backups, security scans, the audit log, terminals, the host File Manager (/api/files/*, reads included), AI runtime / model / provider administration, workflow changes and runs, Server Mode changes (containers, disks, storage, network, proxy routes, tunnels, object storage, Plex), the sysctl actuators, crontab and the Database Manager — additionally need the admin role and return 403 for other users. API keys act with their owner’s current role and are limited to their scopes; the paired mobile app acts with its owner’s role. Installs from every path (API, setup, Molly, Control Panel) count against the plan’s app limit (402 upgrade_required when reached). The full list of public endpoints is in Security Hardening. The base URL is your Cloud OS instance address (for example, http://localhost:8080/api/).
See Authentication for details on obtaining and using tokens.
Error responses
Most errors are JSON with four fields:
{
"error": "Invalid name.",
"code": "invalid_name",
"message": "Invalid name.",
"params": {}
}error and message carry the English text (unchanged for existing clients such as the CLI and the mobile app). code is a stable identifier and params fills its placeholders; the web app uses them to show the message in the user’s language. A few endpoints, such as the component and upgrade guards, keep a machine code in error (for example component_not_installed); their message is still the English text.
Authentication
Endpoints for user setup, login, token management, TOTP two-factor authentication, session management, and Control Panel OAuth integration.
| Method | Path | Description |
|---|---|---|
POST | /api/auth/setup | Create the initial admin account (first boot only) |
POST | /api/auth/login | Authenticate and receive JWT tokens |
POST | /api/auth/refresh | Refresh an expired access token (public; authenticated by the refresh_token cookie) |
POST | /api/auth/logout | Revoke the current session and clear the auth cookies (public; identifies the session by the refresh cookie, a {"refresh_token"} body, or the access token) |
GET | /api/auth/me | Get the current authenticated user |
POST | /api/auth/change-password | Change the admin password |
POST | /api/auth/totp/enable | Enable TOTP two-factor authentication |
POST | /api/auth/totp/verify | Verify a TOTP code |
GET | /api/auth/sessions | List active sessions |
DELETE | /api/auth/sessions/{id} | Revoke a specific session |
GET | /api/auth/cp-status | Check whether this node is connected to a Control Panel (used by login page to show/hide the “Sign in with Quazzar Cloud” button) |
Control Panel Session (Impersonate)
| Method | Path | Description |
|---|---|---|
GET | /api/cp/session | Validate a CP-issued impersonate token and create a local OS session |
This endpoint is called by the browser redirect from the CP “Open in node” button. The token query parameter must be a valid HMAC-signed JWT issued by the CP for this specific node. On success, a session cookie is set and the response redirects to the OS dashboard.
OAuth Callback (Sign in with Quazzar Cloud)
| Method | Path | Description |
|---|---|---|
GET | /api/oauth/callback | Handle the OAuth 2.0 authorization code callback from the CP IdP |
The OS redirects here after the user approves on the CP. The endpoint exchanges the code with the CP, validates the identity assertion, and creates a local OS session.
Query parameters: code (required), state (required, must match the value generated at authorization start).
Lock screen, passkeys & approve-on-device
Every endpoint below that signs someone in (/unlock, /passkeys/login/finish, the approved /approve/{id} poll) answers with exactly the same body and cookies as POST /api/auth/login: {"access_token": "…", "user": {…}} plus the access_token and refresh_token cookies. See the Sign-in guide for the user-facing side.
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/auth/lock-info | public | Node facts for the Login/Lock screen (no user data, cached 10 s) |
POST | /api/auth/lock | session | Lock the current session → 204 |
POST | /api/auth/unlock | session (allowed while locked) | {"password":"…"} or {"passkey":{"session_id","credential"}} → fresh session |
POST | /api/auth/passkeys/register/begin | session | {session_id, options:{publicKey:…}} for navigator.credentials.create() |
POST | /api/auth/passkeys/register/finish | session | {session_id, name, credential} → {id, name, created_at} |
GET | /api/auth/passkeys | session | {"passkeys":[{id, name, created_at, last_used_at}]} |
PATCH | /api/auth/passkeys/{id} | session | {"name":"…"} → the updated passkey |
DELETE | /api/auth/passkeys/{id} | session | Remove a passkey → 204 |
POST | /api/auth/passkeys/login/begin | public | Discoverable sign-in: no username, empty allowCredentials |
POST | /api/auth/passkeys/login/finish | public | {session_id, credential} → login response |
POST | /api/auth/approve/request | public | {"email"} → {request_id, expires_at, code} (always, even for unknown emails) |
GET | /api/auth/approve/{id} | public | status is pending, approved, denied or expired; the first poll after approval also returns the session |
GET | /api/auth/approve/{id}/stream | public | Server-Sent Events, event: status with the same JSON |
GET | /api/auth/approve/pending | session | {"requests":[{id, device, ip, city, created_at, expires_at}]} for the current user |
POST | /api/auth/approve/{id}/decide | session (target user) | {"approve":true,"code":"47"} → 204; a wrong code → 400 and the request is denied |
Lock-info response
{
"hostname": "quazzar-01", "online": true, "uptime_seconds": 1234567,
"apps_in_orbit": 12, "version": "0.10.4", "setup_required": false,
"passkeys_enabled": true, "approve_enabled": true
}passkeys_enabled is false when the page is served over plain HTTP from anything other than localhost, or from a bare IP address — browsers only offer passkeys in a secure context on a real hostname.
Locked sessions. While a session is locked, every authenticated request it makes returns 423 {"error":"locked"} except /api/auth/unlock, /api/auth/logout, /api/auth/lock-info and /api/auth/me (which reports "locked": true). Refreshing the access token does not unlock. Wrong unlock passwords return 401 and count toward the same per-IP limit as /api/auth/login (5 attempts / 15 min). Tokens issued before this release carry no session id; locking them returns 409 session_not_lockable until the next refresh (≤ 15 min).
Passkeys. The relying-party ID is auth.webauthn_rp_id (env QUAZZAR_WEBAUTHN_RP_ID, default proxy.base_domain) when the browser is on that host or one of its subdomains; otherwise it is the request host. Challenges expire after 5 minutes and can be used once. A passkey sign-in with user verification (Face ID, fingerprint, PIN) satisfies two-factor authentication; without user verification, accounts with TOTP get the usual {"totp_required": true, "user_id": "…"} step. A signature counter that goes backwards (a possibly cloned key) is refused and logged.
Approve on another device. Requests expire after 2 minutes and are rate-limited per IP (10 / 10 min) and per email (5 / 10 min). The code is shown only on the requesting screen, never in the notification, so the approver has to type what they see. The SSE stream carries only the status; when it reports approved, make one GET /api/auth/approve/{id} to collect the session (cookies cannot be set mid-stream).
QR-code sign-in
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /api/auth/qr/generate | public (rate-limited) | → {"session_id", "secret", "expires_at"}. Put only session_id in the QR code; keep secret on this device |
GET | /api/auth/qr/status/{session_id} | public | → {"status"}: pending, approved, consumed or expired. Never returns a token |
POST | /api/auth/qr/approve | session or paired mobile app | {"session_id"} → {"status":"approved","message":"session approved"}; 404 unknown, 410 expired, 409 already approved or used. /api/auth/qr/authenticate is an alias for older mobile builds |
POST | /api/auth/qr/exchange | public (the secret is the credential; rate-limited) | {"session_id","secret"} → the login response (same body and cookies as POST /api/auth/login) |
/qr/exchange errors: 400 missing field, 401 {"error":"invalid qr session"} for an unknown id, a wrong secret or a session already exchanged (these cases are indistinguishable), 409 not approved yet (the session stays usable — keep polling), 410 expired. A code lives 5 minutes; approving it leaves at most 60 seconds to exchange, and it can be exchanged once. API keys cannot approve.
Profile: avatar & preferences
| Method | Path | Auth | Description |
|---|---|---|---|
PUT | /api/profile/avatar | session | multipart field file, ≤ 2 MB, PNG/JPEG/WebP → {"avatar_url":"/api/avatars/<sha256>.png"} |
DELETE | /api/profile/avatar | session | Remove the avatar → 204 |
GET | /api/avatars/{sha256}.png | public | The 256×256 PNG, served with immutable cache headers |
GET | /api/profile/preferences | session | Shell preferences with defaults applied: accent, custom_accent, theme, motion, sounds, mode, spaces (hero blocks per space, e.g. {"home":{"player":false}}; only changed blocks are stored) |
PUT | /api/profile/preferences | session | Partial update, returns the full object. Unknown fields → 400; spaces: null resets the blocks |
The image type is detected from the file contents, not the extension. Uploads are center-cropped and re-encoded, which also strips EXIF/GPS metadata. avatar_url (string or null) also appears on the user object of /api/auth/me, the login response and /api/users.
{
"accent": "nebula", "custom_accent": null, "theme": "dark",
"motion": "max", "sounds": true, "mode": "server"
}accent is one of nebula|plasma|ion|quasar|custom, custom_accent is #RRGGBB or null, theme is dark|light, motion is calm|cinematic|max, mode is server|personal|cluster. Unknown fields and invalid values return 400. If you never set mode, it follows the node default chosen at first-run setup, or server for admins and personal for everyone else when none was chosen.
First-run setup
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /api/setup/node | admin | Name the node, pick the default mode, install starter apps |
// request
{ "name": "quazzar-01", "mode": "personal", "starter_apps": ["immich", "jellyfin"] }
// response
{
"hostname": "quazzar-01", "mode": "personal",
"apps": [
{ "template": "immich", "app_id": "8f1c…", "status": "installing" },
{ "template": "jellyfin", "error": "upgrade_required" }
]
}name is lowercased and must be a valid hostname label (letters, digits and -, 1–63 characters, not starting or ending with -). Starter apps install the same way as POST /api/apps, including the plan’s app limit. If one app fails, its error is listed and the rest continue.
Apps
Manage applications installed on the Cloud OS instance. Apps are deployed from templates as Docker containers and can be started, stopped, restarted, and monitored.
| Method | Path | Description |
|---|---|---|
GET | /api/apps | List all installed apps |
POST | /api/apps | Install a new app from a template |
GET | /api/apps/{appId} | Get app details |
DELETE | /api/apps/{appId} | Uninstall an app |
POST | /api/apps/{appId}/start | Start a stopped app |
POST | /api/apps/{appId}/stop | Stop a running app |
POST | /api/apps/{appId}/restart | Restart an app |
GET | /api/apps/{appId}/logs | Get app container logs (admin) |
GET | /api/apps/{appId}/stats | Get app resource usage statistics |
Container logs routinely contain secrets, so every way of reading them is admin only: GET /api/apps/{appId}/logs, GET /api/apps/{appId}/logs/search, GET /api/containers/{id}/logs, the log aggregation surface (/api/logs, /api/logs/stream, /api/logs/system, /api/logs/journal, /api/logs/docker/{id}, /api/log-forwarders) and the public-tunnel log WebSocket GET /ws/tunnels/{id}. Non-admins get 403.
Containers (Server Mode)
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/containers | session | List containers: name, image, state, ports, labels (no environment) |
GET | /api/containers/{id} | admin | Inspect: environment variables, command, mounts, networks |
GET | /api/containers/{id}/logs | admin | Container logs |
POST / DELETE | /api/containers/… | admin | Run, compose deploy, start / stop / restart / remove, images, registries |
Inspect is admin only rather than redacted: the environment usually holds database passwords and API keys, and the command line and logs carry the same secrets, so hiding env values alone would not be enough.
Example: Install an App
curl -X POST http://localhost:8080/api/apps \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"template": "nextcloud",
"name": "my-nextcloud",
"config": {
"admin_user": "admin",
"admin_password": "SecurePassword123"
}
}'{
"id": "app_abc123",
"name": "my-nextcloud",
"template": "nextcloud",
"status": "installing",
"created_at": "2025-01-15T10:30:00Z"
}Example: Get App Logs
curl "http://localhost:8080/api/apps/app_abc123/logs?lines=100&follow=false" \
-H "Authorization: Bearer <token>"Example: Get App Stats
curl http://localhost:8080/api/apps/app_abc123/stats \
-H "Authorization: Bearer <token>"{
"app_id": "app_abc123",
"cpu_percent": 2.5,
"memory_usage_mb": 256,
"memory_limit_mb": 1024,
"network_rx_bytes": 1048576,
"network_tx_bytes": 524288,
"disk_usage_mb": 512
}Templates
Browse the application template catalog. Templates define how apps are installed and configured.
| Method | Path | Description |
|---|---|---|
GET | /api/templates | List all available templates |
GET | /api/templates/categories | List template categories |
GET | /api/templates/{name} | Get details for a specific template |
GET | /api/templates/featured | Get the template shown in the Launchpad Featured banner (404 JSON error when none is flagged) |
Each template may carry three optional fields used by the Launchpad cards: stars (approximate GitHub stars of the upstream project, e.g. 48000), featured (true for the Featured banner) and tagline (a short banner line). They are omitted when not set. Template authors can add them to the template YAML:
name: immich
website: https://immich.app
stars: 48000
featured: true
tagline: "Your photo library, off the cloud."Example: List Templates
curl http://localhost:8080/api/templates \
-H "Authorization: Bearer <token>"[
{
"name": "nextcloud",
"display_name": "Nextcloud",
"description": "Self-hosted productivity platform",
"category": "productivity",
"version": "28.0",
"icon": "nextcloud.svg"
},
{
"name": "gitea",
"display_name": "Gitea",
"description": "Lightweight self-hosted Git service",
"category": "development",
"version": "1.21",
"icon": "gitea.svg"
}
]Dashboard Layout API
Per-user, per-kind dashboard layout stored and versioned server-side. Two independent layouts exist: server (Server Home) and personal (Personal Home). Changes are written with optimistic concurrency — a stale write returns 409 Conflict and the client must refetch before retrying.
See Per-User Layout Sync for the UI-level description.
| Method | Path | Description |
|---|---|---|
GET | /api/profile/dashboard?type=server|personal | Fetch the current layout for the given dashboard kind |
PUT | /api/profile/dashboard?type=server|personal | Save a new layout. Body: { layout, version } |
GET /api/profile/dashboard
Query parameters
| Param | Required | Values |
|---|---|---|
type | yes | server or personal |
curl "http://localhost:8080/api/profile/dashboard?type=server" \
-H "Authorization: Bearer <token>"{
"type": "server",
"version": 7,
"layout": [
{ "id": "cpu", "x": 0, "y": 0, "w": 1, "h": 1 },
{ "id": "ram", "x": 1, "y": 0, "w": 1, "h": 1 },
{ "id": "network", "x": 2, "y": 0, "w": 2, "h": 2 }
]
}version is a monotonically increasing integer. Always pass the version you received back when writing.
PUT /api/profile/dashboard
Query parameters
| Param | Required | Values |
|---|---|---|
type | yes | server or personal |
Request body
{
"version": 7,
"layout": [
{ "id": "cpu", "x": 0, "y": 0, "w": 2, "h": 1 },
{ "id": "ram", "x": 2, "y": 0, "w": 2, "h": 1 },
{ "id": "network", "x": 0, "y": 1, "w": 4, "h": 2 }
]
}curl -X PUT "http://localhost:8080/api/profile/dashboard?type=server" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{ "version": 7, "layout": [...] }'Response (success)
{
"type": "server",
"version": 8
}Status codes
| Status | Meaning |
|---|---|
200 | Layout saved; response includes the new version |
400 | Missing or invalid type parameter, or malformed body |
401 | Not authenticated |
403 | Insufficient permissions |
409 | Stale version — another device has written a newer layout. Fetch the latest and retry |
When a 409 is returned the body includes the current server version so the client knows what to fetch:
{
"error": "stale_version",
"current_version": 9,
"message": "Layout was updated from another device. Reload and retry."
}Components
Read and change which optional parts of Cloud OS are installed. See Components.
| Method | Path | Access | Description |
|---|---|---|---|
GET | /api/components | Any signed-in user | List components and their status |
POST | /api/components/apply | Admin | Start a change; returns an operation id |
GET | /api/components/ops/{id}/events?from=N | Admin | Server-sent events for an operation, starting at event N |
POST | /api/components/objstore/purge-minio | Admin | Delete the old MinIO data after the move to Garage; returns {"freed_bytes": N, "binary_removal_started": true} |
Example: List components
{
"components": [
{
"id": "objstore",
"title": "S3 Object Storage",
"summary": "...",
"ui": "...",
"requires": [],
"enabled": true,
"status": "installed"
}
],
"docker": { "mode": "keep", "flavor": "docker.io", "version": "24.0.7", "too_old": false },
"tuning": { "zram": true, "earlyoom": true, "irqbalance": true, "cpupower": true, "preload": true },
"busy": false
}status is installed, disabled, failed or pending-apt. config_error is added when components.yaml cannot be read; in that case every feature stays visible.
On a node that has MinIO data the response also has a top-level objstore field:
"objstore": { "phase": "copying", "copied": 1240, "total": 3100, "bytes": 5242880 }phase is pending (Garage not installed yet), waiting_space (with error_code: "objstore_not_enough_space" and error_params: {"needed", "free"}), copying, catching_up, verifying, switching, failed (with error) or done (with legacy_bytes until the old data is deleted, then purged: true). drive_paused: true means Orbit Drive is off until the node can tell which store holds its data or quazzard restarts; error_code then says why (objstore_migration_state_unknown, objstore_migration_state_unknown_next_start when Garage is down too and nothing is retried before the next start, objstore_minio_unavailable, objstore_drive_next_start). With objstore_minio_unavailable, error_params.error is the start failure and error_params.missing is binary or env when the minio program or minio.env is gone.
POST /api/components/objstore/purge-minio is refused with 409 and one of objstore_minio_purge_unavailable (the move has not finished), objstore_minio_purge_too_soon (within 6 minutes of the switch; error_params.minutes), objstore_garage_unavailable (Garage is not healthy), objstore_garage_node_changed (the running Garage node is not the one the data was moved to), objstore_garage_data_mismatch (a bucket that received objects in the move is now gone or empty; error_params.bucket, moved, now), objstore_minio_changed_after_switch (files in the MinIO buckets changed after the switch; error_params.count) or components_busy (a components apply is running, or systemd cannot say).
Example: Apply a change
set uses the same syntax as quazzar setup apply --set. Set confirm_snap to true when replacing the snap version of Docker.
curl -X POST http://localhost:8080/api/components/apply \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"set": "vpn,-mail,docker=replace", "confirm_snap": false}'{ "op_id": "op-1790000000000000000" }Errors: 400 (bad_spec, confirm_snap_required), 409 (busy, another operation is running).
Example: Follow progress
GET /api/components/ops/{op_id}/events?from=0 streams events of type plan, step, log, restart, done and error. The stream ends after done or error. The operation may restart Quazzar; reconnect with from set to the last event number plus one to continue without repeats.
id: 3
event: step
data: {"seq":3,"type":"step","component":"vpn","step":"install","title":"Installing WireGuard","status":"ok","time":"2026-09-28T10:00:00Z"}Requests to components that are off
Routes that belong to a component that is not installed answer 404:
{
"error": "component_not_installed",
"component": "objstore",
"message": "This component is not installed. Install it in Settings → Components."
}Changing network settings while network management is off answers 409 with the same error and component set to network. Reading network state still works.
Object Storage
The node’s Garage store. Reads for any signed-in user; changes need the admin role. When S3 Object Storage is off the routes answer 404 component_not_installed; when Garage is not running, 503 objstore_garage_unavailable.
| Method | Path | Description |
|---|---|---|
GET | /api/objects/buckets | {"buckets":[{"name","created_at","size_bytes","object_count","visibility","public_url","public_host","reserved"}]} — public_* only for public buckets; reserved: true for Orbit Drive’s quazzar-drive |
POST | /api/objects/buckets | {"name":"cdn","visibility":"public"} → 201 |
DELETE | /api/objects/buckets/{name} | 204; 409 objstore_bucket_not_empty; 409 objstore_bucket_reserved for quazzar-drive |
GET | /api/objects/access-keys | {"access_keys":[{"access_key","name","status","grants":[{"bucket","read","write","owner"}]}]} |
POST | /api/objects/access-keys | Body below → 201 with the secret, once |
DELETE | /api/objects/access-keys/{key} | 204 |
GET | /api/objects/endpoint | {"internal":"http://127.0.0.1:3900","web":"http://127.0.0.1:3902","region":"us-east-1","secure":false} — internal is a full URL; there is no console |
curl -X POST http://localhost:8080/api/objects/access-keys \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"backup-agent","grants":[{"bucket":"backups","read":true,"write":true,"owner":false}]}'{ "access_key": "GK31c2f218a2e44f485b94239e", "secret_key": "…", "name": "backup-agent",
"grants": [{ "bucket": "backups", "read": true, "write": true, "owner": false }], "secret_is_one_shot": true }Errors: 400 objstore_policy_unsupported (a non-empty policy field — IAM policies are gone), 404 objstore_bucket_not_found (a grant names an unknown bucket; nothing is created), 409 objstore_bucket_exists, 409 objstore_bucket_reserved (a grant on quazzar-drive, Orbit Drive’s bucket; nothing is created), 409 objstore_migration_in_progress (bucket and key changes wait until the move from MinIO finishes).
Reserved settings keys
Settings keys that start with objstore. belong to Quazzar (the MinIO to Garage migration record). The settings API does not list them, GET /api/settings/{key} answers for one exactly as for a key that does not exist (200 with an empty value), and writing one (PUT /api/settings/{key} or inside a bulk PUT /api/settings/) answers 400 settings_key_reserved.
System
Monitor system resources, hardware health, and performance metrics for the Cloud OS instance.
| Method | Path | Description |
|---|---|---|
GET | /api/system/metrics/current | Get current system resource usage (CPU, memory, disk) |
GET | /api/system/metrics | Get historical system metrics |
GET | /api/system/info | Get system information (OS, kernel, uptime) |
GET | /api/system/apps/metrics | Get aggregated metrics for all apps |
GET | /api/system/disk/health | Get disk health status (S.M.A.R.T.) |
GET | /api/system/disks | List real filesystems (ext4, xfs, btrfs, zfs, f2fs, vfat) |
GET | /api/system/network/interfaces | List network interfaces |
GET | /api/system/overview | One-call summary for the Overview and Login screens (cached ~5 s) |
GET | /api/system/metrics/live | Fast live sample for the Monitoring screen (poll ~1 s) |
GET | /api/disks/overview | Physical disks with usage and S.M.A.R.T. health (cached 60 s) |
Example: Get Current Metrics
curl http://localhost:8080/api/system/metrics/current \
-H "Authorization: Bearer <token>"{
"cpu": {
"usage_percent": 12.5,
"cores": 4,
"model": "Intel Xeon E-2236"
},
"memory": {
"total_mb": 16384,
"used_mb": 8192,
"usage_percent": 50.0
},
"disk": {
"total_gb": 500,
"used_gb": 120,
"usage_percent": 24.0
},
"uptime_seconds": 864000
}Example: Get System Info
curl http://localhost:8080/api/system/info \
-H "Authorization: Bearer <token>"{
"hostname": "cloud-os-01",
"os": "Ubuntu 22.04 LTS",
"kernel": "5.15.0-91-generic",
"architecture": "x86_64",
"quazzar_version": "0.9.7",
"uptime_seconds": 864000,
"node_id": "node_abc123"
}Example: List Disks
Returns every real filesystem on the host. Pseudo-filesystems (tmpfs, devtmpfs, squashfs, overlay mounts) are excluded. The Storage Overview dashboard widget reads this endpoint.
curl http://localhost:8080/api/system/disks \
-H "Authorization: Bearer <token>"[
{
"device": "/dev/sda1",
"mount_point": "/",
"fs_type": "ext4",
"total_bytes": 214748364800,
"used_bytes": 52428800000,
"free_bytes": 162319564800,
"usage_percent": 24.4
},
{
"device": "/dev/sda2",
"mount_point": "/boot/efi",
"fs_type": "vfat",
"total_bytes": 536870912,
"used_bytes": 5242880,
"free_bytes": 531628032,
"usage_percent": 1.0
}
]Status codes
| Status | Meaning |
|---|---|
200 | List of filesystems |
401 | Not authenticated |
Example: System Overview
Host facts plus app, container, reverse-proxy, backup, alert and security summaries. Every block is best-effort: when a source is unavailable (no Docker, no scan yet) its counters are 0 and its timestamps null instead of failing the request.
curl http://localhost:8080/api/system/overview \
-H "Authorization: Bearer <token>"{
"hostname": "quazzar-01",
"os": "Ubuntu 24.04.1 LTS",
"kernel": "6.8.0-45-generic",
"arch": "amd64",
"cpu_model": "AMD Ryzen 9 7950X 16-Core Processor",
"cpu_cores": 16,
"cpu_threads": 32,
"ram_total_bytes": 68719476736,
"uptime_seconds": 1234567,
"version": "0.10.4",
"apps": { "installed": 12, "running": 11 },
"containers": { "running": 14, "total": 16 },
"proxy_routes": 14,
"backup": { "status": "ok", "last_success_at": "2026-09-24T02:00:00Z", "last_verified_at": "2026-09-24T02:05:00Z", "last_size_bytes": 442381631488 },
"alerts": { "open": 0, "critical": 0 },
"security": { "score": 92, "max": 100, "last_scan_at": "2026-09-24T09:15:00Z" }
}backup.status is ok, stale (last success older than 48 h), failed (the most recent run failed) or none (no finished backup yet). last_verified_at is the latest passing backup verification — verification results are now stored, so it survives restarts.
Example: Live Metrics
A cheap sample meant to be polled about once a second while the Monitoring screen is visible. Samples younger than 500 ms are shared between callers, so many open tabs cost the same as one. Rates are derived from counter deltas between samples and are never negative (counter resets read as 0). net.iface is the default-route interface (falling back to the busiest non-virtual one); ifaces lists physical interfaces only. cpu_temp_c is null when no sensor is readable (VMs, containers).
curl http://localhost:8080/api/system/metrics/live \
-H "Authorization: Bearer <token>"{
"at": "2026-09-24T15:02:03Z",
"cpu_percent": 23.4,
"per_core": [12.1, 40.2, 8.0, 33.3],
"cpu_temp_c": 52.0,
"load": [1.2, 0.9, 0.7],
"ram_used_bytes": 21233664000,
"ram_total_bytes": 68719476736,
"swap_used_bytes": 0,
"net": { "iface": "eth0", "rx_bytes_per_sec": 4200000, "tx_bytes_per_sec": 1200000 },
"ifaces": [ { "name": "eth0", "rx_bytes_per_sec": 4200000, "tx_bytes_per_sec": 1200000 } ],
"disk_io": { "read_bytes_per_sec": 1000, "write_bytes_per_sec": 2000 }
}Example: Disks Overview (S.M.A.R.T.)
Physical disks only (loop, RAM and zram devices are skipped). used_bytes / used_percent are aggregated over every mounted partition, LUKS or LVM volume on the disk. S.M.A.R.T. is read with smartctl -j -a for every disk (install smartmontools; the service retries through sudo -n when it lacks permission). smart.status is one of Healthy, Failing, <n> reallocated, <n> pending, <n> media errors, Worn <n>%, or Unknown with smart.available: false when S.M.A.R.T. can’t be read.
curl http://localhost:8080/api/disks/overview \
-H "Authorization: Bearer <token>"[
{
"device": "nvme0n1",
"model": "Samsung SSD 990 PRO 1TB",
"serial": "S6Z...",
"size_bytes": 1000204886016,
"used_bytes": 580000000000,
"used_percent": 58.0,
"mounts": ["/", "/data"],
"rotational": false,
"transport": "nvme",
"smart": {
"available": true,
"healthy": true,
"status": "Healthy",
"temperature_c": 38,
"power_on_hours": 8123,
"reallocated_sectors": 0,
"pending_sectors": 0,
"media_errors": 0,
"percentage_used": 3
}
}
]GET /api/disks/{name} now also returns the full S.M.A.R.T. attribute map (smart.attributes, e.g. "Reallocated_Sector_Ct": "4" for SATA drives or "percentage_used": "3" for NVMe).
Release Notes & Feature Tour
The What’s new page reads release notes the node caches from the Control Center (GET {CC}/api/v1/release-notes?product=os). The node pulls the full list once an hour and on Sync; the cache is replaced only after a successful fetch, so a Control Center outage never empties the page. Enrolled nodes send their instance id, so canary notes for a staged rollout they take part in show up too.
| Method | Path | Description |
|---|---|---|
GET | /api/release-notes | Cached release notes, newest version first: [{id, version, title, summary, published_at, items[], fetched_at}] |
GET | /api/release-notes/{version} | One cached release note |
POST | /api/release-notes/sync | Fetch the notes from the Control Center now (admin); 502 and the old cache kept on failure |
GET | /api/feature-tour | Feature tour catalogue: {version, slides[]}. The tour opens once when version is newer than /api/profile/last-seen-release |
Network History
Per-interface throughput history used by the Network — Per Interface dashboard widget.
| Method | Path | Description |
|---|---|---|
GET | /api/network/history | Get bucketed RX/TX history for a network interface |
GET /api/network/history
Query parameters
| Param | Required | Values | Default |
|---|---|---|---|
interface | yes | Interface name, e.g. eth0 | — |
range | no | 5m, 15m, 1h, 6h, 24h | 1h |
Each range value maps to a fixed bucket size:
| Range | Bucket |
|---|---|
5m | 10 s |
15m | 30 s |
1h | 1 min |
6h | 5 min |
24h | 15 min |
curl "http://localhost:8080/api/network/history?interface=eth0&range=1h" \
-H "Authorization: Bearer <token>"{
"interface": "eth0",
"range": "1h",
"bucket_seconds": 60,
"points": [
{ "ts": "2026-05-25T10:00:00Z", "rx_bytes_per_sec": 125000, "tx_bytes_per_sec": 48000 },
{ "ts": "2026-05-25T10:01:00Z", "rx_bytes_per_sec": 132000, "tx_bytes_per_sec": 51000 }
]
}ts values are bucket start times in UTC ISO 8601. The array is sorted ascending by ts. Points with no observed traffic report 0 for both fields rather than being omitted.
Status codes
| Status | Meaning |
|---|---|
200 | History array |
400 | Missing interface param or invalid range value |
401 | Not authenticated |
Backups
Create and manage backups of app data and system configuration. Supports local and remote backup targets.
| Method | Path | Description |
|---|---|---|
GET | /api/backups | List all backups |
POST | /api/backups | Create a new backup |
POST | /api/backups/{id}/restore | Restore from a backup |
GET | /api/backup/targets | List configured backup targets |
POST | /api/backup/targets | Add a backup target (local, S3, etc.) |
GET | /api/backup/schedule | Get the backup schedule |
Example: Create a Backup
curl -X POST http://localhost:8080/api/backups \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"target": "s3-primary",
"apps": ["app_abc123", "app_def456"],
"include_config": true
}'{
"id": "bak_abc123",
"status": "in_progress",
"target": "s3-primary",
"apps": ["app_abc123", "app_def456"],
"include_config": true,
"started_at": "2025-01-15T10:30:00Z"
}Example: Configure an S3 Backup Target
curl -X POST http://localhost:8080/api/backup/targets \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"name": "s3-primary",
"type": "s3",
"config": {
"bucket": "my-backups",
"region": "us-east-1",
"access_key": "AKIAIOSFODNN7EXAMPLE",
"secret_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"encryption": true
}
}'Domains
Manage custom domains and SSL certificates for apps running on the Cloud OS instance.
| Method | Path | Description |
|---|---|---|
GET | /api/domains | List all configured domains |
POST | /api/domains | Add a new domain |
PUT | /api/domains/{id} | Update a domain configuration |
DELETE | /api/domains/{id} | Remove a domain |
GET | /api/domains/{id}/ssl | Get SSL certificate status for a domain |
Example: Add a Domain
curl -X POST http://localhost:8080/api/domains \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"domain": "cloud.example.com",
"app_id": "app_abc123",
"auto_ssl": true
}'{
"id": "dom_abc123",
"domain": "cloud.example.com",
"app_id": "app_abc123",
"ssl": {
"status": "provisioning",
"provider": "letsencrypt"
},
"created_at": "2025-01-15T10:30:00Z"
}SSL certificates are automatically provisioned through the built-in Caddy reverse proxy when auto_ssl is enabled. Ensure your domain’s DNS A record points to the Cloud OS instance before adding it.
Reverse Proxy — Domains & Routes
Manage routing rules for the embedded Caddy reverse proxy. Routes map hostnames (subdomains or full domains) to installed apps, the Cloud OS UI, or arbitrary host:port upstreams. Changes are staged and applied atomically via POST /api/proxy/apply.
See Domains & Routes Manager for the UI walkthrough, including port-conflict resolution and SSO gating.
Routes
| Method | Path | Description |
|---|---|---|
GET | /api/proxy/routes | List all configured routes |
POST | /api/proxy/routes | Create a new route |
GET | /api/proxy/routes/{id} | Get a single route |
PUT | /api/proxy/routes/{id} | Update a route. Append ?confirm=true when re-pointing the apex domain |
DELETE | /api/proxy/routes/{id} | Delete a custom route (returns 409 for apex or auto-created app routes) |
POST | /api/proxy/apply | Apply all staged route changes to Caddy |
GET | /api/proxy/upstreams | List all known upstreams (apps, OS UI, manual entries) |
Settings (admin)
| Method | Path | Description |
|---|---|---|
POST | /api/settings/proxy/takeover | Stop and disable the conflicting web server so Caddy can own ports 80/443 |
GET | /api/settings/proxy/coexist-snippet | Generate a nginx or Apache front-proxy snippet. Query param: proxy=nginx|apache |
Forward Auth
| Method | Path | Description |
|---|---|---|
GET | /api/auth/validate | Caddy forward_auth target. Returns 200 with user headers on a valid session, 401 otherwise |
Example: Create a Subdomain Route
curl -X POST http://localhost:8080/api/proxy/routes \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"hostname": "git.example.com",
"upstream_type": "app",
"upstream_id": "app_abc123",
"require_auth": true
}'{
"id": "route_abc123",
"hostname": "git.example.com",
"upstream_type": "app",
"upstream_id": "app_abc123",
"require_auth": true,
"status": "pending",
"created_at": "2025-06-01T10:00:00Z"
}status is pending until POST /api/proxy/apply is called.
Example: Create a Manual Upstream Route
curl -X POST http://localhost:8080/api/proxy/routes \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"hostname": "metrics.example.com",
"upstream_type": "manual",
"upstream_addr": "localhost:9090",
"require_auth": false
}'Example: Re-point the Apex Domain (requires confirm)
curl -X PUT "http://localhost:8080/api/proxy/routes/route_apex?confirm=true" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"upstream_type": "app",
"upstream_id": "app_abc123"
}'Omitting ?confirm=true returns 409 Conflict:
{
"error": "apex_repoint_requires_confirm",
"message": "Re-pointing the apex domain will move the OS UI away from this address. Resubmit with ?confirm=true to proceed."
}Example: Apply Routes
curl -X POST http://localhost:8080/api/proxy/apply \
-H "Authorization: Bearer <token>"{ "applied": 3, "unchanged": 7, "errors": [] }Example: List Upstreams
curl http://localhost:8080/api/proxy/upstreams \
-H "Authorization: Bearer <token>"[
{ "id": "app_abc123", "type": "app", "name": "gitea", "addr": "127.0.0.1:3000" },
{ "id": "quazzar-ui", "type": "ui", "name": "OS UI", "addr": "127.0.0.1:8080" }
]Example: Get Coexist Snippet
curl "http://localhost:8080/api/settings/proxy/coexist-snippet?proxy=nginx" \
-H "Authorization: Bearer <token>"# Paste inside your existing nginx http {} block
server {
listen 80;
listen 443 ssl;
server_name _;
# Forward everything to Quazzar on the alternate port
location / {
proxy_pass http://127.0.0.1:8443;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Status Codes
| Status | Meaning |
|---|---|
200 | OK |
201 | Route created |
204 | Route deleted |
400 | Invalid request body or hostname |
401 | Not authenticated |
403 | Insufficient permissions (admin-only endpoints) |
409 | Apex re-point without ?confirm=true, or attempt to delete a protected route |
VPN
Manage the built-in WireGuard VPN server for secure remote access to the Cloud OS instance and its apps.
| Method | Path | Description |
|---|---|---|
GET | /api/vpn/provider | Get VPN server configuration |
POST | /api/vpn/provider | Configure the VPN server |
GET | /api/vpn/peers | List VPN peers (clients) |
POST | /api/vpn/peers | Create a new VPN peer |
DELETE | /api/vpn/peers/{id} | Remove a VPN peer |
GET | /api/vpn/peers/{id}/qrcode | Get a QR code for peer configuration |
Example: Create a VPN Peer
curl -X POST http://localhost:8080/api/vpn/peers \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"name": "my-laptop",
"dns": "1.1.1.1"
}'{
"id": "peer_abc123",
"name": "my-laptop",
"public_key": "aB1cD2eF3gH4...",
"allowed_ips": "10.0.0.2/32",
"config": "[Interface]\nPrivateKey = ...\nAddress = 10.0.0.2/32\nDNS = 1.1.1.1\n\n[Peer]\n..."
}The config field contains the full WireGuard configuration file content for the peer. You can also retrieve a QR code for easy mobile setup:
curl http://localhost:8080/api/vpn/peers/peer_abc123/qrcode \
-H "Authorization: Bearer <token>"Cluster
Cluster mode (see Clustering). Admin endpoints need an admin session. Node-to-primary endpoints use per-node credentials; join endpoints are public but token-authenticated and rate-limited.
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/cluster/overview | admin | Role, Raft term / leader, mesh RTT, every node with metrics |
POST | /api/cluster/init | admin | Make this node the primary; returns the overview |
POST | /api/cluster/tokens | admin | Create a one-time join token (ttl_minutes, labels) + install command |
GET | /api/cluster/tokens | admin | Active tokens (no secrets) |
DELETE | /api/cluster/tokens/{id} | admin | Revoke a token (204) |
GET | /api/cluster/join.sh | public | Join script (installs Cloud OS if missing) |
POST | /api/cluster/join | admin or local key | Worker side: send a join request to a primary |
POST | /api/cluster/join-requests | join token | Worker → primary: register as pending → {request_id} |
GET | /api/cluster/join-requests/{id} | X-Join-Token | Poll: pending / approved / rejected; credentials exactly once |
POST | /api/cluster/heartbeat | Authorization: Node <id>:<secret> | Metrics + apps in, directives out |
POST | /api/internal/workflow/execute-step | cluster network + X-Quazzar-Node-Signature | Primary → node: run one delegated workflow step |
POST | /api/cluster/nodes/{id}/approve · /reject | admin | Decide a pending node (204) |
POST | /api/cluster/nodes/{id}/cordon · /uncordon · /wake | admin | Node actions (204; wake → 409 without a MAC) |
POST | /api/cluster/nodes/{id}/drain | admin | Cordon + move apps → {moves, manual} |
PUT | /api/cluster/nodes/{id}/labels | admin | {"labels":{"gpu":"rtx4090"}} (object or legacy string) |
DELETE | /api/cluster/nodes/{id} | admin | Remove node, revoke its credential |
GET | /api/cluster/services | admin | {services:[{id, app_id, app, template, node_id, node_host, port, protocol, state, moved_at}]} |
GET / POST | /api/cluster/placement | admin | List (array) / create placement rules |
PUT / DELETE | /api/cluster/placement/{id} | admin | Update / delete a rule |
POST | /api/cluster/rebalance[?dry_run=1] | admin | {moves:[{app, from, to}]} |
POST | /api/cluster/migrate | admin | Move one stateless app (app_id, to_node / to_node_id, optional from_node / from_node_id) |
GET | /api/cluster/events?limit=20 | admin | {events:[{id, at, kind, severity, node_id, text}]} |
GET | /api/ha/status | user | Raft state, leader_id, term, peers |
POST | /api/ha/drill | admin | Failover drill → {ok, seconds} |
Service state is one of running, rescheduling, pending, failed, manual. Event kind is one of join_requested, approved, rejected, joined, heartbeat_lost, recovered, apps_moved, rescheduling_manual, cordoned, uncordoned, drained, removed, woken, rebalanced, rolling_update, failover_drill, leader_changed.
Example: Create a join token
curl -X POST https://quazzar-01.local/api/cluster/tokens \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"ttl_minutes":15,"labels":{"zone":"home"}}'{
"id": "6f1c…",
"token": "qz_Vh3…",
"expires_at": "2026-09-24T12:15:00Z",
"labels": {"zone": "home"},
"command": "curl -sfL https://quazzar-01.local/api/cluster/join.sh | sudo sh -s -- --token qz_Vh3… --primary https://quazzar-01.local"
}Example: Placement rule
curl -X POST https://quazzar-01.local/api/cluster/placement \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"priority":10,"apps":["ollama","localai"],"selector":"gpu=rtx4090"}'Selector grammar: comma-separated key=value, key (exists), !key (absent). The legacy {"app_template","label_selector"} body is still accepted.
Delegated workflow steps (primary → node)
A workflow step with a target node is sent to that node’s POST /api/internal/workflow/execute-step. The node accepts it only from the cluster network (10.42.0.0/16, TCP peer — forwarded-for headers are ignored) and with a valid signature made with its own node credential. The primary stores only the SHA-256 of each node secret, so it signs with that hash:
POST /api/internal/workflow/execute-step
X-Quazzar-Node-Signature: <node_id>:<unix_seconds>:<hex HMAC-SHA256>
{"step":{"id":"s1","type":"transform","config":{…}},"input":{…}}The HMAC key is hex(sha256(node_secret)) and the message is node_id + "\n" + unix_seconds + "\n" + hex(sha256(body)). The node rejects (401) a signature for another node id, one more than 2 minutes off its clock, a changed body, or a signature it has already seen. A node whose credential was revoked cannot be delegated to.
Heartbeat (node → primary)
POST /api/cluster/heartbeat
Authorization: Node n-3f9a12bc:<node_secret>
{"cpu":24.0,"mem":48.0,"disk":61.0,"version":"0.10.4","rtt_ms":3.1,
"apps":[{"app_id":"…","template":"whoami","status":"running","port":8081,"protocol":"http"}]}{"directives":[{"id":"…","kind":"deploy","app":{"service_id":"…","template":"whoami","name":"Whoami"}}]}Alerts
Configure alert rules and notification channels to monitor app and system health.
| Method | Path | Description |
|---|---|---|
GET | /api/alerts/rules | List alert rules |
POST | /api/alerts/rules | Create an alert rule |
GET | /api/alerts/channels | List notification channels |
POST | /api/alerts/channels | Create a notification channel |
POST | /api/alerts/channels/{id}/test | Send a test notification |
Example: Create an Alert Rule
curl -X POST http://localhost:8080/api/alerts/rules \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"name": "High CPU Usage",
"metric": "system.cpu.usage_percent",
"condition": "greater_than",
"threshold": 90,
"duration": "5m",
"channel_id": "ch_abc123",
"severity": "warning"
}'Example: Create a Notification Channel
curl -X POST http://localhost:8080/api/alerts/channels \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"name": "Ops Team Slack",
"type": "slack",
"config": {
"webhook_url": "https://hooks.slack.com/services/T00/B00/xxxx"
}
}'Security
Run security scans, view findings, and manage Web Application Firewall (WAF) rules.
| Method | Path | Description |
|---|---|---|
GET | /api/security/score | Get the overall security score |
POST | /api/security/scan | Trigger a security scan |
GET | /api/security/findings | List security findings |
GET | /api/waf/rules | List WAF rules |
Example: Get Security Score
curl http://localhost:8080/api/security/score \
-H "Authorization: Bearer <token>"{
"overall_score": 85,
"categories": {
"tls": 95,
"headers": 80,
"firewall": 90,
"authentication": 75,
"updates": 85
},
"findings_count": {
"critical": 0,
"high": 1,
"medium": 3,
"low": 5
}
}Example: Trigger a Security Scan
curl -X POST http://localhost:8080/api/security/scan \
-H "Authorization: Bearer <token>"{
"scan_id": "scan_abc123",
"status": "running",
"started_at": "2025-01-15T10:30:00Z"
}API Keys
Manage API keys for programmatic access to the Cloud OS instance.
| Method | Path | Description |
|---|---|---|
GET | /api/keys | List all API keys |
POST | /api/keys | Create a new API key |
DELETE | /api/keys/{id} | Revoke an API key |
See the Authentication page for detailed examples of creating and using API keys.
Webhooks
Configure outgoing webhooks to receive notifications when events occur on the Cloud OS instance.
| Method | Path | Description |
|---|---|---|
GET | /api/webhooks | List configured webhooks |
POST | /api/webhooks | Create a new webhook |
PUT | /api/webhooks/{id} | Update a webhook |
DELETE | /api/webhooks/{id} | Delete a webhook |
See Webhooks for detailed webhook setup, event types, and signature verification.
Timeline
The system event timeline shown on the Overview screen. Events are recorded automatically in the background (recording never slows down the request that caused it).
| Method | Path | Description |
|---|---|---|
GET | /api/timeline | Recent events, newest first. Query: category, from, to (RFC 3339), limit (1–200, default 50) |
GET | /api/timeline/stats | Event counts per category |
| Category | Recorded events |
|---|---|
apps | App installed (with version), updated (to version), uninstalled, failed to install/update, crashed, ran out of memory |
auth | Successful sign-in — Sign-in from MacBook · Berlin; 5+ failed sign-ins from one IP within 15 minutes (warning) |
security | Security scan · N critical (critical severity when N > 0), scan failures |
backups | Backup completed · 412 GB, Backup verified · 412 GB, backup / verification failures (error) |
alerts | Alert fired (severity from the rule), alert resolved |
system | Update installed · v0.10.4 (recorded on the first boot of a new version) |
Sign-in events name the device from the browser’s User-Agent. Private / LAN addresses read local network. The city of a public address is only shown when an offline MaxMind GeoLite2 City database is configured (auth.geoip_db in the node config or the QUAZZAR_GEOIP_DB environment variable); nothing is looked up online.
curl "http://localhost:8080/api/timeline?category=backups&limit=5" \
-H "Authorization: Bearer <token>"{
"events": [
{
"id": "6f1c…",
"event_type": "backup.verified",
"category": "backups",
"title": "Backup verified · 412 GB",
"description": "",
"severity": "info",
"source": "backups",
"metadata": { "backup_id": "b1", "verification_id": "v1" },
"created_at": "2026-09-24T02:05:00Z"
}
],
"count": 1
}Personal mode (Home)
Per-user endpoints behind the Personal-mode Home screen. All require a session or API key and only ever return the caller’s own data.
Weather
| Method | Path | Description |
|---|---|---|
GET | /api/weather | Current conditions and today’s summary. Query: lat, lon (both or neither), units (metric default, or imperial) |
Forecasts come from Open-Meteo (no API key) through the node, cached for 15 minutes per location rounded to two decimals. Without lat/lon the node’s approximate location (the same IP lookup as /api/system/location) is used and location.city is filled in; for explicit coordinates city is "". rain_at is the local time of the first remaining hour today with a precipitation probability of 50 % or more, otherwise null. When the node is offline the endpoint answers 503 {"error":"weather unavailable"}.
{
"location": { "city": "Lisbon", "country": "Portugal", "lat": 38.72, "lon": -9.14 },
"units": "metric",
"current": { "temp": 22.4, "code": 0, "condition": "clear", "is_day": true },
"today": { "high": 25.1, "low": 16.3, "precip_probability_max": 70, "rain_at": "19:00" },
"updated_at": "2026-09-24T15:00:00Z"
}To-dos
| Method | Path | Description |
|---|---|---|
GET | /api/todos | The caller’s to-dos, open first (done ascending, then position) |
POST | /api/todos | Create — body {"text": "…", "due_at": null}; appended at the end; 201 |
POST | /api/todos/import | Bulk import {"todos": [{"text": "…", "done": true}]}; skips empty text and text you already have; returns the full list |
PATCH | /api/todos/{id} | Update any of text, done, position, due_at (null clears) |
DELETE | /api/todos/{id} | Delete; 204 |
POST | /api/todos/clear-done | Delete every completed to-do; 204 |
text must be 1–500 characters. due_at accepts RFC 3339 or YYYY-MM-DD. Setting position moves the item to that index of the list as GET returns it and renumbers the rest, so position always equals the item’s index. Ids belonging to another user answer 404.
{ "todos": [ { "id": "3f0c…", "text": "Book dentist", "done": false, "position": 0, "due_at": null,
"created_at": "2026-09-24T09:12:00Z", "updated_at": "2026-09-24T09:12:00Z" } ] }Drive usage
| Method | Path | Description |
|---|---|---|
GET | /api/drive/usage | How much of your Drive is used, split into Photos, Docs, Music, Video and Other |
Files in the trash are not counted. Files are grouped by MIME type (image/*, video/*, audio/*; text, PDF and office documents are Docs), falling back to the file extension. total_bytes is your quota when one is set, otherwise the size of the disk that holds Drive storage; quota_bytes is null when there is no quota. The figures are cached for 5 minutes. The endpoint answers with zeros when Drive is not running.
{ "used_bytes": 442381631488, "total_bytes": 2000000000000, "quota_bytes": null,
"categories": [ { "key": "photos", "label": "Photos", "bytes": 240000000000 },
{ "key": "docs", "label": "Docs", "bytes": 100000000000 },
{ "key": "music", "label": "Music", "bytes": 60000000000 },
{ "key": "video", "label": "Video", "bytes": 30000000000 },
{ "key": "other", "label": "Other", "bytes": 12381631488 } ] }Now playing
| Method | Path | Description |
|---|---|---|
GET | /api/music/now-playing | What you are listening to, on whichever device reported last |
PUT | /api/music/now-playing | Report playback state (the player calls this as you play, pause and seek) |
The PUT body has the same shape as the response without updated_at. Only track.path is required: when title, artist or album are missing they are taken from the path (Artist/Album/03 - Title.flac). device defaults to a name derived from the browser (MacBook, iPhone, …). Send "track": null to clear the card. cover_url is only kept when it is a same-origin path, otherwise null. If a device stops reporting while playing is true, the state reads as playing: false once the rest of the track plus 30 seconds has passed. Before anything was played GET returns "track": null, "playing": false and "updated_at": null.
{ "track": { "path": "Music/Nils Frahm/Spaces/Says.flac", "title": "Says", "artist": "Nils Frahm",
"album": "Spaces", "cover_url": null },
"duration_s": 502, "position_s": 167, "playing": true, "device": "MacBook",
"updated_at": "2026-09-24T15:00:00Z" }Recent Orbit files
| Method | Path | Description |
|---|---|---|
GET | /api/orbit/recent | Your documents, spreadsheets, slide decks, notes and whiteboards, most recently changed first. Query: limit (default 20, max 100) |
kind is one of doc, sheet, slides, note, whiteboard, and path is the page that opens the item (/docs/:id, /sheets/:id, /slides/:id, /notes?id=:id, /whiteboards/:id). Notes saved by external AI agents through Memory MCP are left out. Orbit files currently have a single owner — share links are anonymous and live co-editing is not attributed — so collaborators lists the owner and last_edited_by is the owner.
{ "items": [ { "id": "abc", "kind": "sheet", "title": "Family budget 2025", "updated_at": "2026-09-24T12:00:00Z",
"path": "/sheets/abc", "last_edited_by": { "id": "u1", "name": "Alex Moreau" },
"collaborators": [ { "id": "u1", "name": "Alex Moreau", "avatar_url": null } ] } ] }AI
Interact with locally hosted AI models for chat and RAG (Retrieval-Augmented Generation) search.
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/ai/models | session | List available AI models |
POST | /api/ai/models/pull | admin | Download a model |
DELETE | /api/ai/models/{name} | admin | Delete a model |
POST | /api/ai/ollama/install · /start · /stop · /reconfigure, DELETE /api/ai/ollama | admin | Manage the Ollama runtime |
POST | /api/ai/localai/install · /start · /stop, DELETE /api/ai/localai | admin | Manage the LocalAI runtime |
POST / PUT / DELETE | /api/ai/providers…, /api/ai/routing…, PUT /api/ai/cost/budget | admin | Node-wide provider, routing and budget config (reads: session) |
GET / PUT | /api/ai/cost/budget | session / admin | Monthly budget plus daily_cap_usd and per_user_daily_cap_usd (USD per UTC day, 0 = off; omitted on PUT = unchanged); GET also returns spent_today_usd and the caller’s my_spent_today_usd |
GET / PUT | /api/ai/agent-loop | session / admin | Molly agent loop: max_iterations (2–40, 0 = default 10) and tiers — { "<provider id>": { "fast", "standard", "deep" } }, empty = routed model |
GET | /api/ai/health/canary | admin | AI health check status: enabled, interval_seconds, running, next_run_at, and the last report (status, per-check checks[]) |
POST | /api/ai/health/canary/run | admin | Run the AI health check now and return {report}; 409 while a run is in progress, 503 when disabled |
POST / DELETE | /api/ai/mcp/… | admin | Add / remove / connect MCP servers, call MCP tools (reads: session) |
POST / DELETE | /api/ai/finetune/… | admin | Fine-tune jobs, datasets, evaluations (reads: session) |
POST | /api/voice/install/{engine} | admin | Install whisper.cpp / Piper (Orbit Pro) |
GET / POST | /api/ai/conversations | session | List your conversations / start one (you become its owner) |
GET / DELETE | /api/ai/conversations/{id} | owner | Read / delete a conversation |
GET | /api/ai/conversations/{id}/stream | owner | Chat (SSE) |
POST | /api/ai/conversations/{id}/compact | owner | Summarise older messages |
GET | /api/ai/memory | session | List (?ns=) or search (?q=) the node-wide memory store |
POST | /api/ai/memory | admin | Store or update an entry ({namespace, key, value, metadata}) |
DELETE | /api/ai/memory/{id} | admin | Delete an entry |
POST | /api/ai/agents/{id}/feedback | session | Rate an agent interaction (1–5) |
GET | /api/ai/agents/{id}/knowledge | session | Patterns the agent learned from feedback |
DELETE | /api/ai/agents/{id}/knowledge/{kid} | admin | Remove a learned pattern |
POST | /api/rag/search | session | Perform a RAG search over indexed documents |
Conversations are private. AI Hub (/api/ai/conversations…) and Molly (/api/molly/conversations…, including /stream, PATCH, DELETE and /cost) only ever list the caller’s own conversations, and every request for another user’s conversation answers 404 conversation not found — the same as a conversation that does not exist. Admins get no exception. Conversation objects carry user_id. DELETE /api/molly/memory/{id} only deletes the caller’s own memory entries. AI Hub conversations created before this change belong to the node’s first admin.
The AI Hub memory store and agent knowledge are node-wide (no owner): every signed-in user can read them, only admins can change them. The GraphQL appLogs query was removed (it returned placeholder data); read logs through the admin-only REST routes.
Skill changes (/api/skills/create, /install, /marketplace/{id}/install, /{id}/toggle, PUT /{id}/schedule, /{id}/edit, DELETE /{id}), agent bundle import (POST /api/marketplace/agents/import) and import-wizard installs (/api/import/compose/install, /api/import/{casaos,umbrel}/import) are admin only too; running an installed skill (POST /api/skills/{id}/run) stays open to signed-in users.
Workflows
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/ai/workflows, /api/ai/workflows/{id}, …/executions | session | Read workflows and runs |
POST / PUT / DELETE | /api/ai/workflows… | admin | Create, save, delete, run, cancel, schedule |
Workflows carry created_by: the admin who created or last saved (PUT) the workflow. Workflows created before this field existed have created_by: "". When a run starts — manually, on its schedule or with steps delegated to another cluster node — a workflow whose created_by is empty or no longer an admin does not run its http steps or steps with a target_node: each such step fails with blocked: this workflow has no admin owner, so http and cluster steps do not run; an admin must open and save it once, the monitor emits a step_blocked event, and the execution ends failed with that message in error. Other steps (agent, transform, condition, delay) run as before. Open and save the workflow as an admin to adopt it.
POST /api/ai/workflows/{id}/execute answers 202 {"execution_id", "status": "running"} and the run continues in the background after the response — closing the tab does not stop it. Each run has its own 6-hour timeout (the execution then ends failed with timed out after 6h0m0s); POST /api/ai/workflows/{id}/executions/{execId}/cancel stops it early.
Molly chat stream events
GET /api/molly/conversations/{id}/stream?message=… answers with Server-Sent Events. The first event of a successfully routed turn is meta, describing the model the router picked; local is true when the provider runs on this node (Ollama, LocalAI):
event: meta
data: {"model":"llama3.1:8b","provider":"ollama","local":true}
event: chunk
data: {"content":"Your disks look healthy…"}
event: integrity
data: {…}
event: done
data: {}Other events keep their existing order: tool_call / tool_result (when Molly uses tools), chunk, integrity, done, or ai_error on failure.
Example: Chat with a Local AI Model
curl -X POST http://localhost:8080/api/ai/chat \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"model": "llama3",
"messages": [
{
"role": "user",
"content": "How do I configure Nextcloud to use external storage?"
}
]
}'Example: RAG Search
curl -X POST http://localhost:8080/api/rag/search \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"query": "backup encryption setup",
"limit": 5
}'Marketplace
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/marketplace, /api/marketplace/{id}, …/reviews, /api/marketplace/{category}/items… | session | Browse |
POST | /api/marketplace/submit, /api/marketplace/{id}/rate, /api/marketplace/community/… | session | Submit, rate, review |
POST | /api/marketplace/{id}/install, /api/marketplace/{category}/items/{name}/install, /api/marketplace/agents/{id}/install | admin | Install |
POST / DELETE | /api/marketplace/{id}/uninstall, DELETE /api/marketplace/{id}/install | admin | Uninstall |
PUT | /api/marketplace/reviews/{id} | admin | Approve or reject a submission (publishes it to every user) |
Any marketplace write whose path has an install, uninstall, reinstall, remove or import segment needs the admin role.
| Method | Path | Auth | Description |
|---|---|---|---|
GET / PUT | /api/mail/relay | admin | Outbound SMTP relay (password never returned) |
GET / PUT | /api/mail/stack, GET /api/mail/stack/compose | admin | Postfix / Dovecot / Rspamd stack |
GET / POST / PUT / DELETE | /api/mail/mailboxes… | admin | Every user’s mailbox rows |
GET | /api/mail/status | session | Is the stack on, and does the caller have a mailbox |
POST | /api/mail/provision | session | Create the caller’s mailbox |
GET | /api/mail/folders, /api/mail/inbox, /api/mail/messages/{folder}/{uid}[/attachments/{part}] | session | Read the caller’s own mailbox |
POST | /api/mail/messages/{folder}/{uid}/flag · /move, /api/mail/send | session | Act on the caller’s own mailbox (the sender is always the caller’s address) |
Webmail routes never take a mailbox id: the mailbox is resolved from the signed-in user, so a user can only reach their own.
Family
The supervisor of a Family-managed node is its admin. The Control Panel reaches the supervisor routes over its agent tunnel.
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/family/banner | public | Is monitoring active (shown before sign-in) |
GET | /api/family/self | session | What is collected about users of this node, and retention |
GET | /api/family/managed-status | session | Is the node enrolled with a Control Panel |
GET | /api/family/hardening | session | LUKS / BIOS / GRUB password attestation (three booleans) |
GET / PUT | /api/family/users, /api/family/users/{uid}/monitor | admin | Monitored users |
GET / PUT | /api/family/policies/{uid} | admin | Policies |
GET | /api/family/events, /alerts, /summary/daily, /heartbeat | admin | Activity, alerts, daily summary, daemon heartbeat |
POST | /api/family/screen/start/{uid} · /screen/stop/{uid}, GET /api/family/screen/stream | admin | Live screen view (Orbit Pro) |
PUT | /api/family/hardening | admin | Record the hardening attestation |
GET | /api/family/instance-id | admin | Id to paste into the Control Panel when enrolling |
POST | /api/family/pair/generate · /pair/verify | admin | Pairing codes |
POST | /api/family/uninstall | admin | Wipe all Family data on the node |
Orbit Forms (public)
A published form is reachable without a session; its public token is the credential. Unknown or unpublished tokens return 404.
| Method | Path | Description |
|---|---|---|
GET | /f/{token} | The public form page (HTML — the web app with the form embedded as <script id="form-schema" type="application/json">) |
GET | /api/public/forms/{token}/schema | The same form as JSON |
POST | /forms/{token}/submit | Submit a response (multipart/form-data, rate-limited) |
{
"id": "…", "public_token": "…", "title": "Contact us", "description": "",
"schema": { "fields": [ { "id": "f1", "type": "text", "label": "Name", "required": true } ] },
"require_login": false, "captcha_enabled": false
}Audit
View the audit log of all administrative actions performed on the Cloud OS instance.
| Method | Path | Description |
|---|---|---|
GET | /api/audit/logs | List audit log entries |
Example: Get Audit Logs
curl "http://localhost:8080/api/audit/logs?limit=50" \
-H "Authorization: Bearer <token>"[
{
"id": "aud_abc123",
"action": "app.installed",
"resource": "app_def456",
"details": "Installed Nextcloud from template",
"ip_address": "192.168.1.100",
"timestamp": "2025-01-15T10:30:00Z"
},
{
"id": "aud_def456",
"action": "backup.created",
"resource": "bak_ghi789",
"details": "Manual backup to s3-primary",
"ip_address": "192.168.1.100",
"timestamp": "2025-01-15T10:25:00Z"
}
]Audit logs are immutable and cannot be deleted through the API. They are retained according to the configured retention policy.
Memory MCP
Quazzar ships a built-in Model Context Protocol server. External AI clients (Claude Desktop, Claude Code, ChatGPT, Cursor, VS Code Copilot) connect with a bearer token and read/write durable memories backed by the same notes store the UI uses.
See Memory MCP for the tool / resource / prompt surface and Memory MCP quickstart for the wiring walkthrough.
MCP transport endpoints
| Method | Path | Description |
|---|---|---|
GET / POST | /mcp | Streamable HTTP MCP endpoint (MCP spec 2025-03+). Bearer authed. |
GET | /mcp/sse | Legacy SSE event stream for older clients. |
POST | /mcp/messages | Legacy SSE JSON-RPC sink paired with /mcp/sse. |
Authorization header on every request: Authorization: Bearer quaz_....
API keys
Session-authed endpoints (used by the Settings UI) for managing MCP bearer tokens.
| Method | Path | Description |
|---|---|---|
GET | /api/mcp/keys | List every key owned by the current user (metadata only — raw bearer is never returned) |
POST | /api/mcp/keys | Create a key. Raw bearer is returned in the response once. |
DELETE | /api/mcp/keys/{id} | Revoke a key. Immediate — the client loses access on the next request. |
POST | /api/mcp/keys/{id}/rotate | Revoke the existing key and mint a new one with the same name, scopes, namespace, and expiry window. Raw bearer is returned once. |
Create key
curl -X POST http://localhost:8080/api/mcp/keys \
-H "Authorization: Bearer <session-token>" \
-H "Content-Type: application/json" \
-d '{
"name": "Claude on my laptop",
"client_type": "claude_desktop",
"namespace": "work",
"scopes": ["read_memory", "write_memory"],
"expires_in_days": 90
}'{
"id": "key_abc123",
"name": "Claude on my laptop",
"client_type": "claude_desktop",
"namespace": "work",
"scopes": ["read_memory", "write_memory"],
"key_prefix": "quaz_AB12CDE",
"expires_at": "2026-07-21T10:30:00Z",
"raw": "quaz_AB12CDEFGH345JKLMNPQRSTVWXYZ2"
}The raw field is shown only on create and rotate. Raw bearers are bcrypt-hashed on disk.
Rotate key
curl -X POST http://localhost:8080/api/mcp/keys/key_abc123/rotate \
-H "Authorization: Bearer <session-token>"Returns the same shape as create, with a new raw bearer. The old bearer is revoked in place (ErrRevoked on use).
Active sessions
| Method | Path | Description |
|---|---|---|
GET | /api/mcp/sessions | List every live MCP stream bound to the current user |
DELETE | /api/mcp/sessions/{id} | Disconnect a specific session |
curl http://localhost:8080/api/mcp/sessions \
-H "Authorization: Bearer <session-token>"[
{
"id": "sess_abc",
"client_name": "claude-desktop",
"transport": "streamable-http",
"ip": "192.168.1.42",
"connected_at": "2026-04-21T10:10:00Z",
"last_active_at": "2026-04-21T10:30:00Z"
}
]Each MCP connection is tracked as a separate session — restarting a client opens a new one.
Embedding and analytics
| Method | Path | Description |
|---|---|---|
GET | /api/mcp/memory/embed-stats | Embedding backend status, queue depth, and coverage |
POST | /api/mcp/memory/reindex | Push every note owned by the caller onto the embedding queue |
GET | /api/mcp/memory/analytics | Aggregate totals, top tags, top clients, embedding coverage over time |
curl http://localhost:8080/api/mcp/memory/embed-stats \
-H "Authorization: Bearer <session-token>"{
"queue_size": 0,
"total": 1250,
"total_with_embedding": 1247,
"backend_name": "ollama",
"backend_dim": 768,
"enabled": true
}curl -X POST http://localhost:8080/api/mcp/memory/reindex \
-H "Authorization: Bearer <session-token>"{ "enqueued": 1250, "enabled": true }Pending items collapse via the queue’s UNIQUE(note_id) constraint, so triggering a reindex on a noisy node still completes in bounded work.
Privacy guard and visibility
Owner-only: each route acts on the signed-in user’s own memories. See Memory MCP → Privacy guard.
| Method | Path | Description |
|---|---|---|
GET | /api/mcp/memory/quarantine | Writes the privacy guard held, newest first ({"items": [...]}) |
POST | /api/mcp/memory/quarantine/{id}/release | Store a held write as a private memory (a held update is applied in place); returns the memory |
DELETE | /api/mcp/memory/quarantine/{id} | Discard a held write ({"discarded": true}) |
PUT | /api/mcp/memory/notes/{id}/visibility | Set a memory’s tier: {"visibility": "shared" | "workspace" | "private"}. The only way to lower a tier |
{
"items": [
{
"id": "0b8c…",
"namespace": "default",
"title": "Landlord",
"content": "Rent goes to DE89 …",
"tags": ["mcp", "memory"],
"client_name": "claude_desktop",
"pii_class": "financial_iban",
"detector": "mod97",
"detail": "an IBAN bank account number",
"created_at": "2026-09-25T10:00:00Z"
}
]
}POST /api/molly/memory/remember answers 422 with the reason when the guard blocks the text, and 202 with {"status": "quarantined", "quarantine_id": …} when it holds it.
Cross-node sync (Orbit Pro)
| Method | Path | Description |
|---|---|---|
GET | /api/mcp/sync/status | List enabled namespaces, timestamps, and errors |
POST | /api/mcp/sync/enable | Body: { "namespace": "work" }. Opts the namespace in to replication |
POST | /api/mcp/sync/disable | Body: { "namespace": "work" }. Pauses but keeps cursor |
POST | /api/mcp/sync/resync | Body: { "namespace": "work" }. Forces one push + one pull pass |
POST | /api/mcp/sync/reset | Body: { "namespace": "work" }. Drops cursor and outbox |
Enable returns HTTP 402 Payment Required with the canonical upgrade shape when the caller is on the Community plan.
Notes graph
Session-authed endpoints for the Orbit Notes mind-map view.
| Method | Path | Description |
|---|---|---|
GET | /api/notes | List your notes, most recently updated first. Query: folder, limit (positive integer, optional), sort=updated (optional; the default order) |
GET | /api/notes/graph | Return nodes + links with filter support |
PATCH | /api/notes/{id}/meta | Adjust importance or toggle archived on any note |
GET /api/notes/graph
Every filter on the Notes mind-map toolbar maps onto a query parameter:
| Param | Values | Default |
|---|---|---|
source | Comma-separated user,mcp,molly | all three |
namespace | Comma-separated namespace names | all |
client | Comma-separated mcp_client_name values | all |
tags | Comma-separated tag names | no filter |
min_importance | 0.0..1.0 | 0 |
include_archived | 1 / 0 | 0 |
curl "http://localhost:8080/api/notes/graph?source=user,mcp&namespace=work,personal&client=claude-desktop&tags=urgent,review&min_importance=0.4&include_archived=1" \
-H "Authorization: Bearer <session-token>"{
"nodes": [
{
"id": "note_abc",
"title": "Incident 2026-04-19",
"source": "mcp",
"namespace": "work",
"client": "claude-desktop",
"importance": 0.7,
"created_at": "2026-04-19T14:32:00Z",
"updated_at": "2026-04-19T14:32:00Z",
"last_referenced_at": "2026-04-20T09:10:00Z"
}
],
"links": [
{
"source": "note_abc",
"target": "note_def",
"source_kind": "mcp",
"target_kind": "user",
"mixed_sources": true
}
]
}PATCH /api/notes/{id}/meta
curl -X PATCH http://localhost:8080/api/notes/note_abc/meta \
-H "Authorization: Bearer <session-token>" \
-H "Content-Type: application/json" \
-d '{ "importance": 0.75 }'Accepted bodies:
{ "importance": 0.75 }— set importance to a0.0..1.0value.{ "archived": true }— appends#archived, drops importance to 0.{ "archived": false }— strips#archived, leaves importance untouched.
External MCP callers route through memory_update / memory_delete instead, which enforce per-key scope checks.
Molly memory
Session-authed endpoints for Molly’s memory slash commands and settings. Not MCP — external clients use /mcp instead.
| Method | Path | Description |
|---|---|---|
POST | /api/molly/memory/remember | Persist a new memory (body: content, title?, tags?, importance?) |
POST | /api/molly/memory/recall | Hybrid search bound to the user + Molly namespace |
GET | /api/molly/memory/recent?limit=N | List the N most recently referenced Molly memories |
POST | /api/molly/memory/clear | Soft-delete every memory in the Molly namespace |
DELETE | /api/molly/memory/mcp/{id} | Soft (default) or hard (?hard=true) delete a single memory |
GET | /api/molly/memory/health | Knowledge health report: last_run, counts, open review items, top gaps, switches |
POST | /api/molly/memory/health/run | Run the knowledge healer for the caller now; returns the fresh report (409 when QUAZZAR_AI_MEMORY_HEALER=off, 429 + Retry-After within a minute of the previous run) |
POST | /api/molly/memory/health/items/{id} | Resolve a review item. Body: action (archive, keep, merge), target_id? (either memory of a contradiction). 204; 400 for an unknown action or an already-resolved item; 404 for another user’s item |
POST | /api/molly/memory/health/gaps/{id}/dismiss | Hide a knowledge gap (204 / 404) |
POST | /api/molly/messages/{id}/feedback | Rate one of the caller’s Molly replies. Body: rating (up / down) or score (1–5). Returns {rating, changed, adjusted, delta}; 404 for a missing, non-assistant or foreign message |
curl -X POST http://localhost:8080/api/molly/memory/remember \
-H "Authorization: Bearer <session-token>" \
-H "Content-Type: application/json" \
-d '{
"content": "Deployed v0.6.35 to node-01.",
"title": "Deploy 2026-04-21",
"tags": ["deploy"],
"importance": 0.7
}'All endpoints return 503 Service Unavailable when the MCP Memory service is not wired (air-gap builds).
Billing and Licensing
Feature gates for the Orbit Pro paid tier. See Orbit Pro for the tier matrix.
| Method | Path | Description |
|---|---|---|
GET | /api/license/orbit-features | Projection of the current plan’s Orbit feature gate map |
POST | /api/billing/upgrade | Start a Stripe Checkout session (proxied through the CP) |
Feature gate projection
curl http://localhost:8080/api/license/orbit-features \
-H "Authorization: Bearer <session-token>"{
"plan": "community",
"features": {
"orbit_collab": false,
"orbit_graph": false,
"orbit_full_formulas": false,
"orbit_charts": false,
"orbit_pivot": false,
"orbit_history": false,
"orbit_advanced_export": false,
"orbit_memory_sync": false,
"orbit_unlimited_shares": false,
"orbit_slides_themes": false
},
"limits": {
"share_links_max": 3,
"mcp_rate_per_minute": 30,
"version_history_hours": 24
}
}The web client uses this to render gated controls before the user clicks.
Upgrade flow
curl -X POST http://localhost:8080/api/billing/upgrade \
-H "Authorization: Bearer <session-token>" \
-H "Content-Type: application/json" \
-d '{ "plan": "pro", "return_to": "https://node.example.com/docs" }'{
"checkout_url": "https://checkout.stripe.com/c/pay/cs_live_..."
}The request is proxied through the Control Panel to Stripe. After checkout, Stripe redirects to return_to with a session id. The CC agent picks up the new plan on the next heartbeat — the node’s license.Gate is wired to the agent’s plan accessor, so the feature unlocks on the next request without a restart.
Canonical 402 response
Every gated path returns HTTP 402 Payment Required on the Community plan with this shape:
{
"error": "upgrade_required",
"feature": "orbit_pivot",
"current_plan": "community",
"required_plan": "pro",
"message": "Pivot tables require Orbit Pro.",
"upgrade_url": "/api/billing/upgrade"
}Clients catch the 402 via isUpgradeError() and render either an inline <UpgradePrompt> or open a modal <OrbitUpgradeDialog> so the user can upgrade without losing the action that triggered the gate.