Platform API
This page documents the account layer of workload-api: signing in, managing users, organizations, projects and their members, invitations, break-glass, API keys, service accounts, quotas, the Crusoe Cloud integration, the audit log, search, and the platform model key. Set $CAI_API first — see the API overview.
Conventions
- Errors use the standard envelope
{"error": "<message>", "request_id": "<id>"}. Request bodies are JSON and most are capped at 8 KiB. - Pagination on every list:
page_size(default 50, max 200;limitaccepted as an alias) andpage_token; responses carrynext_page_token. - The 404 rule: any request against a project you hold no grant on returns 404
not found— never 403 — so project existence is not discoverable. - Two axes of authority (the platform's IAM model): authority over the project object (rename, delete, membership — the metadata axis) is separate from authority over its contents (agents, secrets — the resource axis). A platform admin holds metadata authority everywhere and resource authority nowhere: provider staff cannot read tenant data. The only way in is a visible, expiring, audited break-glass grant. See Projects and access.
- Auth gates used below: platform admin (runs the whole platform), org admin (administers one organization; inherits
adminon every project in it, resolved live per request), metadata admin (admin on the project object), resource admin (admin over project contents), member (any project grant), visible (any grant on either axis), session (any signed-in principal). - A user who still owes a first-login password change gets 403 —
password change required before using this API— on every management route exceptGET /v1/auth/meandPOST /v1/auth/change-password.
Endpoints at a glance
This page is long, so start here. Each row is one area of the account layer; the section it links to opens with the full method, path, and auth gate for every route in it.
| Area | Routes under | What it covers |
|---|---|---|
| Authentication and session | /v1/auth/... | Sign in, read your own identity, change your password |
| Users | /v1/users/... | Create, list, promote, delete, and reset accounts |
| Organizations and org members | /v1/orgs/... | Organizations and the members who administer them |
| Projects | /v1/projects and /v1/projects/{projectID} | Create, list, rename, and delete projects |
| Project members | /v1/projects/{projectID}/members/... | Grant, change, and remove project roles |
| Break-glass | /v1/projects/{projectID}/break-glass | The one path a platform admin has into a project's contents |
| Invitations | /v1/projects/{projectID}/invitations/..., /v1/invitations/... | Invite someone who has no account yet, and accept an invitation |
| Personal API keys | /v1/users/me/keys/... | Your own keys — create, list, revoke |
| Service accounts and keys | /v1/projects/{projectID}/service-accounts/... | Machine identities and their keys |
| Quota | /v1/projects/{projectID}/quota | Live usage against the project's caps (read-only) |
| Crusoe Cloud integration | /v1/projects/{projectID}/crusoe-cloud/... | Link a Crusoe Cloud account, list its buckets, repositories and models, and check that the stored credential still works |
| Audit log | /v1/projects/{projectID}/audit | Every state change in the project |
| Search | /v1/search | One query across every resource you can see |
| Inference credential | /v1/projects/{id}/inference | A project's own Foundry key |
Authentication and session
| Method | Path | Auth |
|---|---|---|
| POST | /v1/auth/login | none (rate-limited) |
| GET | /v1/auth/me | session (works pre-password-change) |
| POST | /v1/auth/change-password | session (works pre-password-change) |
| GET | /healthz | none |
POST /v1/auth/login
| Field | Type | Required |
|---|---|---|
email | string | yes |
password | string | yes |
Response: 200
{"token": "...", "email": "...", "role": "admin", "must_change_password": false, "expires_at": 1765480000}
role is admin or user (the platform-level role). expires_at is Unix seconds. Token lifetime: 12 hours, HMAC-signed, stateless, and not revocable before expiry.
Errors:
- 400 — invalid JSON
- 401 —
invalid email or password(one message for both "no such user" and "wrong password" — no account-existence oracle) - 429 —
too many sign-in attempts; try again in <duration>with aRetry-Afterheader (throttle: 10 attempts per IP and 50 per account, per 15 minutes) - 503 —
user management is not initialized
GET /v1/auth/me
Response: 200 — {"email", "role", "must_change_password", "expires_at"}. The role is the effective role re-read from the store, not the token's claim — so a demotion shows up immediately.
POST /v1/auth/change-password
Body: {"current_password", "new_password"}.
Response: 200 — same shape as login. A fresh token is minted (the old must-change token would keep locking you out).
Errors:
- 401 —
current password is incorrect - 400 —
password must be at least 12 characters - 400 —
new password must differ from the current one
Password rule everywhere: at least 12 characters, and at least 3 of these 4: an uppercase letter, a lowercase letter, a number, a symbol.
Users (platform administration)
| Method | Path | Auth |
|---|---|---|
| GET | /v1/users | platform admin |
| POST | /v1/users | platform admin, org admin, or project admin (their own org only) |
| PATCH | /v1/users/{email} | platform admin |
| DELETE | /v1/users/{email} | platform admin |
| POST | /v1/users/{email}/reset-password | platform admin |
GET /v1/users
Response: 200 — {"users": [...], "unowned_agents": [...], "next_page_token": "..."}
Each user:
| Field | Type | Meaning |
|---|---|---|
id | UUID | Account ID. |
email | string | Sign-in address. |
role | string | admin | user (platform role). |
must_change_password | bool | True until the first-login password change. |
org_id | UUID | Home organization; absent for external users. |
external | bool | true = no home org (joined by invitation). |
created_at | string | RFC3339. |
projects | string[] | Project slugs. |
agents | string[] | Agent names — scoped to the caller's resource grants; a platform admin sees accounts, not tenant workload names. |
agent_count | int | Count of the above. |
unowned_agents lists agents deployed before ownership existed or by the automation token.
POST /v1/users
| Field | Type | Required | Default |
|---|---|---|---|
email | string | yes | — |
role | string | no | user (admin | user) |
password | string | no | a temporary password is generated |
org_id | UUID | no | the creator's own org |
Response: 201 — {"user": {...}, "temporary_password": "..." (only when password was omitted), "note": "shown once - the user must change it at first login"}. Admin-created accounts always start with must_change_password: true.
Org placement: an explicit org_id requires being a platform admin, an admin of that org, or (for a project admin) it must be their own home org.
Errors:
- 409 —
an account already exists for that email address. Do not create a second one: add the existing account to the project instead (POST /v1/projects/{projectID}/members with that email) - 403 —
only a platform admin can create a platform admin - create the account with "role":"user" and ask a platform admin to promote it - 403 —
creating an account in that organization requires administering it - 403 —
creating an account requires administering an organization or a project - 400 —
org_id is not a valid id; bad email; weak password;role must be "admin" or "user"
PATCH /v1/users/{email}
Body: {"role": "admin"} or {"role": "user"}. Role changes are effective immediately — authorization re-reads the store on every request.
Errors:
- 400 —
nothing to update: send {"role":"admin"} or {"role":"user"} (to change a password use POST /v1/users/{email}/reset-password)(sent for an empty body) - 400 —
cannot remove your own admin role - ask another admin to do it - 409 —
cannot demote the last admin - promote another account first - 404 —
user not found
DELETE /v1/users/{email}
Response: 200 — {"deleted": "<email>", "note": "project grants were removed with the account; any agents they deployed keep running"}
Errors: 400 — cannot delete the account you are signed in as; 409 — last-admin guard (as above).
POST /v1/users/{email}/reset-password
Response: 200 — {"email", "temporary_password", "note": "shown once - the user must change it at next login"}
Organizations and org members
All org routes are platform admin only.
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/orgs | List organizations |
| POST | /v1/orgs | Create |
| DELETE | /v1/orgs/{orgID} | Delete an empty org |
| GET | /v1/orgs/{orgID}/members | List org members |
| POST | /v1/orgs/{orgID}/members | Add a member |
| PATCH | /v1/orgs/{orgID}/members/{userID} | Change a member's role |
| DELETE | /v1/orgs/{orgID}/members/{userID} | Remove a member |
GET /v1/orgs
Response: 200 — {"orgs": [{"id", "slug", "name", "created_at", "projects": 3, "users": 12}], "next_page_token": "..."}
POST /v1/orgs
Body: {"slug", "name"} (slug is lowercased and trimmed). Response: 201 — {"org": {...}}
DELETE /v1/orgs/{orgID}
Response: 200 — {"deleted": true, "org": "<slug>"}
Errors:
- 403 —
the default organization cannot be deleted(the default org's slug isdefault) - 409 —
organization still has projects - delete them first - 409 —
organization still has users - move or remove them first - 400 —
invalid orgID: must be a UUID
Org members
Member shape: {"user_id", "email", "role": "admin" | "member", "created_at"}.
POST /v1/orgs/{orgID}/members — body {"email", "role"} (role defaults to member). Adding is add-only:
- Already a member with the same role → 200 —
already a member of this organization with this role - Already a member with a different role → 409 —
<email> is already a member of this organization with role "<r>"; use PATCH /v1/orgs/{orgID}/members/{userID} to change it - New member → 201 —
{"member": {...}, "note": "org admins inherit the admin role on every project in this org, immediately"}(the note appears only for the admin role) - 404 —
no account with that email address
PATCH /v1/orgs/{orgID}/members/{userID} — body {"role": "admin" | "member"} → 200 — {"member": {...}, "note": "effective immediately - inherited project access is re-resolved on every request"}. 409 — this is the organization's only admin - appoint another before demoting them.
DELETE /v1/orgs/{orgID}/members/{userID} → 200 — {"deleted": true, "user_id", "note": "effective immediately ..."}. The last-admin guard also applies (...before removing them).
Org admins inherit the admin role on every project in their org. This is resolved live on each request — no membership rows are written.
Projects
| Method | Path | Auth |
|---|---|---|
| GET | /v1/projects | any session |
| POST | /v1/projects | org admin of the target org, or platform admin |
| GET | /v1/projects/{projectID} | visible (either axis) |
| PATCH | /v1/projects/{projectID} | metadata admin |
| DELETE | /v1/projects/{projectID} | metadata admin |
The project view (returned by every project response):
| Field | Type | Meaning |
|---|---|---|
id | UUID | Project ID. |
org_id | UUID | Owning organization. |
slug | string | Renameable handle. |
name | string | Display name. |
short | string | Immutable ID fragment used in the project's network space and hostnames — survives renames. |
created_at | string | RFC3339. |
your_role | string | Your contents (resource) authority: admin | member | absent. |
your_metadata_role | string | Your object (metadata) authority: admin | member | absent. |
A platform admin gets your_metadata_role: "admin" and an empty your_role — the console hides content pages from that signal.
GET /v1/projects
Response: 200 — {"projects": [...], "next_page_token": "..."} — the union of: everything (platform admin), your direct grants, and org-admin-inherited projects. Sorted by slug.
POST /v1/projects
Body: {"slug", "name", "org_id"} (org_id optional; defaults to your home org).
Response: 201 — {"project": {...}}, sometimes with "warning" and/or "note". Creation provisions the project's isolated workspace, its quota, its network rules, its model credential, its record, and its session-store account — each step is non-fatal and surfaced via "warning" if it fails.
A platform admin creating a project outside their own org does not become a member and gets: "note": "you created this project as a platform admin, so you administer it but hold nothing inside it. Add an administrator from the owning organization (POST /v1/projects/<id>/members)."
Errors:
- 403 —
creating a project requires the org admin role - 403 —
you have no home organization - ask a platform admin to create the project, or to add you to one - 400 —
org_id is not a valid id - 409 — slug conflicts
PATCH /v1/projects/{projectID}
Body: {"slug"?, "name"?}.
Response: 200 — {"project": {...}, "note": "the project's short id is unchanged, so every resource path, hostname and running workload is unaffected"} (a no-op returns "note": "nothing changed").
Errors: 409 — that slug is reserved for the default project
Renaming never changes short or the project's network space — those are immutable.
DELETE /v1/projects/{projectID}
Response: 200 — {"deleted": true, "project": "<slug>"}
Errors:
- 403 —
the default project cannot be deleted - 409 —
project still has agents - delete them first (this is deliberate: deleting a project would destroy their code and logs)
Project members
| Method | Path | Auth |
|---|---|---|
| GET | /v1/projects/{projectID}/members | visible |
| POST | /v1/projects/{projectID}/members | metadata admin |
| PATCH | /v1/projects/{projectID}/members/{userID} | metadata admin |
| DELETE | /v1/projects/{projectID}/members/{userID} | metadata admin |
| PATCH | /v1/projects/{projectID}/members/service-accounts/{saID} | metadata admin |
| DELETE | /v1/projects/{projectID}/members/service-accounts/{saID} | metadata admin |
Member view: {"user_id", "email", "role": "admin" | "member", "kind": "user" | "service_account", "service_account_id"?, "external": bool, "created_at"} — break-glass rows additionally carry "break_glass": true, "expires_at", "reason", "granted_by" and, after expiry, "expired": true (they stay listed as history).
POST /v1/projects/{projectID}/members
Body: {"email", "role"} (role defaults to member). The email is looked up first; the role is granted to the existing account.
Response: 201 — {"member": {...}, "note": "granted to the existing account for this address; no new account was created"}
Errors:
- 404 —
no account exists for <email> - invite them instead (POST /v1/projects/<id>/invitations), which lets them choose their own password when they accept - 400 —
a valid email address is required;role must be "admin" or "member"
PATCH /v1/projects/{projectID}/members/{userID}
Body: {"role"} → 200 — {"user_id", "role"}.
Errors: demoting or removing the last real admin → 409 — this is the project's only admin - promote someone else first. Break-glass, expired, and service-account admins do not count toward this guard.
Service-account member routes
PATCH .../members/service-accounts/{saID} — body {"role"} → 200 — {"service_account_id", "role"}. DELETE removes only the grant; the account and its keys live on.
Break-glass
POST /v1/projects/{projectID}/break-glass
Platform admin only, human credentials only (machine credentials are refused). A break-glass grant is a self-granted, reason-required, expiring membership row — visible to the project's members and recorded in its audit log. See Break-glass and audit.
| Field | Type | Required | Default | Rule |
|---|---|---|---|---|
reason | string | yes | — | ≥ 12 characters; shown verbatim to the project's members. |
role | string | no | admin | — |
minutes | int | no | 240 (4 hours) | Max 1440 (24 hours). |
Response: 201
{"grant": {"...": "...", "break_glass": true, "expires_at": "...", "reason": "...", "granted_by": "..."},
"note": "this grant is visible to the project's members and recorded in its audit log. It expires on its own; remove it sooner with DELETE /v1/projects/<id>/members/<userId>"}
Errors:
- 400 —
a reason is required and is shown to the project's members verbatim - say what you are fixing and, if there is one, name the ticket - 400 —
a break-glass grant may last at most 24h0m0s - take a fresh one, with a fresh reason, if the work outlives it - 403 —
break-glass access is for platform admins repairing a project they are not a member of; you already hold a grant here, or you hold none to escalate from
Invitations
| Method | Path | Auth |
|---|---|---|
| GET | /v1/projects/{projectID}/invitations | visible |
| POST | /v1/projects/{projectID}/invitations | metadata admin |
| DELETE | /v1/projects/{projectID}/invitations/{invitationID} | metadata admin |
| GET | /v1/invitations/{token} | unauthenticated (the token is the credential) |
| POST | /v1/invitations/accept | unauthenticated, login-rate-limited |
POST /v1/projects/{projectID}/invitations
Body: {"email", "role"}.
If the address already has an account, the role is granted directly: 201 with a "member" object and "note": "an account already existed for this address, so the role was granted to it directly - no invitation was sent and no second account was created" — or 409 — that user is already a member of this project.
Otherwise: 201
{"invitation": {"id": "...", "email": "...", "role": "member", "state": "open",
"created_at": "...", "expires_at": "...",
"accept_url": "/invite/<token>", "token": "..."},
"note": "the token is shown once - send the accept link to the invitee now, it cannot be retrieved later"}
Invitation states: open | accepted | revoked | expired.
GET /v1/invitations/{token}
Response: 200 — {"project_name", "project_slug", "email", "role", "expires_at", "account_exists": bool}
Errors:
- 404 —
this invitation link is not valid - 410 —
this invitation has already been used, revoked, or expired
POST /v1/invitations/accept
Body: {"token", "password"} — password is required only when no account exists (≥ 12 characters, else 400 — choose a password of at least 12 characters, mixing at least 3 of 4 character types, to create your account). Accepting never changes an existing account's password.
Response: 200 — {"accepted": true, "project_id", "role", "email", "note": "sign in with this email to use the project"}
Invited outsiders get no home organization — their account shows external: true.
Personal API keys
Any signed-in user manages their own keys. Key format: cai_<keyid>_<secret> — only a hash is stored. See Service accounts and API keys.
Key view: {"id", "key_id", "display_name", "kind", "created_at", "expires_at"?, "last_used_at"?, "revoked_at"?, "live": bool} — there is structurally no field a secret could travel in. The full secret exists in exactly one response, at creation:
{"key": {"...": "..."}, "secret": "cai_...",
"note": "copy this now - only a hash is stored, so it cannot be shown again. If it is lost, revoke this key and create another."}
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/users/me/keys | List your keys — {"keys": [...]} |
| POST | /v1/users/me/keys | Create — body {"display_name", "expires_in_days"} → 201 with the one-time secret |
| DELETE | /v1/users/me/keys/{keyID} | Revoke — {"revoked": "<key_id>", "note": "effective immediately - the next request presenting this key is refused"} |
expires_in_days: 0 or absent = never expires; otherwise 1–3650 — else 400 — expires_in_days must be between 1 and 3650, or 0 for a key that does not expire.
Deleting someone else's key ID returns 404 (anti-enumeration). A personal key acts as you, re-resolved per request: lose a grant and the key loses it on its next call.
Service accounts and keys
A service account is a machine principal that belongs to one project, with the derived, immutable email <name>@<project-short>.cai.local. Listing requires project membership; create, key management, and delete require the project resource admin role.
| Method | Path | Auth |
|---|---|---|
| GET | /v1/projects/{projectID}/service-accounts | member |
| POST | /v1/projects/{projectID}/service-accounts | resource admin |
| DELETE | /v1/projects/{projectID}/service-accounts/{sa} | resource admin |
| GET | /v1/projects/{projectID}/service-accounts/{sa}/keys | member |
| POST | /v1/projects/{projectID}/service-accounts/{sa}/keys | resource admin |
| DELETE | /v1/projects/{projectID}/service-accounts/{sa}/keys/{keyID} | resource admin |
GET /v1/projects/{projectID}/service-accounts
Response: 200 — {"service_accounts": [{"id", "project_id", "name", "email", "display_name", "role"?, "disabled": bool, "created_at"}], "next_page_token"?}
POST /v1/projects/{projectID}/service-accounts
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
name | string | yes | — | DNS-1035 label. Reserved permanently, even after deletion. |
display_name | string | no | — | description accepted as an alias; display_name wins. |
role | string | no | member | Project role the account holds. |
Response: 201 — {"service_account": {...}, "note": "create a key for it at POST <path>/<name>/keys"}
Errors: 409 — that name is taken. Service account names are reserved permanently, including after deletion, so that a new principal can never inherit an old one's grants and audit history
DELETE /v1/projects/{projectID}/service-accounts/{sa}
Soft delete. Response: 200 — {"deleted": true, "service_account": "<email>", "note": "every key it held was revoked in the same transaction; the name stays reserved"}
Service-account keys
Same body and one-time-secret response as personal keys. Keys list newest first.
Errors: 409 — this service account is disabled - a key issued for it would not authenticate. A key belonging to a different service account returns 404.
Role management lives on the members surface: PATCH /v1/projects/{projectID}/members/service-accounts/{saID} with {"role"}.
A service-account key can never create or manage credentials, accounts, or org/project membership. It gets 403 — a service account cannot create or manage credentials. Sign in as a user (or use your own API key) to issue keys - otherwise a leaked key could mint replacements and revoking it would achieve nothing. Its authority is its own project's, on the resource axis only.
There is also an internal workload key kind (cai_wl_..., minted automatically at deploy): its only allowed call is minting a read token for its own project's secrets. On any management route it gets 403 — this credential is a deployed workload's key; it can only mint a read token for its own project's secrets, not use management endpoints.
Quota
GET /v1/projects/{projectID}/quota
Any project member. Read live from the platform's own enforcement — this API is read-only; no endpoint raises a cap.
Response: 200
{"project": "<slug>",
"quotas": [{"resource": "running_instances", "used": "3", "hard": "50", "used_pct": 6,
"display": "Running instances: 3 / 50 (6%)"}],
"note": "Live usage for this project's limits. The percentage is how much of each limit is in use; a limit of 0 means that resource is unlimited."}
quotas is [], never null. used_pct is 0 when hard is 0 or lower (unlimited). Friendly labels: running_instances → "Running instances", services → "Services", requests.cpu → "CPU (reserved)", requests.memory → "Memory (reserved)", limits.cpu → "CPU (max)", limits.memory → "Memory (max)", count/deployments.apps → "Workloads", persistentvolumeclaims → "Storage volumes".
Defaults written at project creation are listed in Platform limits. See also Quotas and audit.
Crusoe Cloud integration
Ten routes link one project to one Crusoe Cloud account, act on it, and report whether the stored credential still works: object-storage buckets, container-registry repositories, the managed-inference models the account can serve, and the credential health check. The task-oriented guide is Crusoe Cloud; this section is the wire contract.
The mapping is also a precondition for deploying: every image the platform builds for a project is pushed to a repository in that project's own Crusoe Cloud container registry, so POST /v1/agents and the other build routes answer 409 — this project cannot deploy yet: ... until this PUT has succeeded once. See Agents API.
These routes sit on the resource axis, like secrets and Pub/Sub — the credential and the resources it reaches are tenant data. A platform admin holding no grant on the project gets 404, not 403. Reading the mapping and the three discovery lists is any member's right (the console fills dropdowns from them); setting or clearing the mapping, and creating cloud resources that cost the tenant money, require resource admin.
| Method | Path | Auth |
|---|---|---|
| GET | /v1/projects/{projectID}/crusoe-cloud | member |
| PUT | /v1/projects/{projectID}/crusoe-cloud | resource admin |
| DELETE | /v1/projects/{projectID}/crusoe-cloud | resource admin |
| GET | /v1/projects/{projectID}/crusoe-cloud/buckets | member |
| POST | /v1/projects/{projectID}/crusoe-cloud/buckets | resource admin |
| GET | /v1/projects/{projectID}/crusoe-cloud/repositories | member |
| POST | /v1/projects/{projectID}/crusoe-cloud/repositories | resource admin |
| GET | /v1/projects/{projectID}/crusoe-cloud/models | member |
| GET | /v1/projects/{projectID}/crusoe-cloud/credential-check | member |
| POST | /v1/projects/{projectID}/crusoe-cloud/credential-check | member |
Request bodies on PUT and both POSTs are capped at 16 KiB.
GET /v1/projects/{projectID}/crusoe-cloud
Reads the mapping status. Response: 200, one of two shapes:
{"mapped": false}
{"mapped": true, "cc_project": {"id": "...", "name": "..."}, "region": "us-east1-a"}
region is omitted when the platform never resolved one. The stored access key is never in this response, or in any other.
PUT /v1/projects/{projectID}/crusoe-cloud
Creates or replaces the mapping. The key is validated against the live Crusoe Cloud API before anything is stored.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
access_key_id | string | yes | — | The Crusoe Cloud access key's public identifier. |
secret_key | string | yes | — | Validated, stored, and never returned. |
cc_project_id | string | no | auto | Required only when the key can reach more than one Crusoe Cloud project. |
region | string | no | discovered | Blank means the platform guesses from the account's models, then its buckets. A failed guess leaves region empty; it is not an error. |
Response: 200 — the same shape as a mapped GET.
Two side effects follow a successful store. The project's discovery cache is cleared, so the next list reflects the new mapping immediately. Then the project's ccr-pull image-pull Secret is re-minted from the new credential, keyed to every registry host the mapped account has repositories on, so private registry images can be pulled; if the account has no repositories, any stale ccr-pull Secret is removed instead. That second step is best-effort — the credential is already stored, so a failure there is logged and does not fail the PUT. It self-heals on the next PUT.
Errors:
- 400 —
access_key_id and secret_key are required(a blank field, checked before any upstream call) - 400 —
the Crusoe Cloud credential was rejected: ...(a well-formed key the API refused; nothing stored) - 400 —
this credential can access 3 Crusoe Cloud projects; set cc_project_id to choose one - 400 —
cc_project_id "..." is not among the 3 project(s) this credential can access - 400 —
this credential can access no Crusoe Cloud projects, so there is nothing to map to - 400 —
invalid JSON body: ..., orcould not build a Crusoe Cloud client: ... - 502 —
could not reach Crusoe Cloud to validate the credential: ...(an upstream outage, never rendered as your bad request) - 503 — the project secret store cannot answer; see The 503 that is not "unmapped"
DELETE /v1/projects/{projectID}/crusoe-cloud
Removes the stored credential and deletes the project's ccr-pull Secret. Nothing in Crusoe Cloud is deleted. The project can no longer deploy until a credential is set again — builds have nowhere to push.
Response: 200
{"mapped": false, "unmapped": true}
Errors: 404 — this project is not mapped to Crusoe Cloud. Note this is the one route where "not mapped" is a 404; on the five discovery routes it is a 409.
GET /v1/projects/{projectID}/crusoe-cloud/buckets
Response: 200
{"buckets": [{"name": "incoming",
"s3_endpoint": "https://object.us-east1-a.crusoecloudcompute.com",
"region": "us-east1-a",
"created_at": "2026-07-30T18:22:11Z"}],
"next_page_token": ""}
buckets is [], never null. s3_endpoint, region and created_at are omitted when
they cannot be determined.
:::note Where s3_endpoint comes from
Crusoe Cloud does not return an endpoint on the bucket list — only on the
per-bucket route. The platform fills the gap for you: any bucket the list reports
without an endpoint is looked up individually, so this list carries the endpoint even
though upstream's does not.
Those lookups are bounded. If a project has more buckets than the cap, the response
carries endpoints_not_looked_up: <n> and those buckets omit s3_endpoint — that
number is the difference between "Crusoe reports no endpoint" and "we did not ask".
Endpoints look like https://object.<location>.crusoecloudcompute.com, using the
full availability zone (us-southcentral1-a, not us-southcentral1) and
path-style addressing. The host resolves to private address space, so it is
reachable from inside your Crusoe network and not from a laptop.
:::
POST /v1/projects/{projectID}/crusoe-cloud/buckets
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Unique within the mapped Crusoe Cloud project. |
region | string | yes | A bucket must be placed somewhere. |
Response: 201 — one bucket object, same fields as the list.
Errors: 400 — name is required; 400 — region is required to create a bucket.
GET /v1/projects/{projectID}/crusoe-cloud/repositories
Response: 200
{"repositories": [{"name": "my-service",
"registry_url": "registry.us-east1-a.crusoecloud.com/acme-prod/my-service",
"region": "us-east1-a",
"id": "3a1c5e90-77b2-4a41-9d0e-6b2f8c4d1a03"}],
"next_page_token": ""}
A repository may also carry images ([{"name", "url"}]), filled only when it is cheap to fetch. An empty or absent images means "not fetched", never "no images" — do not read absence as a fact.
POST /v1/projects/{projectID}/crusoe-cloud/repositories
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Images are pushed to it and deployed from it. |
region | string | no | Blank uses the mapped project's region. |
Response: 201 — one repository object.
Errors: 400 — name is required.
GET /v1/projects/{projectID}/crusoe-cloud/models
Read-only. Response: 200
{"models": [{"id": "m-1a2b3c4d", "name": "glm-5-2", "model_name": "zai/GLM-5.2",
"region": "us-east1-a", "status": "ready", "context_length": 131072}],
"next_page_token": ""}
Every field after id and name is omitted when upstream does not report it.
GET and POST /v1/projects/{projectID}/crusoe-cloud/credential-check
Whether the stored access key still works. The PUT above validates a key once, when it is stored; after that the customer owns it and can revoke or rotate it at any time. The control plane re-checks every mapped credential on a jittered ~12-hour schedule and records the answer, so a revocation is reported here rather than surfacing hours later as a container image that will not pull.
GET reads the recorded answer. POST (empty body) runs the check immediately and returns the fresh one; a project checked within the last 30 seconds gets the recorded answer instead of a second upstream call. Response: 200
{"state": "invalid", "blocking": true, "checking": true,
"detail": "Crusoe Cloud rejected this project's access key. It has most likely been revoked, rotated or expired.",
"checked_at": "2026-08-17T09:00:00Z", "valid_at": "2026-08-16T21:14:02Z",
"next_check_at": "2026-08-17T22:41:19Z"}
| Field | Meaning |
|---|---|
state | valid, invalid, unreachable, or unknown |
blocking | true only for invalid — the one state the customer must act on |
checking | false when this control plane has no system of record and therefore cannot keep the answer current. Treat as no information: not health, not a warning |
detail | a sentence for the customer. Empty when there is nothing to say |
checked_at | when a check last ran, whatever it concluded |
valid_at | when the credential was last seen to actually work. Survives a failed check |
next_check_at | when this project is next due |
invalid and unreachable are not the same thing, and the difference is the point. Only an authentication or authorization refusal — the key rejected, or the mapped Crusoe Cloud project no longer visible to it — produces invalid. A timeout, a 5xx, a rate limit or an unreadable secret store produces unreachable, which means we could not find out and blocks nothing. Rendering the second like the first would, during one Crusoe Cloud outage, tell every tenant on the platform to rotate a key that was working perfectly.
An invalid verdict is never retracted by a later failed check: a credential Crusoe Cloud has already refused does not become un-refused because the next check timed out. A successful check clears it immediately, so rotating the key visibly fixes the warning.
POST answers 503 when no checker is running (checking: false) rather than pretending to have checked.
Pagination and caching on the three lists
next_page_token is always the empty string. It exists for shape-consistency with the platform's other lists; the upstream SDK returns each set in a single call, so there is no cursor to thread yet. Do not write a paging loop against it.
All three lists are cached per project for 45 seconds. Any write through this API (mapping change, bucket or repository create) drops that project's cache immediately, so your own writes are visible at once. A resource created directly in Crusoe Cloud can lag by up to 45 seconds.
The error families, and which one means "act"
These four are the ones worth telling apart, because each implies a different next move.
| Status | Message | What it means | What to do |
|---|---|---|---|
| 409 | this project is not mapped to Crusoe Cloud. Set a credential with PUT /v1/projects/{id}/crusoe-cloud first. | No credential is stored. Returned by all five discovery routes. | Map the project with PUT. |
| 409 | the stored Crusoe Cloud mapping credential is invalid or unparseable; re-map the project with PUT /v1/projects/{id}/crusoe-cloud. | A credential exists but is not valid JSON. Deliberately a 409, never a 500. | Map the project again; PUT overwrites it. |
| 503 | see below | The secret store cannot answer, so whether you are mapped is unknown. | Nothing. Wait and retry. Do not re-map. |
| 502 | the Crusoe Cloud API call failed: ... | Crusoe Cloud itself failed. Your mapping is fine. | Retry. |
Five more you can hit on the discovery and create routes:
- 400 —
the stored Crusoe Cloud credential is not usable: ...— a credential is stored and parses, but no API client can be built from it. Map the project again. - 400 —
the stored Crusoe Cloud credential was rejected by the API: ...— the key was valid when you mapped and is not any more (revoked, expired, permissions changed). Create a new key and map again. - 400 —
invalid JSON body: ...— on eitherPOST, or onPUT. - 409 —
the Crusoe Cloud mapping has no project id; re-set the mapping with PUT /v1/projects/{id}/crusoe-cloud— the stored mapping never resolved a Crusoe Cloud project id. - 404 — passed through verbatim from Crusoe Cloud when it reports a resource does not exist.
The 503 that is not "unmapped"
This distinction is load-bearing. 409 means "there is no mapping — create one." 503 means "the mapping is intact and simply not readable right now." The platform stores the access key as an ordinary project secret under the reserved name crusoe-cloud-credential, so the secret store's sealed and unavailable semantics apply to this whole surface — and a sealed store is reported as a store outage, never as an absent mapping.
If you collapse the two, a routine secret-store outage reads as a lost mapping, and you unmap and re-map a project that needed neither.
There are two distinct 503 bodies. This one means the platform was deployed with no secret store at all:
the project secret store is not configured: workload-api starts it only when BAO_ADDR and BAO_TOKEN are set in its environment, and they are not. This is a deployment gap, NOT an empty secret store - do not read it as 'this project has no secrets'.
Any other 503 message comes from the store itself (sealed or unreachable). Both are platform-side problems — report them to your administrator.
The reserved secret name
The credential lives in the project secret store under the reserved name crusoe-cloud-credential, and that name is invisible on the ordinary Secrets API: create, get, versions, delete, reveal, and the name-scoped token route all return 404 for it, and it never appears in a listing. Use these routes instead. The guard exists so a member cannot forge a mapping the resource-admin gate would have refused, reveal a secret key this surface promises never to return, or unmap a project while bypassing the ccr-pull Secret teardown.
Audit log
GET /v1/projects/{projectID}/audit
Readable by any project member. Append-only, newest first, cursor-paged.
Response: 200 — {"entries": [{"ts", "actor", "action", "target", "detail"}], "next_page_token"?}. An empty actor means the platform itself acted.
Every action value the platform emits:
Agents, functions, MCP servers, and the account layer
agent.config, agent.delete, agent.deploy, agent.embed.delete, agent.embed.rotate-key, agent.embed.update, agent.file.delete, agent.file.write, agent.redeploy, agent.set-traffic, apikey.create, apikey.revoke, invitation.accept, mcpserver.create, mcpserver.delete, mcpserver.tool.publish, mcpserver.tool.delete, mcpserver.version.rollback, mcpserver.version.yank, mcpserver.version.unyank, org.create, org.delete, org.member.add, org.member.remove, org.member.role, project.break-glass, project.create, project.delete, project.rename, project.invite, project.invite.revoke, project.member.add, project.member.remove, project.member.role, project.member.sa.role, project.member.sa.remove, project.crusoe-cloud.map, project.crusoe-cloud.unmap, project.crusoe-cloud.bucket.create, project.crusoe-cloud.repository.create, project.crusoe-cloud.credential-check, project.secret.write, project.secret.delete, project.secret.reveal, project.secret.issue-token, project.secret.apply, project.secret.bind, project.secret.unbind, serviceaccount.create, serviceaccount.delete, serviceaccount.key.create, serviceaccount.key.revoke, user.create, user.delete, user.role, user.reset-password, memorystore-acl.backfill.
Publishing to the internet
gateway.endpoint.create, gateway.endpoint.update, gateway.endpoint.delete, gateway.endpoint.publish, gateway.endpoint.unpublish, gateway.key.issue, gateway.key.rotate, gateway.key.revoke, gateway.domain.claim, gateway.domain.release, gateway.domain.verify.
Managed data services
memorystore.create, memorystore.delete, memorystore.credential.rotate, memorystore.credential.renew, vectordb.index.create, vectordb.index.update, vectordb.index.delete, vectordb.points.delete, vectordb.credentials.issue, pubsub.topic.create, pubsub.topic.update, pubsub.topic.delete, pubsub.subscription.create, pubsub.subscription.update, pubsub.subscription.delete, pubsub.credentials.issue, serverless.service.create, serverless.service.update, serverless.service.delete, serverless.trigger.create, serverless.trigger.update, serverless.trigger.delete.
Creating or deleting a cache, an index, a topic, a subscription or a serverless service used to leave no trace: the trail showed only what the core API did. All four services now record, so a destructive action in any of them answers "who did this, when, and from where".
If you have tooling that alerts on action values, it will start seeing the names above. They are stable.
What a row carries. A change with a prior state records both before and after in detail, because the interesting changes are relaxations — an allow list emptied, an auth mode dropped to none — and a row holding only the new value cannot tell a deliberate setting from a protection somebody removed. A delete row carries what the resource was, since afterwards there is nothing left to look up. A subscription's delete row also records its unacknowledged backlog: that is the measure of what was actually thrown away.
What a row never carries. Credential material — not a password, not a token, not a prefix of one. Key IDs are recorded, and are safe to search on.
Audit writes never fail the request they record, with two deliberate exceptions: issuing a VectorDB or Pub/Sub credential. Those calls mint a token and leave no other trace, so an unauditable issue is refused (503) rather than performed silently.
Search
GET /v1/search
Any session. Query params: q (substring) and optional project.
Case-insensitive substring search across your visible projects. Contents (agents, secrets, and so on) match only where you hold resource authority; secrets are matched by name only.
Response: 200
{"results": [{"kind": "agent", "name": "...", "project": "...", "project_short": "...",
"route": "...", "subtitle": "..."}],
"next_page_token": "..."}
kind is one of agent, function, service, mcp_server, service_account, secret, project.
Profile (platform model key)
| Method | Path | Auth |
|---|---|---|
| GET | /v1/projects/{id}/inference | project member |
| PUT | /v1/projects/{id}/inference | project admin |
| DELETE | /v1/projects/{id}/inference | project admin |
Inference credential (per project)
Agents call inference with your own Crusoe Intelligence Foundry key. The platform ships no model credential, so this is the only key in play.
GET /v1/projects/{id}/inference
Response: 200 — {"mapped": bool, "key_suffix": "...abcd", "base_url": "…", "model": "…"}.
The key itself is never returned; key_suffix is enough to tell two keys apart and
to confirm a rotation landed.
PUT /v1/projects/{id}/inference
Body: {"api_key": "…"} (optionally base_url, model). Stores the key in the
project's own secret store and materialises it for the runtime. Agents deployed from
then on use it; redeploy an existing agent to move it onto a new key. A per-agent
MODEL_API_KEY secret still wins — see the Agents API.
DELETE /v1/projects/{id}/inference
Removes it. Running agents keep the key baked into their current revision until they are redeployed; a new model-backed agent cannot deploy until a key is mapped again.
/v1/profile and PUT/DELETE /v1/profile/model-key have been removed. They
stored one model credential shared by every tenant and copied it into every
project: any agent could read it out of its own environment, spend could not be
attributed to the project that caused it, and nothing capped it. Key resolution is the per-agent secret,
then the project's own credential, and stops there. Use the routes above.
There is also one administrative route, POST /v1/admin/memorystore-acl/backfill, restricted to platform admins — an internal backfill tool you will not need day to day.
Related pages
- API authentication · Projects and access · Service accounts and API keys · Quotas and audit
- Platform limits — token lifetimes, password rules, and quota defaults in one table.