Docs / HTTP API
KLYRN VM controller HTTP API (v1)
Base: https://<controller>:8443/api/v1. JSON in, JSON out. Errors are {"error":{"code","message","hint?","details?"}} with the HTTP status implied by the code (invalid 400, unauthenticated 401, forbidden 403, not_found 404, conflict 409, not_ready 409, capacity 409, rate_limited 429, unavailable 503, internal 500).
Authentication: a session cookie (klyrnvm_session, SameSite=Strict) or Authorization: Bearer klyrnvm_…. State-changing requests from a browser must carry X-Klyrn-Request: 1 (CSRF), and Origin must match.
Lists take ?page=&page_size=&sort=&q= plus documented filters and return api.Page[T]: {items, total, page, page_size, pages}. Page size defaults to 50, max 200.
Setup and auth
| Method | Path | Body → Result |
|---|---|---|
| GET | /setup/status | → SetupStatus |
| POST | /setup | SetupRequest → LoginResult (creates the first admin; the token was printed by the installer) |
| POST | /auth/login | LoginRequest → LoginResult |
| POST | /auth/logout | |
| GET | /me | → Me |
Overview and search
| GET | /overview | → Overview | | GET | /search?q= | → []SearchResult (server-side, capped at 20) | | GET | /events | server-sent events: node, vm, task, task_log, image, alert, activity, system |
Nodes (admin)
| Method | Path | Notes |
|---|---|---|
| GET | /nodes | filters: state, mode, location → Page[Node] (facts omitted in lists) |
| POST | /nodes/bootstrap | NodeBootstrapRequest → NodeBootstrap (token shown once) |
| GET | /nodes/bootstrap | pending tokens → []NodeBootstrap (no token) |
| DELETE | /nodes/bootstrap/{id} | |
| GET | /nodes/{id} | → Node with facts, capacity, health |
| PATCH | /nodes/{id} | NodeUpdateRequest (name, mode observe→managed, maintenance, overcommit) |
| DELETE | /nodes/{id} | revokes the certificate; refused while VMs are placed here |
| GET | /nodes/{id}/domains | observed libvirt domains → []ObservedDomain |
| GET | /nodes/{id}/images | the node's cache → []NodeImage |
| POST | /nodes/{id}/inventory | ask for a fresh inventory → Task |
| POST | /nodes/{id}/preflight | re-run readiness → Task |
| GET | /nodes/{id}/tasks | → Page[Task] |
| GET | /nodes/{id}/samples?hours= | hourly capacity points |
The bootstrap command served for a token: GET /get-node.sh (public, unauthenticated, the script only; the token travels in the env of the command the UI shows).
Virtual machines
| Method | Path | Notes | |
|---|---|---|---|
| GET | /vms | filters: status, node, project, q → Page[VM] | |
| POST | /vms | VMCreateRequest → VMCreateResult (201; 200 with replayed on an idempotency hit) | |
| GET | /vms/{id} | → VM | |
| PATCH | /vms/{id} | VMUpdateRequest | |
| DELETE | /vms/{id} | VMDeleteRequest (confirm = name; destroy_disks separate) → Task | |
| POST | /vms/{id}/start | → Task | |
| POST | /vms/{id}/shutdown | graceful, 90 s deadline → Task | |
| POST | /vms/{id}/reboot | → Task | |
| POST | /vms/{id}/force-stop | VMPowerRequest{confirm} → Task | |
| POST | /vms/{id}/reset | VMPowerRequest{confirm} → Task | |
| GET | /vms/{id}/tasks | → Page[Task] | |
| GET | /vms/{id}/activity | → Page[AuditEntry] | |
| GET | /vms/{id}/metrics?res=1m&hours=24 | → MetricSeries (from the node; 503 unavailable when offline) | |
| POST | /vms/{id}/console | `{kind?: vnc | serial} → ConsoleTicket` |
| WS | /console/ws/{ticket} | binary RFB frames (noVNC) or bytes (serial) | |
| POST | /vms/{id}/reset-password | {password?} → {user, task_id, chosen?, password?}: the chosen password, or a generated one returned once, set through the guest agent | |
| GET | /vms/{id}/windows | → WindowsSetup: a Windows machine's first boot and whether its chosen password was set | |
| POST | /vms/{id}/windows/credential | → the password a Windows machine set for itself, once | |
| GET | /vms/{id}/migration-targets | admin → []MigrationTarget: every other node judged, with mode live, restart or offline | |
| POST | /vms/{id}/migrate | admin {node_id, idempotency_key} → Task (vm.migrate) | |
| POST | /vms/{id}/cpu | admin {cpu_mode, restart?, idempotency_key} → Task (vm.cpu.set): rewrites only the <cpu> element of the stored definition. A running machine keeps its processor until it is stopped and started; restart shuts it down cleanly and starts it again, and a guest that has not shut down within three minutes is left running, unchanged. The facts' cpu.next_mode says a change is waiting |
Customers see only VMs in their projects. Admins see all and get placement on create.
Passwords. VMCreateRequest.set_password (and the same field on a reinstall) chooses the password for the account the access mode names: root with root_password, the image's own user with user_sudo, and the administrator on Windows, where access_mode is left out. It must pass CheckPassword: 12 to 128 characters, three of lowercase, uppercase, digits and symbols, printable ASCII without spaces, and not containing the account's name. It is never echoed (password_chosen: true says it was taken) and never stored. A Linux guest receives its SHA-512 crypt only. A Windows guest receives it through its own agent once its first boot has reported, and GET /vms/{id}/windows says when (password_set_at) or why not (password_error, with the machine's own password offered once in its place). password: true without set_password still asks for a generated one, returned once.
Tasks
| GET | /tasks | filters: status, op, node, resource_kind → Page[Task] | | GET | /tasks/{id} | → Task | | GET | /tasks/{id}/events?after= | → []TaskEvent | | POST | /tasks/{id}/cancel | |
Images (admin writes, everyone reads enabled ones)
| GET | /images | → []Image | | POST | /images | ImageCreateRequest (custom) | | POST | /images/refresh | read publisher checksums → Task | | PATCH | /images/{id} | {enabled} | | POST | /images/{id}/pull | {node_id} → Task (prefetch on a node) |
Networks and IPAM (admin)
| GET | /networks | → []Network with pools | | POST | /networks | NetworkCreateRequest | | PATCH | /networks/{id} | {default, mtu} | | DELETE | /networks/{id} | refused while allocated | | POST | /networks/{id}/pools | IPPoolCreateRequest | | GET | /pools/{id}/allocations | → Page[IPAllocation] |
Projects, SSH keys, plans, locations, users, tokens
| GET/POST | /projects | | | GET/POST | /ssh-keys, DELETE /ssh-keys/{id} | a user's own; admins see all | | GET/POST/PATCH/DELETE | /plans | | | GET/POST | /locations | | | GET/POST/PATCH | /users | admin | | GET/POST/DELETE | /tokens | APITokenCreateRequest → APITokenCreated once | | GET | /activity | → Page[AuditEntry] (admin: all; customer: own) | | GET | /alerts | open alerts; POST /alerts/{id}/ack | | GET | /settings, PATCH | admin |
Administering people, and support sessions
Everything under /admin/users is administrator only. It is a separate prefix from /users on purpose: /users answers "who can I put on a project" for every page that needs a name, and widening that answer would send a customer's phone number and suspension reason to a drop-down.
| GET | /admin/users | ?q=&role=&status=active|suspended -> Page[Person] | | GET | /admin/users/{id} | one person, with what they own | | PATCH | /admin/users/{id} | name, email, role, phone, company, note. Omitted fields are unchanged | | POST | /admin/users/{id}/suspend | {reason}. Required. The person is shown this sentence when they sign in | | POST | /admin/users/{id}/restore | lifts it and clears the reason | | POST | /admin/users/{id}/sign-out | ends every browser session. API tokens are untouched | | POST | /admin/users/{id}/password | {password}. Never logged, never audited, ends every session | | POST | /admin/users/{id}/impersonate | {reason} -> SupportSession, and the cookie becomes the customer's | | GET | /admin/users/{id}/impersonations | who has been inside this account | | POST | /impersonation/end | any session. Returns the operator to their own |
GET /me carries impersonation when the cookie is a support session, so the UI draws its band from the request every page already makes.
A support session:
- only an administrator may start one, never from an API token, never against an administrator or a member of staff, never against themselves, and never from inside another one.
- lasts 30 minutes and is enforced on the request that discovers the expiry, not only by a sweep.
- is read-mostly. Every GET is allowed. The only three requests that are not reads and are still allowed are
POST /auth/logout,POST /impersonation/endandPOST /vms/{id}/start. Everything else is refused withforbiddenand the message "a support session can look, not act": no delete of anything, no shutdown, reboot, force stop or reset, no console, no create, reinstall, resize or rescue, no token creation or revocation, no SSH key change, no password change, no security group change, nothing under/provision, no sign-in and no SSO redeem. - writes every audit row naming both parties.
actorreadsoperator@example.com as customer@example.com,viaisimpersonation,impersonation_idjoins to the row holding both user ids, andactor_idis the CUSTOMER, so it appears in their own/activity.
Production networking and IPAM
Everything here is admin-only except the machine-scoped calls at the end, which a customer may make for their own machines.
Pools
| Method | Path | What it does |
|---|---|---|
GET | /api/v1/pools | every pool, with counts by state and pressure |
GET | /api/v1/pools/{id} | one pool |
PATCH | /api/v1/pools/{id} | change name, gateway, range, DNS, topology, gateway mode, guest prefix, location, nodes, VLAN, enabled. The CIDR and family never change: that would be a different pool. An edit that would put an address a guest is using outside the range is refused and names the address. |
DELETE | /api/v1/pools/{id} | refused while anything is held, including RELEASING |
POST | /api/v1/networks/{id}/pools | add a pool to a network |
A pool carries topology (nat \| bridged \| routed), gateway_mode (onlink \| subnet), guest_prefix_len (what the GUEST is configured with, 32 for a routed IPv4), node_ids (empty means every node in the location), reserved_count, allocated_count, releasing_count, blocked_count and pressure.
An IPv6 pool is a prefix and prefix_len is the size of the sub-prefix each machine receives (default 64). total is the number of sub-prefixes, never the number of addresses.
Addresses
| Method | Path | What it does |
|---|---|---|
GET | /api/v1/pools/{id}/allocations | every row in the pool: allocations and reservations together, because "what is in this pool" is one question |
GET | /api/v1/pools/{id}/reservations | what is set aside, and why |
POST | /api/v1/pools/{id}/reservations | {ip, range_end?, kind?, note}, the note is required |
DELETE | /api/v1/pools/{id}/reservations/{rid} | release a reservation; one KLYRN made from the pool's own arithmetic is refused |
POST | /api/v1/pools/{id}/block | {ip, note}: take an address out of service; refused while a machine holds it |
POST | /api/v1/pools/{id}/unblock | {ip} |
An allocation's state is allocated, releasing, reserved or blocked. FREE is the absence of a row. releasing means the machine has gone and the compute node has not yet confirmed the host stopped routing and filtering the address; it will not be handed out until it does.
Security groups
| Method | Path | What it does |
|---|---|---|
GET | /api/v1/security-groups | the caller's groups (an admin sees all) |
POST | /api/v1/security-groups | {name, description?, project_id?, default_ingress?, default_egress?}, defaults are drop in, accept out |
GET | /api/v1/security-groups/{id} | one group with its rules |
DELETE | /api/v1/security-groups/{id} | refused while a machine uses it |
POST | /api/v1/security-groups/{id}/rules | {direction, action?, protocol, family?, port_from?, port_to?, cidr?, icmp_type?, note?}; every machine using the group is re-applied |
DELETE | /api/v1/security-groups/{id}/rules/{rid} | same |
A rule can only describe the guest's own traffic. There is no field for an interface, a chain or the host's address.
One machine's networking
| Method | Path | What it does |
|---|---|---|
GET | /api/v1/vms/{id}/networking | NICs, every address with its state, the groups attached, this month's transfer, and how many IPv4 the plan allows |
GET | /api/v1/vms/{id}/bandwidth | the last twelve months, newest first |
POST | /api/v1/vms/{id}/ips | {family?, pool_id?, ip?, nic_id?}: attach another address. Bounded by the plan's ipv4_count for a customer. The anti-spoof rules are rewritten in the same operation. |
POST | /api/v1/vms/{id}/ips/detach | {ip}: the address goes to RELEASING at once and becomes free when the node confirms. The primary address cannot be detached. |
PUT | /api/v1/vms/{id}/security-groups | {group_ids}: replaces the whole set and applies it |
PATCH | /api/v1/vms/{id}/network | {rate_mbps}: 0 removes the cap (admin) |
The traffic allowance
| Method | Path | What it does |
|---|---|---|
GET | /api/v1/traffic?period=YYYY-MM | every machine the caller may see for one period, with the allowance beside the usage, the periods that exist, and the last enforcement decisions. Scoped in SQL: a customer sees their own machines and their own totals |
GET | /api/v1/vms/{id}/traffic?period=YYYY-MM | one machine: its position, its whole enforcement history, and twelve months of transfer |
PUT | /api/v1/plans/{id}/traffic | {traffic_gib, traffic_direction, traffic_action, traffic_throttle_mbps} (admin + admin scope) |
GET | /api/v1/backups/schedule | every scheduled machine the caller may see, with when it last completed, how many copies it has and when the next run is due. Scoped in SQL, like traffic |
PUT | /api/v1/plans/{id}/backups | {backup_schedule, backup_hour, backup_weekday, backup_keep} (admin) |
traffic_direction is outbound (the guest's uploads, the default) or both. traffic_action is report, throttle or suspend. backup_schedule is off, daily or weekly. backup_hour is UTC and backup_weekday is 0 for Sunday. backup_keep is how many COMPLETE backups to keep, and 0 keeps every one of them: the difference between keeping all and deleting all must not be one mistyped digit. A retention of exactly 1 is refused, because while the next backup is being written the copy it replaces would be the only one there is.
Saving a policy deletes nothing. The response carries deletes, the number of backups the new retention WOULD remove, and the removal happens later, after each machine's next backup has completed, so a machine never has fewer good copies than it had a moment ago.
The response to PUT is not just the plan. It carries effective_from, machines, deferred and a written note, because a tightening starts next period. An allowance added to a plan that already has machines on it cannot put them in breach of a limit that did not exist yesterday. Raising or removing an allowance applies at once.
Every usage figure carries resets, samples and first_at. They are the reasons the number can read LOW: a guest restart loses the traffic between the last reading and the restart, and a reporting gap loses the gap. Nothing here can make it read high. Present it as a floor, not as an exact count.
Node operations these use
vm.net.apply carries the addresses, topology, firewall and rate limit for one machine and makes the host agree with all four together. vm.net.release removes host routes and rules and names back exactly what it removed; the controller frees only those. node.net.probe reads what the host really has, so a difference can be shown rather than assumed.
When a host can no longer be asked
POST /api/v1/pools/{id}/force-free with {ip, note} returns a RELEASING address to the pool on an operator's word. It exists because the alternative is worse: an address whose host is gone, rebuilt, or no longer identifiable would otherwise sit out of the pool for ever with no way back. It is admin-only, it is audited, and the note is required: it is the only record of why bypassing the node's confirmation was safe.
The provisioning API (what a billing system drives)
Base /api/v1/provision. Every endpoint needs the provision scope (or admin). This surface adds no way to make a virtual machine: it reaches the same CreateVM the customer panel does. There is one implementation of provisioning and this is not a second one.
Idempotency, which is the point
A billing system RETRIES. POST /provision/services requires idempotency_key. The key is namespaced to the calling token, fingerprinted against the request body, and claimed inside a transaction before any work starts, so twenty concurrent retries of one order produce one machine and twenty identical answers.
- the same key with a different body →
409 conflict, nothing happens - the same key while the first call is still running → the caller waits for it and then gets the first answer
- the same key after a refusal → the order is tried again; a key guards against a duplicate success, not against a retry after a refusal
(integration, external_ref)is unique as a second, independent guard, so a CreateAccount clicked twice with two different keys still cannot make two machines
Services
| Method | Path | Body → Result |
|---|---|---|
GET | /provision/ping | → what this KLYRN is and whether it can provision at all |
GET | /provision/catalogue | → ProvisionCatalogue (plans, images, locations, suspend policy) in one call |
POST | /provision/services | ProvisionCreateRequest → ProvisionResult (201, or 200 with replayed) |
ProvisionCreateRequest.access_mode is optional and opt in. Left out, the customer gets the image's own login user with sudo (ubuntu on Ubuntu), which is what this path has always produced, so an integration whose welcome email says ssh ubuntu@ is never silently changed. Set it to root_password or root_key to offer root instead. An unknown value is refused rather than quietly defaulted. | GET | /provision/services | filters: state, integration, external_ref → Page[Service] | | GET | /provision/by-ref/{ref}?integration= | the billing system's own id → Service | | GET | /provision/services/{id} | → Service | | GET | /provision/services/{id}/events | → []ServiceEvent, newest first | | GET | /provision/services/{id}/usage | ?period=YYYY-MM → ServiceUsage | | POST | /provision/services/{id}/suspend | {mode?, reason?}, never touches a disk | | POST | /provision/services/{id}/unsuspend | reverses what the suspension actually did | | POST | /provision/services/{id}/terminate | {confirm: true, destroy_disks?}, the only destructive call | | POST | /provision/services/{id}/change-plan | {plan_id\|plan_slug, reboot?}, resizes the SAME machine | | POST | /provision/services/{id}/resize | explicit sizes instead of a plan | | POST | /provision/services/{id}/reinstall | same machine, same address, new image | | POST | /provision/services/{id}/retry | recovers a provisioning_failed service | | POST | /provision/services/{id}/sso | → {url, expires_at}, single use, 90 seconds | | GET | /provision/tasks/{id} | → {task, events}, provisioning status, polled rather than guessed |
ServiceState is provisioning → active | provisioning_failed, plus suspended and terminated. **provisioning_failed is the state that has to exist**: the money was taken and the machine was not made, and that is neither "unpaid" (the customer is billed and blamed) nor "active" (the customer is billed for nothing and finds out alone). A failed provisioning releases its storage promise, leaves no orphan address, never reads as a running machine, and has retry as the way out, which reuses the same service row, so the billing system's handle never changes.
Suspension
PUT /provision/settings with {suspend_mode}, provider-wide:
power_off: ACPI shutdown. Disks, addresses and configuration untouched.network_isolate: the guest keeps running; the host stops carrying its traffic. Implemented as a drop-all firewall in the ordinaryvm.net.apply, recorded on the SERVICE row rather than as a security group, because a customer may edit their groups and must never be able to edit their way out of a suspension.
The mode that was used is stored, so unsuspend reverses what happened rather than what the setting says today.
Adoption: linking a service to a machine KLYRN already runs
The dangerous hour of a migration is the one where the billing system knows how to CREATE and the estate is already running.
| Method | Path | Notes |
|---|---|---|
POST | /provision/adopt/preview | AdoptPreviewRequest → AdoptPreview. Writes nothing. |
POST | /provision/adopt | the same body plus confirm (the machine's UUID) |
POST | /provision/link | admin only: link to a machine with no staged record, confirm is the machine's name |
The preview reads three things (the billing system's id, the Migration Center's staging row, and KLYRN's own vms table), and returns one line:
WHMCS service 4128 → Solus server 188 → KLYRN VM 12 legacy-01 MATCH
Verdicts: match, staged_only, already_linked, taken, no_uuid, not_found. Only match may be adopted. would_create_vm and would_change_ip are false on every path that reaches match, and adopt contains no call to CreateVM at all.
The handoff
GET /sso/{token} is public by necessity: the customer is not signed in yet. It is single use, expires in ninety seconds, and only redirects to a path inside this panel.
This page is generated from the guide that ships in the product's repository, so it describes the release it was built from. What changed in each release.