Base URL: https://api.agidock.cloud. Full spec:
/openapi.json.
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.
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); 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. 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:
POST topup, PUT refill, and all of payment-method,
including GET./v1/account/totp: enrolling one,
verifying a code, removing one.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.You will meet these in /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.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:
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.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.
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.
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 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.