REST API
The REST API lets an agent or a script do what the portal’s PaaSbox Clusters pages do, with a team API key instead of a browser. This page lists every endpoint with its scope, its body, its answer and its errors, and the rules that apply to all of them.
Base URL and authentication
Section titled “Base URL and authentication”| Item | Value |
|---|---|
| Base path | /api/v1/teams/{team}/k3s/ on the portal’s host. The API keys & agents page shows the full URL for your team. |
{team} | The team’s slug. It must be the key’s own team; any other answers 404 not_found. |
{cluster} | The cluster’s slug (its DNS label) or its id (a UUID). A slug finds a cluster that is not deleted; an id also finds a deleted one. |
| Header | Authorization: Bearer <key>. Authorization: Api-Key <key> works too. |
| Key | <prefix>.<secret>, created by a team admin on API keys & agents (Give an agent access). The prefix names the key in the portal and in the audit log. |
| Browser session | A signed-in session works on the REST endpoints too, with the person’s own rights and all four scopes. |
| Conditions on every call | The key is not revoked or expired, its owner is still an active member of the team, and the team has PaaSbox Clusters. |
| Format | JSON, camelCase fields, times as RFC 3339 strings in UTC. |
Scopes and roles
Section titled “Scopes and roles”Every endpoint needs exactly one scope. A key holds the scopes ticked when it was created.
| Scope | Allows |
|---|---|
k3s:read | See clusters, their health, nodes, snapshots, operations, upgrades and add-ons, and the Hetzner projects and server types to create a cluster on. |
k3s:write | Take snapshots, schedule upgrades, and switch or configure add-ons. |
k3s:access | Ask for temporary kubeconfigs, sealed to the caller’s own key, and revoke them. |
k3s:destructive | Create, delete, detach and restore clusters, each only with the cluster’s name typed as confirmation. |
A key acts with the rights of the person who created it, in that team. Members read; every change needs a team admin; a member gets a view kubeconfig unless the team allows admin. A scope narrows these rights; it never widens them.
A key belongs to one team and reaches every cluster of that team: no scope can be limited to one cluster. A key with k3s:destructive that creates throwaway clusters can also delete production if production is in the same team; keep production in a team the key does not belong to.
Endpoints
Section titled “Endpoints”Paths are relative to the base path. “Admin” means the key’s owner must be a team admin. The operation id is what the audit log records as the call.
| Method | Path | Scope | Admin | Answer | Operation id |
|---|---|---|---|---|---|
| GET | projects/ | k3s:read | — | 200 {items: [{id, name, ref}]} | k3s_projects_list |
| GET | projects/{project}/server-types/ | k3s:read | — | 200 locations and server types | k3s_projects_server_types |
| GET | clusters/ | k3s:read | — | 200 a page of clusters | k3s_clusters_list |
| POST | clusters/ | k3s:destructive | yes | 202 {cluster} | k3s_clusters_create |
| GET | clusters/{cluster}/ | k3s:read | — | 200 the cluster in full | k3s_clusters_get |
| DELETE | clusters/{cluster}/ | k3s:destructive | yes | 202 {cluster} | k3s_clusters_delete |
| GET | clusters/{cluster}/status/ | k3s:read | — | 200 state and health | k3s_clusters_status |
| GET | clusters/{cluster}/nodes/ | k3s:read | — | 200 {items: [node]} | k3s_clusters_nodes |
| POST | clusters/{cluster}/detach/ | k3s:destructive | yes | 200 {cluster} | k3s_clusters_detach |
| GET | clusters/{cluster}/snapshots/ | k3s:read | — | 200 a page of snapshots, newest first | k3s_snapshots_list |
| POST | clusters/{cluster}/snapshots/ | k3s:write | yes | 202 {operation} | k3s_snapshots_create |
| POST | clusters/{cluster}/restore/ | k3s:destructive | yes | 202 {operation} | k3s_clusters_restore |
| GET | clusters/{cluster}/operations/ | k3s:read | — | 200 a page of operations, newest first | k3s_operations_list |
| GET | clusters/{cluster}/operations/{operation_id}/ | k3s:read | — | 200 the operation with its events | k3s_operations_get |
| GET | clusters/{cluster}/upgrades/ | k3s:read | — | 200 upgrade information | k3s_upgrades_get |
| POST | clusters/{cluster}/upgrades/ | k3s:write | yes | 202 {operation} | k3s_upgrades_schedule |
| GET | clusters/{cluster}/addons/ | k3s:read | — | 200 the release’s add-ons | k3s_addons_list |
| PATCH | clusters/{cluster}/addons/{name}/ | k3s:write | yes | 200 the saved add-on | k3s_addons_set |
| POST | clusters/{cluster}/access/ | k3s:access | for admin, if the team does not allow members | 202 {operationId, operation, resultUrl} | k3s_access_request |
| GET | clusters/{cluster}/access/{operation_id}/result/ | k3s:access | — | 202, then 200 once | k3s_access_result |
| POST | clusters/{cluster}/access/revoke/ | k3s:access | for anyone but yourself | 202 {operation} | k3s_access_revoke |
A 202 means the portal queued the work. Changes to a cluster run as operations that pbx-agent picks up at its next sync, about every 30 seconds: poll the operation until its state is succeeded or failed. A new cluster is polled with GET clusters/{cluster}/ until its state is ready.
Request bodies and parameters
Section titled “Request bodies and parameters”Create a cluster: POST clusters/
Section titled “Create a cluster: POST clusters/”The create page’s form as JSON, checked by that same form. Before the form, the portal checks in this order: a connected Hetzner project, a release offered on the stable channel, the team’s cluster limit, the accepted terms, and billing. A team’s first cluster starts its subscription and is created in the portal, not through the API (402 payment_method_required); so is the first cluster after the team’s subscription has ended. The form is checked against the release on the stable channel, also for channel: early.
| Field | Type | Required | Default and limits |
|---|---|---|---|
name | string | yes | ≤ 100 characters |
confirm | string | yes | the new cluster’s name, typed again |
project | integer | yes | the Hetzner project’s id, from GET projects/ |
slug | string | — | the DNS label, ≤ 32 characters; made from the name |
location | string | — | from GET projects/{project}/server-types/ |
serverType | string | — | from the same list |
adoptServer | object | — | instead of a new server: id and confirmName (the server’s name, typed: it is rebuilt), keepVolumes and detachFirewalls (both false) |
channel | string | — | stable (default) or early |
topology | string | — | single |
maintenance | object | — | days (default saturday, sunday), start 02:00, end 05:00, timezone Europe/Berlin |
allowedApiRanges | list of CIDRs | — | who may reach port 6443; default 0.0.0.0/0 and ::/0 |
backup | object | — | holder portal (default) or customer; s3 with endpoint (host, no scheme), bucket, folder, region, accessKey, secretKey; scheduleCron (default 0 */6 * * *); keepCycles 1–500 (default 28) |
hcloudToken | object | — | mode separate (default) or copy; token for separate |
platform | object | — | profile, one the create form offers for the PaaSbox Platform add-on; observability (default false). Since paasbox-platform 1.0.2 (release 2026.10.2) the platform needs an ACME e-mail (acmeEmail). The portal’s create page asks for it; the API has no field for it, so a create with platform is refused with 422 validation_failed. Create the cluster without platform and switch the platform on with PATCH …/addons/paasbox-platform/ |
The other bodies
Section titled “The other bodies”| Endpoint | Body or query | Rules |
|---|---|---|
GET projects/{project}/server-types/ | query location | Default fsn1, else the project’s first location. Each server type: name, arch, cores, memoryGB, diskGB, cpuType, monthlyNetEUR (Hetzner’s monthly net price). Hetzner’s answer is kept for 10 minutes. |
GET clusters/, …/snapshots/, …/operations/ | query page (from 1), page_size (≤ 100, default 25) | The answer is {items, count, next, previous}. |
GET …/operations/ | query state, type | One of the values below; anything else is 422. |
DELETE clusters/{cluster}/ | confirm, finalSnapshot (default true), as a body or as query parameters | The cluster stops counting for billing at once (Costs and billing); the servers, network and firewall go in the background, after a final snapshot unless finalSnapshot is false. |
POST …/detach/ | confirm | The cluster keeps running; the portal stops managing and billing it. |
POST …/restore/ | snapshot (the name in the snapshot list), confirm | The cluster must be ready or failed. Its API stops while the restore runs, and everything written since the snapshot is lost. |
POST …/snapshots/ | none | The cluster must be running. |
POST …/upgrades/ | when: window or now; release (optional) | Only a ready cluster with no pending upgrade. release is a guard: refused when another release is on offer. window waits for the next maintenance window In progress; now runs at the next sync and reboots the node. |
PATCH …/addons/{name}/ | enabled, options, also (all optional) | Only what is named changes. options are merged over the stored ones, null unsets one, secret options are kept when not sent. also names other add-ons this change may switch in the same save: one this add-on requires goes on; one it conflicts with, or one that requires it when it goes off, goes off. The add-ons page’s checks apply: its options schema, requires, conflicts, and memory against the server. A baseline add-on cannot be switched off. The PaaSbox Platform with Flux: {"enabled": true, "options": {"profile": "saas-http01", "acmeEmail": "you@example.com"}, "also": ["flux"]}. |
POST …/access/ | publicKey, role (view default, or admin), lifetimeSeconds (600–86400, default 3600) | publicKey is base64 of the 65-byte uncompressed point of an ECDH P-256 key you made for this request. The cluster must be ready, upgrading or restoring. |
GET …/access/{operation_id}/result/ | none | 202 {operationId, state, ready: false, retryAfterSeconds: 5} while pbx-agent works; 200 {operationId, state, ready: true, sealed, aad, expiresAt} exactly once; 410 gone after it was taken or 5 minutes passed; 409 conflict when the request failed. Only the key, or the session, that asked can take it. |
POST …/access/revoke/ | user: me (default), all, or a member’s id or e-mail | Deletes the ServiceAccounts behind the kubeconfigs at the next sync. |
The sealed kubeconfig is {alg, epk, nonce, ciphertext} with alg ECDH-P256+HKDF-SHA256+A256GCM. To open it: ECDH of your private key with epk, HKDF-SHA256 with 32 zero bytes of salt and the info pbx-access-v1 to 32 bytes, then AES-256-GCM with nonce and the operation id (aad, UTF-8) as associated data. The portal’s helper paasbox_kubeconfig.py does this (Give an agent access).
Response objects
Section titled “Response objects”| Object | Fields |
|---|---|
| cluster | id, name, slug, state, location, serverType, arch, topology, project {id, name}, channel, autoPatchUpgrades, release, desiredRelease, apiEndpoint, publicIPv4, allowedApiRanges, unreachable, lastSeenAt, createdAt |
cluster in full (GET clusters/{cluster}/) | the cluster, plus status, maintenance (with the next window), nodes, lastSnapshot, backup {holder, schedule, keep, target}, upgrade |
| status | state, step, failure {during, step, message}, unreachable, unreachableSince, lastSeenAt, apiHealthy, generation, observedGeneration, certificatesExpireAt, flags ({level, text}, worst first), activeOperation, waitingOperations |
| node | name, role, state, ready, leader, unschedulable, k3sVersion, release, arch, publicIPv4, diskFreeBytes, lastSeenAt |
| snapshot | name, location (s3 or local, one row per copy), kind, takenAt, sizeBytes, node |
| operation | id, type, state, params, requestedBy, windowBound, notBefore, notAfter, createdAt, acceptedAt, finishedAt, step, failedStep, message, result; with events (seq, at, state, step, message, log, result) on GET …/operations/{operation_id}/ |
| upgrade information | current and available ({version, k3sVersion, status, paused, notes}, available with patch), pending, channel, autoPatchUpgrades, canSchedule, nextWindow |
| add-on | name, title, kind, version, enabled, editable, options, secretOptionsSet, optionsSchema, notEditableOptions, requires, conflicts, memoryMiB, minServerMemoryMiB, fits, memoryByOptions, reported |
| saved add-on | addon, switched ([{addon, enabled}]), generation, note |
No answer carries your Hetzner token, the bucket’s keys or the cluster’s tokens; backup.target is s3://<endpoint>/<bucket>/<folder>.
| Value set | Values |
|---|---|
Cluster state | pending, provisioning, ready, upgrading, restoring, deleting, deleted, detached, failed |
Operation type | snapshot.save, cluster.restore, cluster.upgrade, server.rejoin, node.drain, node.forget, agent.rotate_key, access.issue, access.revoke, diag.collect, s3.verify |
Operation state | queued, delivered, accepted, running, succeeded, failed, unsupported, expired, cancelled |
Destructive operations
Section titled “Destructive operations”Create, delete, detach and restore need k3s:destructive, a team admin as the key’s owner, and confirm:
- for delete, detach and restore, the cluster’s
name(not its slug), exactly; - for create, the new cluster’s name, typed again.
A confirm that is missing fails the body check, 422 validation_failed with field: "confirm"; any other value than the name is 422 confirmation_required. The portal checks the name, not that a person agreed: an agent that knows the name can send it. Whether your agent asks you first is up to its client.
Errors
Section titled “Errors”Every error is an RFC 7807 problem document (application/problem+json): type, title, status, detail, code, instance, operationId, and field when one input is at fault. Some add members: requiredScope (insufficient_scope), errors by field (validation_failed), canSwitchWith (add-ons). Switch on code.
| Status | code | When |
|---|---|---|
| 400 | invalid_request | A malformed request. |
| 401 | unauthenticated | No key, or a key that is unknown, revoked or expired; or its owner is no longer an active member. |
| 402 | payment_method_required | Create: the team’s first cluster, which starts the subscription in the portal. |
| 402 | account_past_due | Create: the team’s account is past due. |
| 403 | insufficient_scope | The key lacks the scope (requiredScope names it), or carries no k3s scope at all. |
| 403 | admin_required | The change needs a team admin, or a member asked for an admin kubeconfig the team does not allow. |
| 403 | not_enabled | PaaSbox Clusters is not enabled for the team. |
| 403 | terms_not_accepted | Create: a team admin has not accepted the terms in the portal. |
| 404 | not_found | No such team, cluster, snapshot, operation, add-on, project or member, or not yours. Another team’s cluster answers the same. |
| 409 | conflict | The cluster’s state does not allow it, an operation is already queued, no release is on offer, or Hetzner refused the project’s token. |
| 409 | quota_exceeded | Create: the team’s cluster limit (10 at once, more on request). |
| 410 | gone | The sealed kubeconfig was already taken or is older than 5 minutes. |
| 422 | validation_failed | A field is wrong; errors lists every field by its API name. |
| 422 | confirmation_required | confirm is missing or not the name. |
| 429 | rate_limited | Too many calls; Retry-After gives the seconds to wait. |
| 500 | internal_error | A failure in the portal. |
| 503 | reconciler_unavailable | The Hetzner API answered with an error. |
Rate limits
Section titled “Rate limits”| Limit | Default |
|---|---|
| Calls per minute, per key (per person for a browser session) | 120 |
| Changing calls (POST, PATCH, DELETE) per minute, within those | 20 |
| Window | fixed, one minute |
| Shared with | the MCP tools of the same key |
Every changing call, and every request for and hand-out of a kubeconfig, is recorded with the key, the person, the operation id, the cluster, the arguments without secrets, the outcome and its error code. Refusals for a missing scope, a role, the cluster’s state or a wrong confirmation are recorded too. Not recorded: reads, polls for a kubeconfig that is not ready yet, and calls refused before their check, for an unknown key, the rate limit or a malformed body. The team’s admins read the record on API keys & agents (Give an agent access).
OpenAPI schema
Section titled “OpenAPI schema”The portal serves the OpenAPI document at /api/schema/, with Swagger UI at /api/schema/swagger-ui/ and ReDoc at /api/schema/redoc/. The endpoints on this page carry tags that start with “PaaSbox Clusters”; the key scheme is TeamKey.
Example
Section titled “Example”curl -H "Authorization: Bearer $PAASBOX_API_KEY" \ "$PAASBOX_PORTAL/api/v1/teams/acme/k3s/clusters/upcheck-prod/status/"A delete refused because confirm was not the cluster’s name:
{ "type": "https://app.paasbox.com/errors/confirmation_required", "title": "Typed confirmation required", "status": 422, "detail": "deleting a cluster needs confirm set to the cluster's name, exactly: 'upcheck-prod'", "code": "confirmation_required", "instance": "/api/v1/teams/acme/k3s/clusters/upcheck-prod/", "operationId": null, "field": "confirm"}MCP tools has the same operations as tools for an agent, and Guardrails for agents what these rules stop and what they do not.