API conventions

Base URL: https://api.agidock.cloud. Full spec: /openapi.json.

The /v1 promise: append-only

Within /v1, endpoints and response fields are **added, never renamed, removed, or re-typed**. A breaking change would arrive under a /v2 prefix; none is planned.

Authentication

Roles: read (GET only), write (the whole operating surface — VMs, volumes, email credentials, tickets), both confined to the one project the key was minted into; admin (write across every project in the account, plus issuing and revoking keys, managing projects, and freezing or deleting the account). An admin key belongs to the account, so each project-scoped call names its project with the AGIdock-Project header — see projects. Issuance is strictly downward — an admin key may mint write and read keys but **never another admin** (403 admin_key_human_only). Revocation stops minting instantly; ordinary reads on a revoked key can keep working for up to a minute after it. Use write unless you need to manage credentials or reach more than one project.

Rate limits issue downward too: a new key's limit may not exceed that of the key minting it, nor the platform ceiling (400 rate_limit_invalid, 403 rate_limit_not_downward).

``` GET /v1/account/keys list keys with last-used times; never secrets POST /v1/account/keys mint one (admin key, or your human): write or read into the project AGIdock-Project names; admin with no header DELETE /v1/account/keys/{id} revoke; minting on that key stops instantly (admin key, or your human) ```

The other credential in the spec, Bearer sess_..., is your human's dashboard session; there is no key-based path to one. It matters only for step-up, which asks how recently a session entered an authenticator code. Step-up applies to sessions only and never challenges an API key, so 403 totp_step_up_required is a response you cannot receive.

Human-only is the rule that binds you. It asks *who is calling* and refuses API keys with 403 human_required, which no retry resolves. Two groups are human-only:

Routes that exist but are not yours

You will meet these in /openapi.json. None of them are yours to call.

Errors: RFC 7807 problem+json

Every error body looks like:

{
  "code": "credit_cutoff",
  "detail": "credit balance is 0; top up via POST /v1/billing/topup — running VMs are unaffected",
  "status": 402,
  "title": "Payment Required"
}

Four fields, always all four. title is the status text and status the HTTP status, so neither tells you anything the response line did not — code and detail are the two to read. Some errors add machine-readable fields beside them at the top level, listed with the code below.

code is stable and machine-matchable; detail always states the fix. There are more codes than the list below, and /openapi.json names only a few per status: these are the ones worth branching on, and for anything else detail is the instruction.

Codes you must handle:

Retry policy: retry 5xx, 429, idempotency_in_progress, and the per-resource timing codes (operation_in_progress, volume_busy, storage_busy) — honoring Retry-After where present — with backoff. volume_moved needs a re-read first and volume_already_grown a different size. Never retry any other 4xx except after fixing what detail says.

Failures originating in a backing system — the hypervisor, the payment provider, an unreachable internal service — answer 409, not 502, so their detail reaches you intact. Read it: it says whether the condition is yours to fix or worth retrying.

Idempotency

Every mutating endpoint honors Idempotency-Key (any string up to 200 chars, scoped to your account, 24-hour window). Same key + same body → the stored response is replayed, even across network failures. Same key + different body → 409 idempotency_key_reuse. Always send one: it is what makes a retry after a timeout safe.

The key is claimed before the work starts, so this holds while the first request is still running too: a retry that overtakes it gets 409 idempotency_in_progress instead of starting the operation again. That is the usual answer when a create takes longer than your client's timeout — keep retrying the same key and one of the attempts returns the stored result.

Pagination

One route pages: GET /v1/billing/ledger, newest entry first.

GET /v1/billing/ledger?limit=100
  -> {"entries": [...], "next_cursor": 8412}
GET /v1/billing/ledger?limit=100&cursor=8412    the next page, older

next_cursor is the id of the last entry on the page, not a flag for "more exist" — it is zero only when the page came back empty. **Stop when entries is shorter than limit**; waiting for a zero costs one more request for a page with nothing in it. limit defaults to 100, and anything outside 1-500 is read as absent: limit=1000 returns 100, not 500. Filters (?kind=, ?exclude=) are per request, so repeat them on every page.

Nothing else pages. Three lists return their newest 100 with no way to reach past it: GET /v1/compliance/cases, GET /v1/support/tickets and GET /v1/vms/{id}/events. GET /v1/vms, /v1/volumes, /v1/images and /v1/account/keys return everything you have, uncapped. GET /v1/usage is not a list at all — it aggregates over the from/to window you pass.

IDs

IDs are opaque strings carrying a type prefix (vm_..., vol_..., key_...) and sort by creation time. Treat them as tokens: match on the prefix if you must route on type, and never parse past it.

A resource's own shape — what a VM reports, what a volume reports — belongs to its guide: VMs, email, the account.