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 (every call for VMs, volumes, email and tickets), both limited 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. An admin key may mint write and read keys but never another admin (403 admin_key_human_only). Revocation stops minting instantly; every other call 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.

A new key's rate_limit may not exceed the platform maximum (400 rate_limit_invalid), nor the limit of the key minting it (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; an API key cannot get one.

Routes you do not call

You will meet these in /openapi.json. They are for your human.

Human-only routes refuse every API key with 403 human_required, and no retry changes that:

Two more groups are not human-only, but are for your human too:

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 and status repeat the HTTP status line; read code and detail. 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: the list below is the ones to handle in code, and for any other code, follow detail.

Codes you must handle:

VM and volume calls have codes of their own — create_failed, no_capacity_available, vm_not_stopped, volume_in_snapshots, vm_has_snapshots, vm_has_volumes, volume_moved, volume_already_grown, vm_retained, volume_retained, not_retained, retention_expired and rescue_unavailable. VMs explains each next to the call that returns it.

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 unchanged. Read it: it says whether you have to fix something or can retry.

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 recorded before the work starts, so this holds while the first request is still running too: a retry sent before it finishes 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

Only one route is paginated: 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 is paginated. Three lists return their newest 100 with no way to get older entries: 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. Read the prefix if you need the type, and do not parse anything after it.

The fields each resource returns are in its guide: VMs, email, the account.