HTTP API
Everything Pando does, it does through this API. The console, the CLI and the MCP server are clients of it and none of them has a capability it lacks (R-261).
Base path: /api/v1. Times are RFC 3339 in UTC. IDs are prefixed and
opaque — app_01HQ8…, spec_…, tok_….
Authenticating#
Token — Authorization: Bearer <token>
A token minted from the console or with pando token create. A delegated token acts as the person who made it and is bounded by their live grants (R-058, R-059). A service token is its own principal and holds only what has been shared with it (R-060).
Session — POST /api/v1/sessions, then the session cookie
How the console authenticates. Cookies in Pando's namespace never reach an app (R-173).
Endpoints#
The verb column is the authorization verb the endpoint asks for. Blank means it asks for nothing beyond being signed in. There is no implication graph: holding one verb says nothing about another (R-082).
Session#
| Endpoint | Verb | What it does |
|---|---|---|
POST /api/v1/sessions |
Sign in with a username and password. Sets the session cookie. | |
DELETE /api/v1/sessions |
Sign out, ending this session. | |
GET /api/v1/setup |
Whether this installation is waiting for its first administrator (needed). Public. |
|
POST /api/v1/setup |
Set up a new installation: the first account (username, display_name, password), made an administrator, and signed in. Public, and refused once any account exists (R-046). |
|
GET /api/v1/me |
Who the caller is, and the install-level verbs they hold. | |
POST /api/v1/me/password |
Change your own password. Yours only, whatever verbs you hold. | |
GET /api/v1/me/apps |
The apps you can open, which is a different list from the apps you can administer (R-070, R-071). favorite marks the ones you have pinned, section_id the section you filed each under, and sections lists your sections. |
|
PUT /api/v1/me/favorites/{appID} |
Mark an app you can open as a favorite, pinning it to the top of your launcher. Yours only; it grants nothing (R-341). | |
DELETE /api/v1/me/favorites/{appID} |
Unpin an app from your favorites. | |
POST /api/v1/me/sections |
Make a section in your launcher: a named, collapsible grouping of apps. Yours only; it grants nothing (R-342). | |
PATCH /api/v1/me/sections/{sectionID} |
Rename one of your sections. | |
DELETE /api/v1/me/sections/{sectionID} |
Delete one of your sections. Its apps go back to Your apps. | |
PUT /api/v1/me/sections/{sectionID}/apps/{appID} |
File an app you can open into one of your sections, moving it out of any other. | |
DELETE /api/v1/me/sections/{sectionID}/apps/{appID} |
Take an app out of a section, back to Your apps. |
Tokens#
| Endpoint | Verb | What it does |
|---|---|---|
GET /api/v1/tokens |
Your own tokens. Never anyone else's. | |
POST /api/v1/tokens |
Mint a delegated token. It acts as you, is bounded by your live grants, and dies with your account (R-058, R-059). The secret is shown once. | |
GET /api/v1/tokens/service |
install.tokens.manage |
The installation's service tokens. |
POST /api/v1/tokens/service |
install.tokens.manage |
Mint a service token: its own principal, holding only what is shared with it, outliving whoever created it (R-060). The secret is shown once. |
DELETE /api/v1/tokens/{tokenID} |
Revoke a token. Yours; a service token with install.tokens.manage; anyone else's with install.users.manage. |
Apps#
| Endpoint | Verb | What it does |
|---|---|---|
GET /api/v1/apps |
The apps you can administer. | |
POST /api/v1/apps |
app.create |
Create an app from a repository. Returns immediately in draft while detection runs. |
GET /api/v1/apps/{appID} |
app.view |
One app: name, source, state and pinned spec. |
PATCH /api/v1/apps/{appID} |
app.spec.edit |
Rename an app or change its source. |
DELETE /api/v1/apps/{appID} |
app.delete |
Delete an app. With storage, backup=true keeps a final copy and force=true discards it; without either, the request is refused so the decision is taken rather than assumed (R-204, R-205). |
GET /api/v1/apps/{appID}/icon |
The image on the app's launcher tile. Anyone who can open the app can load it; icon_updated_at on the app says whether there is one and when it changed (R-340). |
|
PUT /api/v1/apps/{appID}/icon |
app.spec.edit |
Set the app's tile image. The body is the image itself — PNG, JPEG, WebP or GIF, at most 256 KB. SVG is refused (R-340). |
DELETE /api/v1/apps/{appID}/icon |
app.spec.edit |
Remove the app's tile image, so the tile goes back to the map generated for it. |
GET /api/v1/apps/{appID}/usage |
app.view |
What each part of the app is using now — CPU in thousandths of a core, memory and disk in bytes, and each mounted volume's size — beside its limits (0 is none; host_cpu_millis and host_memory_bytes say what none means). A reading, not a history (R-245, R-016). supported: false when the runtime cannot report it. |
GET /api/v1/apps/{appID}/status |
app.view |
What the app is doing now: its state, and each part separately — running, restarting and how often, health, exit code — so a single crash-looping part is visible rather than averaged into one word. |
POST /api/v1/apps/{appID}/start |
app.restart |
Set the app's desired state to running. The reconciler converges to it, so it survives a restart. |
POST /api/v1/apps/{appID}/stop |
app.restart |
Set the app's desired state to stopped. |
POST /api/v1/apps/{appID}/restart |
app.restart |
Restart the running workloads without changing anything. |
GET /api/v1/apps/{appID}/logs |
app.logs.read |
The app's own output, from the runtime. tail sets how many lines; workload picks which part of the app, defaulting to the primary one. |
GET /api/v1/apps/{appID}/exec |
app.exec |
A terminal in the running app, over a websocket. Refused when host policy has turned exec off, including for the owner (R-085). |
Detection#
| Endpoint | Verb | What it does |
|---|---|---|
GET /api/v1/apps/{appID}/detection |
app.view |
What Pando worked out about the repository: the winning bid, its evidence, the runners-up and any outstanding questions. |
POST /api/v1/apps/{appID}/detection/rerun |
app.spec.edit |
Run detection again, against the current commit. |
GET /api/v1/apps/{appID}/detection/diff |
app.view |
What accepting the proposal would change about the running app. |
POST /api/v1/apps/{appID}/detection/answers |
app.spec.edit |
Answer detection's questions. Each answer is a fact detection could not find, not a preference. |
POST /api/v1/apps/{appID}/detection/accept |
app.spec.edit |
Accept the proposal, writing a spec revision and pinning it. values sets variables in the same step — {key, value, secret?, workload?} each; a secret goes to the secrets adapter and needs app.secrets.write. Accepting over a configured app needs confirm. |
POST /api/v1/apps/{appID}/source |
app.spec.edit |
Upload a source archive for an app that has no reachable repository. |
Configuration#
| Endpoint | Verb | What it does |
|---|---|---|
GET /api/v1/apps/{appID}/specs |
app.view |
Every spec revision, and which one is pinned. Revisions are append-only (R-152). |
POST /api/v1/apps/{appID}/specs |
app.spec.edit |
Write a new spec revision. It does not deploy and does not become pinned. |
GET /api/v1/apps/{appID}/specs/{rev} |
app.view |
One revision, in full. |
POST /api/v1/apps/{appID}/specs/{rev}/pin |
app.spec.edit |
Pin a revision: what the reconciler converges to, and what the next deploy ships. |
GET /api/v1/apps/{appID}/specs/{a}/diff/{b} |
app.view |
The classified difference between two revisions — what a deploy of it would restart, rebuild or leave alone. |
GET /api/v1/apps/{appID}/export |
app.view |
The app's configuration as a document, with every secret redacted (R-194). |
POST /api/v1/apps/{appID}/plan |
app.view |
What a deploy would do, and every reason it would refuse — before anything is created. |
GET /api/v1/apps/{appID}/slots |
app.view |
The things the app says it needs, and what fills each one (R-130). |
PUT /api/v1/apps/{appID}/slots/{key} |
app.spec.edit |
Fill a slot: provision one, bind to something already running, or set a value. Takes effect at the next deploy. |
Storage#
| Endpoint | Verb | What it does |
|---|---|---|
GET /api/v1/apps/{appID}/volumes |
app.view |
The storage this app keeps. It outlives the app (R-204). |
POST /api/v1/apps/{appID}/volumes |
app.spec.edit |
Declare a path the app keeps between deploys. Anything written outside one is discarded at the next deploy (R-201). |
POST /api/v1/apps/{appID}/restore |
app.deploy |
Restore this app's storage from one of its backups. |
Deploys#
| Endpoint | Verb | What it does |
|---|---|---|
GET /api/v1/apps/{appID}/deployments |
app.view |
Every deploy of this app, newest first. |
POST /api/v1/apps/{appID}/deployments |
app.deploy |
Deploy. Returns 202 with a deployment ID; the build runs behind it. Retrying with the same idempotency key replays the first answer rather than deploying twice (R-262). |
GET /api/v1/apps/{appID}/deployments/{depID} |
app.view |
One deploy: what it shipped, and how it ended. |
GET /api/v1/apps/{appID}/deployments/{depID}/logs |
app.logs.read |
The deploy's output as server-sent events, flushed per line while it runs (R-170). |
POST /api/v1/apps/{appID}/deployments/rollback |
app.deploy |
Deploy the last revision that ran successfully. |
Secrets#
| Endpoint | Verb | What it does |
|---|---|---|
GET /api/v1/apps/{appID}/secrets |
app.view |
Which secrets this app has, and where each came from. Never their values. |
PUT /api/v1/apps/{appID}/secrets/{key} |
app.secrets.write |
Set a secret. Rotating one recreates the workload rather than leaving it running with the old value (R-193). |
DELETE /api/v1/apps/{appID}/secrets/{key} |
app.secrets.write |
Remove a secret. |
GET /api/v1/apps/{appID}/secrets/{key}/value |
app.secrets.read |
Read one secret's value. Its own verb, separate from managing the app, and audited every time (R-083). |
Security#
| Endpoint | Verb | What it does |
|---|---|---|
GET /api/v1/apps/{appID}/security |
app.view |
The app's security score, what it was taken from, and the findings behind it (R-310). |
POST /api/v1/apps/{appID}/security/scan |
app.deploy |
Scan the app now. A write, not a refresh: the score decides whether the next deploy is allowed (R-312, R-314). |
Sharing#
| Endpoint | Verb | What it does |
|---|---|---|
GET /api/v1/apps/{appID}/grants |
app.view |
Who can reach this app, and who can administer it — two planes, listed separately (R-070, R-071). |
POST /api/v1/apps/{appID}/grants |
app.grants.manage |
Share the app with a user, a group, a token, or with everyone. The anonymous grant is a real row, refused where host policy forbids it (R-075, R-076). |
PATCH /api/v1/apps/{appID}/grants/{grantID} |
app.grants.manage |
Change the role a grant for managing the app carries (role_id), one update so the person is never left with nothing in between; or, on the grant to everyone, set passcode ("" removes it). A new passcode asks everyone let in by the old one again (R-075a). |
GET /api/v1/apps/{appID}/principals |
app.grants.manage |
People and groups to share the app with, matching q (username, name or email; group name), at most 20 of each. |
GET /api/v1/apps/{appID}/passcode |
The name of an app that asks for a passcode, for its passcode page. Public; not-found for any other app (R-075a). | |
POST /api/v1/apps/{appID}/passcode |
Enter an app's passcode (passcode). Right, and the browser is let in for a day by a cookie the app never sees; ten wrong tries in fifteen minutes and it waits. Public (R-075a). |
|
DELETE /api/v1/apps/{appID}/grants/{grantID} |
app.grants.manage |
Take a grant away. |
Identity#
| Endpoint | Verb | What it does |
|---|---|---|
GET /api/v1/users |
install.view |
The accounts on this installation. |
POST /api/v1/users |
install.users.manage |
Create an account. |
GET /api/v1/users/{userID} |
install.view |
One account. Your own needs no verb. |
PATCH /api/v1/users/{userID} |
install.users.manage |
Change an account: any of username, display_name, email and status. Username and email only on a local account; a username only with this verb, even your own. Suspension is not deletion (R-049). Your own name and email need no verb. |
DELETE /api/v1/users/{userID} |
install.users.manage |
Delete an account, with the destruction rules that follow from it (R-282). |
PUT /api/v1/users/{userID}/role |
install.users.manage |
Give an account an installation role. Deliberately not a field on PATCH: changing someone's status and changing their power are different acts. |
DELETE /api/v1/users/{userID}/role |
install.users.manage |
Take an installation role away. The last administrator cannot be demoted. |
POST /api/v1/users/{userID}/password |
install.users.manage |
Reset another local account's password (password), ending every session it holds. must_change_password defaults to true: whoever set it hands it over, and its holder chooses their own at the next sign-in. Your own is POST /me/password. |
POST /api/v1/passwords/generate |
install.users.manage |
A strong random password, 18 to 22 characters with upper and lower case, digits and symbols, for creating or resetting an account. Stores nothing. |
GET /api/v1/users/{userID}/apps |
install.view |
The apps an account has something on: its role for managing each, directly or through a group, whether it can use each, and whether you can change that (can_manage). Only apps you can see are listed. Your own needs nothing. |
GET /api/v1/groups |
install.view |
Groups, whether Pando's own or an identity adapter's (R-078). |
POST /api/v1/groups |
install.users.manage |
Create a group. |
PUT /api/v1/groups/{groupID}/members |
install.users.manage |
Set a group's members. |
PUT /api/v1/groups/{groupID}/members/{userID} |
install.users.manage |
Add one account to a group. It then holds everything the group holds. |
DELETE /api/v1/groups/{groupID}/members/{userID} |
install.users.manage |
Remove one account from a group. Refused when it would leave nobody who can manage accounts (R-088). |
PUT /api/v1/groups/{groupID}/role |
install.users.manage |
Give a group an installation role (role_id), which everyone in it holds. |
DELETE /api/v1/groups/{groupID}/role |
install.users.manage |
Take a group's installation role away. Refused when it would leave nobody who can manage accounts (R-088). |
GET /api/v1/groups/{groupID}/apps |
install.view |
A group's app grants: the role everyone in it has on each app, whether they can open it, and whether you can change that (can_manage). Only apps you can see. Share an app with a group through POST /apps/{appID}/grants with principal_kind: group. |
DELETE /api/v1/groups/{groupID} |
install.users.manage |
Delete a group. Everything shared with it goes with it: its members lose that access and keep anything given to them another way. Refused if it would leave nobody who can manage accounts (R-088). |
GET /api/v1/roles |
install.view |
Roles, built in and custom. By default the ones granted across the installation; scope=app gives the ones granted on an app, and scope=all both. Built-in roles are immutable (R-081). |
POST /api/v1/roles |
install.users.manage |
Compose a custom role from verbs (R-082). |
DELETE /api/v1/roles/{roleID} |
install.users.manage |
Delete a custom role, and every grant of it: whoever held it loses what it allowed. Built-in roles cannot be deleted (R-081). Refused if it would leave nobody who can manage accounts (R-088). |
GET /api/v1/verbs |
install.view |
Every verb, by scope, for composing a role. There is no implication graph: holding one says nothing about another (R-082). |
Installation#
| Endpoint | Verb | What it does |
|---|---|---|
GET /api/v1/adapters |
install.view |
The adapters configured here and what they can currently do — live capabilities, not stored configuration. Names which credentials are set, never their values. pending_restart marks one saved since Pando started, which is not yet what runs; restart_needed says any is. |
GET /api/v1/adapters/kinds |
install.view |
The kinds of adapter this build of Pando can run, and the settings each takes — which are credentials (write-only, stored encrypted), which are required, and the default each takes when left empty or an example. |
POST /api/v1/restart |
install.adapters.manage |
Restart Pando: finish the requests in flight, then start again, loading the adapters and the configuration file afresh. Apps behind Pando are unreachable for the seconds it takes. Environment variables are not re-read. Returns before the restart; started_at on GET /api/v1/adapters changes once it is back. |
POST /api/v1/adapters |
install.adapters.manage |
Configure an adapter. Settings go in config; credentials such as an API key go in credentials, which is write-only and stored encrypted. |
GET /api/v1/capacity |
install.view |
What the host has, and what is committed to apps (R-242). |
GET /api/v1/policy |
install.view |
Host policy. Reading the rules you work under is not the same privilege as changing them (R-274). |
PUT /api/v1/policy |
install.policy.manage |
Replace host policy. Policy is a floor, never an override (R-272). |
POST /api/v1/policy/preview |
install.policy.manage |
Which apps a candidate policy would block, before it is saved. |
GET /api/v1/config |
install.view |
The configuration Pando started with (R-271): every non-secret setting, its value and where it came from — an environment variable, the config file, or the default — and the host policy fields fixed there, which cannot be changed through the API while they are set. Secrets are never listed. |
GET /api/v1/audit |
install.audit.read |
The audit log, newest first. Filters combine: action (a prefix), principal_id (who did it, including through a token), principal_kind (user, token, system or anonymous), app_id, target_kind and target_id (what it was done to), involving (an ID that is the actor or the target — everything to do with one account), and since/until (RFC 3339; since inclusive, until exclusive). Pages with before. Append-only: no endpoint edits or deletes an event, and the database refuses it too (R-027). |
GET /api/v1/backups |
install.backup.manage |
The backups this installation holds. |
POST /api/v1/backups |
install.backup.manage |
Take a backup now. |
POST /api/v1/backups/{backupID}/verify |
install.backup.manage |
Check a backup before it is needed, rather than at the moment of disaster (R-216). |
POST /api/v1/backups/{backupID}/restore |
install.backup.manage |
Restore from a backup. Verified first: an incomplete one is refused rather than half-applied (R-215). |
Reference#
| Endpoint | Verb | What it does |
|---|---|---|
GET /api/v1/reference |
This document: every endpoint, every CLI command, every MCP tool and every error code, built from the running binary. |
Errors#
Every error crossing this boundary carries the same envelope: a stable machine
code, a human message, an optional remedy, details and the request_id
that finds the log line. Branch on the code; the message may be reworded.
| Code | HTTP | Meaning |
|---|---|---|
VALID_DANGLING_MOUNT |
400 | A workload mounts a volume the spec does not declare. |
VALID_DANGLING_SLOT_REF |
400 | The spec refers to a slot it does not declare. |
VALID_DEPENDENCY_CYCLE |
400 | The workloads depend on each other in a cycle. |
VALID_ENV_AMBIGUOUS |
400 | An environment variable is set twice with different values. |
VALID_INVALID |
400 | The request or spec is malformed. |
VALID_PRIMARY_WORKLOAD |
400 | A spec must name exactly one primary workload. |
AUTH_INVALID |
401 | The credential presented is not valid. |
AUTH_REQUIRED |
401 | No credential was presented, or the session has expired. |
AUTH_TOKEN_INVALID |
401 | The token is unknown, revoked or expired. |
AUTH_TOKEN_ORPHANED |
401 | The token's owner was suspended or deleted, so the token no longer resolves to anyone (R-059). |
PERM_DENIED |
403 | Authenticated, but not permitted to do this. |
PERM_PASSCODE_REQUIRED |
403 | The app is shared with everyone who knows its passcode, and this request has not shown it. A browser is sent to the passcode page; entering it there lets the visitor in. |
PERM_VERB_REQUIRED |
403 | The caller holds no grant carrying the verb this action needs. |
POLICY_ANONYMOUS_GRANT_FORBIDDEN |
403 | Host policy does not allow apps to be shared with everyone (R-076). |
POLICY_EXEC_DISABLED |
403 | Host policy has turned off terminal access, including for an app's owner (R-085). |
POLICY_SOURCE_NOT_ALLOWED |
403 | Host policy does not allow apps from this source (R-092). |
NOT_FOUND |
404 | No such object, or none the caller may see. |
CAPACITY_NO_FREE_PORT |
409 | Port-mode routing has no free port in the configured range. |
CAPACITY_WOULD_OVERSUBSCRIBE |
409 | Running this would commit more of the host than is left (R-242). |
PLAN_ADAPTER_NOT_CONFIGURED |
409 | The spec names an adapter this installation does not have. |
PLAN_CAPABILITY_UNSUPPORTED |
409 | The spec asks for something the chosen adapter does not do (R-254). |
PLAN_COMPOSE_CONSTRUCT_REJECTED |
409 | The compose file uses a construct Pando will not translate (R-099). |
PLAN_NO_ADAPTER_MEETS_POLICY |
409 | No configured adapter can satisfy this spec under host policy (R-024, R-114). |
PLAN_SECURITY_BELOW_THRESHOLD |
409 | This installation requires a security score, and this app is below it or has never been scanned (R-314). |
PLAN_SLOT_UNFILLED |
409 | A required dependency has nothing filling it, so the deploy would start an app that cannot connect (R-132). |
STATE_APP_EXITED |
409 | The app started and then stopped, so the deploy has nothing to send traffic to. |
STATE_BACKUP_DECISION_REQUIRED |
409 | The app has storage and the request did not say whether to keep a final backup of it (R-204, R-205). |
STATE_INVALID |
409 | The object is in a state this action does not apply to. |
BACKUP_DECRYPT_FAILED |
422 | The backup could not be decrypted with the key supplied. |
BACKUP_INCOMPLETE |
422 | The backup is missing part of what it claims to hold, so it was not applied (R-215). |
BUILD_FAILED |
422 | The build ran and did not succeed. Its log is the answer. |
BUILD_LISTENS_ON_LOOPBACK |
422 | The built app listens only on 127.0.0.1 inside its container, where nothing outside it can reach it. |
BUILD_TIMEOUT |
422 | The build exceeded the time allowed for it (R-119). |
INTERNAL |
500 | Pando failed in a way it did not expect. The request ID finds the log line. |
RATE_LIMITED |
500 | Too many attempts in a short time — at a passcode, for example. Wait a few minutes and try again. |
ADAPTER_FAILED |
502 | The adapter was reached and failed. |
ADAPTER_UNAVAILABLE |
502 | The adapter needed for this is not configured or not reachable. |