API Conventions
Control plane API conventions: envelopes, errors, pagination, identifiers, idempotency, filters.
These conventions apply to the control plane API on every route. The gateway uses OpenAI formats instead; see Calling the gateway.
Response shapes
Every list response is an envelope, never a bare array:
{
"items": [],
"count": 0,
"next_cursor": null
}
count is the number of items in this page. A bare array cannot grow a cursor or a total without breaking every client, and lists that never paginate are a temporary condition.
Every detail response is the object itself, unwrapped. A singular envelope buys nothing.
Error format
Errors are uniform:
{
"code": "not_found",
"message": "human readable, never parsed",
"detail": {}
}
code is the contract; message is for people. HTTP status carries the same information for anything that only speaks status codes, but clients should branch on code.
Error codes
400: a malformed request, such as a body that does not decode or an unparseable query parameter. An unknown query parameter is400withcode: "unknown_parameter".detailcarriesunknown(the offending names) andaccepted(everything that route declares)401 not_authorized: missing, unrecognized, expired or tampered credential403 forbidden: credential of the wrong kind for the route, or a browser write from a disallowed origin404 not_found: object or route does not exist409 conflict: clash with current state, such as a staleIf-Matchrevision or a duplicate name415 unsupported_media_type: body is notapplication/json422 invalid: a well-formed request the plane refuses, such as a validation failure429 rate_limited: too many failed sign-ins; see Authentication500 internal: server error
Unknown parameters are rejected
Unknown query parameters are rejected with 400 and code: "unknown_parameter". The default web-framework behaviour (ignore what you do not recognise) is the wrong one for a filtered collection: a misspelt, retired, or invented filter answers 200 OK with the whole set, and the response is indistinguishable from a correctly filtered one. A silently ignored filter is a lie the caller cannot detect.
The accepted set is derived from the matched handler’s own signature at request time, under each parameter’s wire name. /health, /metrics and the OpenAPI routes are exempt.
Pagination
List routes accept limit and cursor params. limit caps the page size: the default is 50, the maximum is 200 (larger values are clamped), and a value below 1 is 422 invalid. Follow next_cursor for more:
GET /v1/clusters?limit=20
GET /v1/clusters?limit=20&cursor=eyJ...
When next_cursor is null, you’ve reached the end.
Identifiers
Prefixed, opaque, stable: clu_, nod_, dev_, tpd_, pol_, art_, rec_, dep_, rep_, end_, prj_, qta_, opr_, cmp_, ten_, prc_, plc_, alc_, wkl_, evt_.
The body is a Crockford base32 ULID, lowercase. Crockford is case-insensitive when decoding, but these ids are opaque strings that get compared, indexed and put in URLs, so exactly one spelling is canonical.
IDs and names
A path segment accepts either the id or the name where an object has a unique name:
GET /v1/clusters/iad-1
GET /v1/clusters/clu_01hq0v8p2m
Both are the same request. Prefix matching is not supported by the API: hgr resolves prefixes client-side.
Timestamps
Timestamps are RFC 3339, UTC, millisecond precision. Nullable where the event may not have happened; last_sync_at on a never-synced cluster is null, not the epoch.
Declared and observed
An object’s declared and observed halves are returned together but never merged. The rule is visible in the field names:
{
"id": "clu_01hq0v8p2m",
"name": "iad-1",
"phase": "active",
"reachable": true,
"sync": {
"state": "connected",
"last_sync_at": "2026-10-27T12:34:56.789Z",
"lag_ms": 340
}
}
phase is declared: what we asked for, one of pending | active | draining | disabled. reachable and sync are observed: what is true right now.
The split is not cosmetic. Folding reachability into phase as an unreachable value makes “the network is down” indistinguishable from “someone disabled this cluster.” Those need different responses from a human and different behaviour from the placement layer, so they cannot share a field.
Idempotency
POST /v1/operations accepts an Idempotency-Key header. A second request with the same key and the same body returns the original operation without creating another. The same key with a different request is 409 conflict.
POST /v1/operations
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
No other route reads this header.
Conditional updates
PATCH requests on declared objects (clusters, pools, artifacts, workloads, endpoints, deployments, tenants, projects, quotas and similar) accept If-Match against the object’s revision, as does DELETE /v1/clusters/{ref}. The revision is the object’s last write time in microseconds, sent as the bare value:
PATCH /v1/clusters/clu_01hq0v8p2m
If-Match: 1761568496789123
A mismatch is 409 conflict with the current revision in detail.revision, so the caller can read fresh state and retry.
Partial updates
PATCH is a partial update. A field absent from the body is untouched. null explicitly clears a nullable field.
Common query params
Most collections support:
limit: page sizecursor: pagination cursorsearch: substring match on name or idlabel: repeatable filter,k=vformat. Example:?label=env=prod&label=region=us
Object-specific filters vary by collection. Examples: ?cluster=iad-1, ?phase=running, ?project=prj_xyz.