Skip to main content

HTTP API

Every node serves one HTTPS API and the web UI on web.address:web.port (default port 8443). The web UI uses exactly this API, so anything you can click you can script. All paths are under /api, /auth, /oauth, /.well-known or /mcp; any other GET returns the web UI. Examples use cloud.example.com and a key in $KEY.

note

This page was written from reading the code. Only unauthenticated requests were tried against a running node: API paths answer 401 with JSON, and any other GET returns the web UI.

Authentication​

There are two ways in.

  • API key. Send Authorization: Bearer clk_<id>_<secret>. An API key is always an admin. Only a hash is stored; the plaintext is returned once by POST /api/keys. The first key of a new cluster is called bootstrap and is printed to the log once.
  • Session cookie. POST /auth/login with {"key": "clk_..."} returns the identity and sets the cl_session cookie (HttpOnly; Secure; SameSite=Lax). An OIDC login (access and login) sets the same cookie. The cookie is how browser EventSource streams authenticate, because they cannot send headers.

In the tables below, Auth means:

ValueMeaning
nonePublic.
userAny signed-in caller: an API key or a session.
adminAn API key, or an OIDC session whose admin claim matches. A viewer gets 403 admin role required.
tokenA secret token in the URL path.
internalNode-to-node, signed with the cluster key. Not usable from scripts.

A malformed Authorization header (for example Basic ...) is a 401 even if a valid cookie is also sent. The Bearer prefix is optional on the dashboard API but required on /mcp.

Check your key
curl -s -H "Authorization: Bearer $KEY" https://cloud.example.com/auth/me
# {"subject":"...","username":"...","role":"admin","mcp_enabled":true}

CORS is permissive, so keep keys and cookies secret; do not rely on the browser's same-origin policy for this API.

Which node answers​

Send requests to any node. You do not need to know the leader.

