# API conventions

Base URL: `https://api.agidock.cloud`. Full spec:
[/openapi.json](/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

- `Authorization: Bearer agd_...` — API key. This is your credential, and the
  only one you will ever hold. The first is minted by your human in the
  dashboard ([onboarding](onboarding.md)); after that a key is never
  challenged again.

  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](account.md#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:

- **Money** — `POST topup`, `PUT refill`, and all of `payment-method`,
  including `GET`.
- **The authenticator** — everything under `/v1/account/totp`: enrolling one,
  verifying a code, removing one.
- **Lifting a freeze** — `POST /v1/account/unfreeze`, which also needs a
  current authenticator code in the body. An admin key can throw the switch;
  releasing it is your human's, by construction.

## Routes that exist but are not yours

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

- `POST /v1/account/login`, `GET /v1/account/login/confirm`,
  `POST /v1/account/signup/complete` — the magic-link flow that issues a human
  a `sess_...` token. The first sends mail to your human's inbox; the other two
  need what that mail contains. There is no key-based path to a session.
- `POST /v1/billing/topup`, `PUT /v1/billing/refill`, and every verb of
  `/v1/billing/payment-method` including `GET` — money and the card behind it.
  `403 human_required`, always, no retry. (`GET /v1/billing/refill` *is*
  yours: it reports the ceiling you work under, and `GET /v1/billing/balance`
  what you have left.)
- `POST` and `DELETE /v1/account/totp`, and `POST /v1/account/totp/verify` —
  the account authenticator, all three. `403 human_required`, always.
- `POST /v1/account/export`, `DELETE /v1/account`,
  `POST /v1/account/delete/cancel` — GDPR export, account closure with a
  14-day grace, and the cancel that reverses it. Step-up guarded rather than
  human-only, so a key passes — any key for the export, an admin key for the
  other two. Route these to your human.

## 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](/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:

- `credit_cutoff` (402) — balance at/below zero. Mutating calls are blocked.
  Reads stay open, all of `/v1/support` and `/v1/compliance` stays open — a
  case deadline does not wait for your balance — and so does every call that
  pays the way out: the ones your human makes to top up, and the
  authenticator calls that let them. Running VMs are not touched. After 7 days
  at zero they
  are stopped, disks intact, and 30 days after that anything still there is
  deleted. A top-up at any point resumes everything.
- `read_only_key` (403) — the key has role `read` and the call mutates. Not
  retryable: use a `write` key, or ask your human for one.
- `admin_key_required` (403) — the call reaches past one project: minting or
  revoking keys, managing projects, freezing or deleting the account. Not
  retryable with this key.
- `project_required` (400) — an `admin` key called a project-scoped route
  without the `AGIdock-Project` header. Add it; nothing is assumed.
- `key_project_mismatch` (403) — a `write` or `read` key named a project other
  than its own. Drop the header, or use a key minted into that project.
- `quota_exceeded` (403) — includes `quota`, `used`, `requested`, `cap`.
- `rate_limited` (429) — includes `Retry-After`. Default 120 req/min per key.
- `idempotency_key_reuse` (409) — same key, different request body.
- `idempotency_in_progress` (409) — an earlier request with this key is still
  running. Nothing was executed twice. Retry the *same* key after the
  `Retry-After` pause to collect its result; a fresh key would start a second
  operation.
- `ticket_cap_reached` (409) — 5 open support tickets already; append to
  one (`POST /v1/support/tickets/{id}/messages`) instead of opening more.
- `no_capacity_available` (409) — we have no room for that much RAM right
  now: on create, or on a resize or rollback that would grow a VM. Nothing was
  changed. It is not a quota, so raising yours won't help. A VM that fails
  partway through being built reports `create_failed` instead.
- `create_failed` (409) — `POST /v1/vms` blocks until the VM is built, so
  provisioning failures come back on the create itself, carrying the
  hypervisor's own message. The VM is rolled back and its IP released:
  nothing is retried for you and nothing is left to clean up. Fix what
  `detail` says and POST again.
- `hypervisor_error` (409) — the virtualization layer refused an operation;
  `detail` is its own text, passed through untouched and unclassified. Some
  are permanent (a shape it won't take), some are timing (a guest still
  booting when you asked it to reboot). Read `detail`: if it reads like a
  timeout, retry once after a pause; otherwise fix the cause.
- `vm_not_stopped` (409) — a volume cannot be detached from a running VM.
  Unmount it in-guest, stop the VM, detach, then start it again. Attaching a
  volume needs none of that and works while the VM runs.
- `volume_in_snapshots` (409) — a volume can't be detached while a snapshot of
  its VM includes it, because a snapshot restores a volume only on the VM that
  took it. The `snapshots` extra names them: delete those
  (`DELETE /v1/vms/{id}/snapshot/{name}`), then detach again. Deleting a
  snapshot leaves the volume's current data as it is.
- `vm_has_snapshots`, `vm_has_volumes` (409) — `DELETE /v1/vms/{id}` destroys
  only the VM, and refuses while it still holds anything meant to outlive it.
  Both refusals list what is left in the `snapshots` and `volumes` extras and
  spell out the whole sequence. The order: [VMs](vm.md#lifecycle).
- `operation_in_progress`, `volume_busy`, `storage_busy` (409) — another call
  is already working on that VM, that volume, or the bulk storage behind it.
  Work is serialized per resource and refuses rather than queues: this is the
  ordinary answer to calling in parallel. **Nothing was applied.** Retry the
  identical request.
- `volume_moved` (409) — the volume was attached, detached or deleted by
  someone else between your reading it and your request arriving, so the state
  you asked against no longer exists. Nothing was applied. Re-read
  `GET /v1/volumes/{id}` and decide again: this is the one in the family that
  a blind retry will not fix.
- `volume_already_grown` (409) — another resize passed the size you asked for.
  Nothing was applied, and the volume is already at least as big as you
  wanted; retry only with a larger number.
- `service_saturated` (503) — we are already running as many operations at
  once as we can and declined to start another. Nothing was applied. Back off
  further than for the 409s above: this one is about total load rather than a
  resource you named.

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](vm.md), [email](email.md), [the account](account.md).