Request on a followerResult
GET under /apiAnswered from that node's replicated state. It may lag; compare state_version from /api/status. Live container, usage and daemon-log data comes from the node that owns it.
POST, PUT, PATCH, DELETE under /api or /authRelayed to the leader (method, headers and a body up to 32 MiB). The leader's status, headers and body come back to you.
/auth/oidc/*Served locally, so login works from any node.
/api/git-backup/webhook, /api/git-backup/webhook/internal-trigger, any path containing /internal/Served locally.
GET /oauth/authorize307 redirect to the leader.
POST /oauth/token, POST /oauth/registerRelayed to the leader.
/mcpRelayed to the leader (body up to 5 MiB).
No leader known503.
The not-leader 503 with Location

Older documentation says a follower answers a write with 503 and a Location header pointing at the leader. The current code relays the write instead, and never sends a Location header, so clients do not need to follow redirects. The error type not the leader still exists (status 503) but is only built without a leader address.

Errors​

An error is {"error": "<message>"}. Read only error; a detail field exists in the type but is never filled in.

StatusMeaning
400Bad request (validation failed, bad body).
401Not authenticated. On /mcp it adds WWW-Authenticate.
403Forbidden (for example admin role required).
404Not found (for example no instance <id>).
409Conflict (for example an update already in progress, a stale settings save, this node holds no cluster key).
429Too many requests.
503Unavailable or not leader.
500Internal error.

Two 503s are not JSON: the plain text no cluster leader elected yet (a follower with no known leader), and the JSON could not reach the leader: ... when the relay fails. Scripts that parse JSON errors must handle the plain-text one.

Live streams (SSE)​

Five endpoints stream text/event-stream. With curl add -N:

Follow a container's logs
curl -N -H "Authorization: Bearer $KEY" \
"https://cloud.example.com/api/instances/vaultwarden.web.0/logs?follow=true&tail=50"
EndpointEventDataNotes
GET /api/eventsstateThe redacted cluster state (no secret values)Sent on connect, then whenever the state version changes (polled every 500 ms).
GET /api/instances/eventsprogress{instance_id, state, reason, step}; step is [n, m] or nullFirst event per instance is its current value, then only changes (polled every 1 s).
GET /api/instances/{id}/logsstdout, stderr, error{timestamp, text} per line; error carries a message stringQuery: follow (default false), tail (default 200), since (unix seconds). Without follow the stream ends after the tail. Relayed for remote instances.
GET /api/instances/{id}/statsstats{cpu_percent, memory_used_bytes, memory_limit_bytes, network_rx_bytes, network_tx_bytes, io_read_bytes, io_write_bytes}One sample per tick until you disconnect. Relayed for remote instances.
GET /api/nodes/{name}/logs (admin)log{seq, timestamp (unix ms), level, target, message}Query: tail (default 200, up to the 2000-line ring) and follow (default true). An unreachable node answers 503.

Endpoints​

Auth and session​

MethodPathAuthBodySuccessNotes
POST/auth/loginnone{"key":"clk_..."}200 identity, sets cl_session401 that is not an api key or invalid api key. Expects the bare key.
POST/auth/logoutnonenone200, clears cookie
GET/auth/meusernone{subject, username, role, mcp_enabled}role is admin or viewer.
GET/auth/oidc/login?return_to=nonenone303 to the provider400 oidc is not configured. return_to must be a same-origin path.
GET/auth/oidc/callback?code&statenonenone303 to the return path, sets cookie
GET/api/keysusernone[{id, name, created_at}]Never secrets.
POST/api/keysadmin{"name"?: string}{id, key}key is shown once.
DELETE/api/keys/{id}adminnone204404 no key <id>.

Cluster​

MethodPathAuthBodySuccessNotes
GET/api/statususernone{node, is_leader, has_quorum, leader, voters, reachable, managers_total, managers_reachable, version, state_version}Answered by this node.
GET/api/stateusernoneRedacted cluster state
GET/api/eventsusernoneSSE state
GET/api/nodes/connectivityusernone{reports: [{node, reported, disk_ms, links: [{peer, raft, api, raft_ms}]}]}Each node probes its peers itself: raft is an authenticated raft ping, api a TCP connect to the web port. disk_ms is a write plus fsync on that node's data dir, null if unreported, failed, or disk_probe: false.
GET/api/tls-fingerprintusernoneFingerprint of the certificate this node serves
GET/api/configusernoneCluster config JSON
PUT/api/configadmin{"config": {...}, "rollback_after": "60s"}200Applies the config and arms a rollback timer; call confirm before it expires.
POST/api/config/validateuser{"config": {...}}{valid, errors: [{path, message}]}
POST/api/config/confirmadminnone200Keeps the pending config.
POST/api/config/rollbackadminnone200Restores the previous config now.
GET/api/config/pendingusernone{armed, seconds_left}
POST/api/config/networks/{name}/renameadmin{"new_name": "..."}200Also rewrites stacks, redirects, static routes and WireGuard.
POST/api/config/drives/{name}/renameadmin{"new_name": "..."}200Also rewrites stacks' volume mounts.

Nodes​

MethodPathAuthBodySuccessNotes
GET/api/nodesusernone[{name, role, availability, reachable, container_count}]
POST/api/nodesadmin{name, role, rpc_addr, drives, labels}201role is manager or worker; rpc_addr is ip:port. Upserts the node.
GET/api/nodes/metricsusernone[{name, container_count, usage}]Unreachable nodes skipped.
GET/api/nodes/{name}usernone{name, role, availability, reachable, drives, container_count, usage}
PATCH/api/nodes/{name}admin{"availability": "active"} or "drain"empty on success
DELETE/api/nodes/{name}adminnoneempty on successRemoves the node from the config and validates the result.
POST/api/nodes/join-commandadmin{name, role?, address?}{command, key_id, address, fingerprint}Mints a key named join <name>, shown in the command once.
GET/api/nodes/{name}/metrics/historyusernone[{at, cpu_percent, memory_used_bytes}]Last 120 samples, 5 s apart.
GET/api/nodes/{name}/interfacesusernone["eth0", ...]
GET/api/nodes/{name}/logsadminnoneSSE logDaemon logs.

Stacks​

MethodPathAuthBodySuccessNotes
GET/api/stacksusernone[{name, revision, deployed_at}]
GET/api/stacks/{name}usernone{name, spec, revision, configs, secrets, used_configs, used_secrets, requirements, deployed_at, previous_spec}404 if missing.
PUT/api/stacks/{name}admin{"spec": "<yaml>", "configs": {name: base64}, "secrets": {name: base64}}200The server seals secrets; unreferenced ones are ignored.
DELETE/api/stacks/{name}adminnone204
POST/api/stacks/{name}/validateuser{"spec": "<yaml>"}{valid, errors: [{path, message}], requirements, per_service}Does not deploy.
GET/api/stacks/{name}/update-webhookadminnone{"path": "/api/stacks/<name>/update-webhook/<token>"}Issues a token on first call.
POST/api/stacks/{name}/update-webhook/regenerateadminnone{"path": ...}The old token stops working.
POST/api/stacks/{name}/update-webhook/{token}tokennone202 {"accepted": [ids]}Rolling update of every instance. 401 bad webhook token.

Instances​

MethodPathAuthBodySuccessNotes
GET/api/instancesusernone[InstanceSummary]Includes pending placements.
GET/api/instances/eventsusernoneSSE progress
GET/api/instances/{id}usernoneInstanceSummary404 no instance <id>.
PATCH/api/instances/{id}admin{"desired": "running"} or "stopped"200409 if raft rejects.
POST/api/instances/{id}/restartadminnone200Re-places the instance; it may move.
POST/api/instances/{id}/moveadmin{"node": "<name>"}200Starts a migration.
POST/api/instances/{id}/updateadminnone200Pull and recreate. 409 if already in progress.
POST/api/instances/{id}/migration/retryadminnone200409 ... has no failed move, ... is still moving.
POST/api/instances/{id}/migration/dismissadminnone200Clears a failed move.
GET/api/instances/{id}/actionsusernone[{name, confirm}]Actions with show_in_ui only.
POST/api/instances/{id}/actions/{name}adminnone{stdout, stderr, exit_code, timed_out, stdout_truncated, stderr_truncated, duration_ms}Runs the action's argv in the container.
GET/api/instances/{id}/logsusernoneSSESee streams.
GET/api/instances/{id}/statsusernoneSSE stats
POST/api/rebalanceadminnone{moves: [{instance, from, to}]}Starts the planned moves.
GET/api/rebalance/lastusernone{at, moves} or null

InstanceSummary has 23 fields: id, node, desired, state, running, health, image, image_digest, running_digest, started_at, migration, migration_problem, reason, step, placement_error, proxy, exit_code, oom_killed, restart_count, restart_limit, finished_at, memory_limit_bytes, crash_moves, plus the optional ones omitted when empty. A pending placement has state: pending_placement and a placement_error. Instances on another node are forwarded to their owner, not rejected; if that node is unreachable the state falls back to unknown.

Updates​

MethodPathAuthBodySuccessNotes
GET/api/updatesusernone[{id, stack, service, node, image, running_digest, latest_digest, pinned_digest, update_available, pin_drift, error?}]One registry call per distinct image. Do not poll.
POST/api/updates/pinadmin{"instances": []}[{id, ok, error?}]Empty list means all unpinned.
POST/api/updates/applyadmin{"instances": []}202 {"accepted": [...]}Empty list means all that are behind. Rolling; watch /api/instances/events.

Configs, secrets and the cluster key​

MethodPathAuthBodySuccessNotes
GET/api/configsusernone[{name, size, revision, owner}]Reserved names hidden.
GET/api/configs/{name}usernone{name, content, revision, owner}content is base64.
PUT/api/configs/{name}admin{"content": "<base64>"}200
DELETE/api/configs/{name}adminnone204
GET/api/secretsusernone[{name, revision, owner, key_fingerprint}]Never values.
PUT/api/secrets/{name}admin{"value": "..."}, {"content": "<base64>"} or {"generate": {"length": 32, "charset": "alphanumeric"}}200Charsets: alphanumeric, hex, base64, printable. 409 if this node holds no cluster key; 400 for reserved names or over 1 MiB.
DELETE/api/secrets/{name}adminnone204
GET/api/secrets/keyusernone{held, fingerprint}
POST/api/secrets/keyadmin{"key": "<base64>"}200Refused if a key is already present.
POST/api/secrets/key/wrapadmin{node, public_key}WrapResponseUsed when a node joins.

Drives and volumes​

MethodPathAuthBodySuccessNotes
GET/api/drivesusernone[DriveSummary]
GET/api/volumesusernone[VolumeSummary]
GET/api/volumes/{drive}/{volume}/inspect?node=usernoneVolumeInspectionFiles of the copy on node.
POST/api/volumes/{drive}/{volume}/syncadminnone202Bumps the generation, which triggers a sync.
POST/api/volumes/{drive}/{volume}/freezeadminnone202Pauses replication.
POST/api/volumes/{drive}/{volume}/unfreezeadminnone202
DELETE/api/volumes/{drive}/{volume}adminnone204

Certificates, DNS and proxy​

MethodPathAuthBodySuccessNotes
GET/api/certificatesusernone[CertificateSummary]No keys.
POST/api/certificates/{fqdn}/renewadminnone202Removes the stored certificate so it is reissued.
GET/api/dnsusernone[DnsRecordSummary]
POST/api/dns/reconcileadminnone2xxWakes the DNS and ACME loops.
GET/api/proxy/routesusernone[RouteRow]What the proxy serves.
GET/api/proxy/remembered-ipsadminnone[RememberedIp]
DELETE/api/proxy/remembered-ips/{id}adminnone204
GET, PUT/api/cloudflare/tokenuser, adminPUT: {"token": "..."}status, 204Write-only token.
GET, PUT/api/oidc/client-secretuser, adminPUT: {"client_secret": "..."}status, 204Write-only.
GET, PUT/api/proxy-auth/client-secretuser, adminPUT: {"client_secret": "..."}status, 204Write-only.

Redirects, static routes and WireGuard​

MethodPathAuthBodySuccessNotes
GET, POST/api/redirectsuser, adminPOST: RedirectRulelist, ruleManaged in the UI, not in cluster.yaml.
PUT, DELETE/api/redirects/{id}adminPUT: RedirectRulerule, 204
GET, POST/api/static-routesuser, adminPOST: StaticRoutelist, route
PUT, DELETE/api/static-routes/{id}adminPUT: StaticRouteroute, 204
GET, POST/api/wireguard-ingressuser, adminPOST: ingress rulelist, rule
PUT, DELETE/api/wireguard-ingress/{id}adminPUT: ingress rulerule, 204
PUT, DELETE/api/wireguard/{network}/{node}adminPUT: {"content": "<wg conf>"}{"interface": "..."}, 204The config is sealed like a secret.

Registries​

MethodPathAuthBodySuccessNotes
GET/api/registriesusernone[{server, username}]Never passwords.
PUT/api/registriesadmin{server, username, password}204Verifies the login before storing.
POST/api/registries/verifyadminsame204Check only.
DELETE/api/registries/{server}adminnone204

Git backup​

MethodPathAuthBodySuccessNotes
GET/api/git-backup/statususernoneGitBackupStatus
GET/api/git-backup/webhook-secretusernone{configured}
PUT/api/git-backup/webhook-secretadmin{"token": "..."}204
POST/api/git-backup/webhook/{token}tokennone204Called by your git host. 401 bad webhook token.
POST/api/git-backup/export-nowadminnone202 or 204
POST/api/git-backup/pull-nowadminnone202 or 204
POST/api/git-backup/reconcile-nowadminnone202 or 204

Restic backups and restore​

MethodPathAuthBodySuccessNotes
GET/api/restic/destinationsusernone[{id, name, kind, has_credentials}]
PUT/api/restic/destinationsadmin{id, name, kind, password?, ssh_private_key?, s3_access_key_id?, s3_secret_access_key?}204Create or edit by id. Secrets are sealed and never returned.
DELETE/api/restic/destinations/{id}adminnone204
GET/api/restic/destinations/{id}/snapshotsusernone[SnapshotInfo]
GET/api/restic/targetsusernone[DriveBackupTarget]
PUT/api/restic/targetsadmin{id, drive, destination_id, enabled, frequency_secs, time_of_day?, keep_last?, keep_within_secs?, stop_containers?}204
DELETE/api/restic/targets/{id}adminnone204
POST/api/restic/targets/{id}/runadminnonerun idRuns outside the schedule.
GET/api/restic/runs?target=usernone[BackupRun]Newest first.
POST/api/restic/restore/driveadmin{drive, destination_id, snapshot?}request idReturns at once; a node claims and runs it.
POST/api/restic/restore/volumeadmin{drive, volume, destination_id, snapshot?}request id
POST/api/restic/restore/serviceadmin{stack, service, destination_id?, snapshot?}request id
GET/api/restic/restore-requestsusernone[RestoreRequest]Poll for progress.

Self-update​

MethodPathAuthBodySuccessNotes
GET, PUT/api/self-update/github-tokenuser, adminPUT: {"token": "..."}status, 204Write-only.
GET, PUT/api/self-update/repouser, admin{"release_url": "..."}config or null, 204Write-once.
PUT/api/self-update/checkadmin{"enabled": bool, "interval"?: string}204Interval at least 1h.
GET/api/self-update/latestusernone{tag, architectures, source, running_version, fetched_at}Cached 5 minutes.
GET/api/self-update/runusernonerun state or null
POST/api/self-update/runadminnone202 run state
POST/api/self-update/canceladminnone204

Alerts​

MethodPathAuthBodySuccessNotes
GET/api/alertsusernone{active, recent, counts}recent has at most 300 entries.
GET/api/alerts/catalogusernone[CatalogEntry]
GET/api/alerts/settingsusernone{settings, secrets: {dest: {url, auth_set}}, smtp_password, health}URLs masked to scheme and host.
PUT/api/alerts/settingsadmin{settings, secrets?, smtp_password?}SettingsViewA key left out keeps the value, null deletes, a string replaces. A masked URL keeps the stored one. 409 on a concurrent edit.
POST/api/alerts/testadmin{name} or {destination, secrets, smtp?, smtp_password?}{ok, error?}One attempt, 10 s limit. Giving both fails with give either name or destination.

See Alerts.

OAuth and MCP​

MethodPathAuthBodySuccessNotes
GET/.well-known/oauth-protected-resourcenonenoneRFC 9728 JSONAlso with a /mcp suffix. 404 when MCP is disabled.
GET/.well-known/oauth-authorization-servernonenoneRFC 8414 JSONAlso with a /mcp suffix.
POST/oauth/registernoneRFC 7591 body201 client metadataPublic clients only.
GET/oauth/authorizenonequery parameters303 to /oauth/consent?req=<id>PKCE S256 required.
POST/oauth/tokennoneform: grant_type, code, client_id, code_verifier, redirect_uri, or refresh_tokentoken JSON
GET/api/oauth/requestsadminnonepending consents on this node
GET/api/oauth/requests/{id}usernone{client_name, redirect_host, scope, requested_grant, allowed_grants}404 when expired.
POST/api/oauth/requests/{id}/approveuser{"grant": "read"} or "admin"{"redirect": "..."}Admin grant needs an admin: only an admin can grant admin access.
POST/api/oauth/requests/{id}/denyusernone{"redirect": "..."}
GET/api/mcp/clientsusernone[{client_id, client_name, ...}]
DELETE/api/mcp/clients/{id}adminnone204Revokes; tokens stop immediately.
ANY/mcpbearer cmt_ or clk_JSON-RPC over Streamable HTTPJSON or SSESee MCP.

Internal routes​

Paths containing /internal/, plus /api/links/..., are for nodes and the clouduler CLI. They are signed with the cluster key (HMAC over method, path, query, timestamp, nonce and body hash), accepted once within a time window, and cannot be called from a script. Examples are /api/git-backup/pull, /api/git-backup/reconcile, /api/updates/internal/scan and /apply, /api/self-update/internal/restart, /api/instances/internal/*, /api/nodes/internal/* and /api/dns/internal-reconcile. Do not expose them.

Examples​

Deploy a stack
curl -X PUT https://cloud.example.com/api/stacks/notes \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d "{\"spec\": $(jq -Rs . < notes.yaml), \"configs\": {}, \"secrets\": {}}"
Drain a node
curl -X PATCH https://cloud.example.com/api/nodes/cirrus \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"availability": "drain"}'
Cluster status from any node
curl -s -H "Authorization: Bearer $KEY" https://cloud.example.com/api/status

Quirks and gotchas​