Skip to content

API changelog

This page lists every change to a request or response shape on the VirtConsole APIs: a field that moved, a status code that used to be wrong, a header that is now sent, an endpoint that is new or renamed. It does not list every bug fix or new feature - only changes an existing integration needs to know about to keep working.

If you maintain a script or service that calls the admin API (/api/v1) or the user API (/api), read the section for the release you are upgrading past.

Several response-shape changes in 3.2.3.3 shipped without an announcement, breaking integrations that relied on the old shapes. This page records every shape change from now on; see also the new rule in the API contract guide requiring a changelog entry for every future shape change.

Every response on the admin API (/api/v1) now normalises to one shape:

{success: bool, message?: string, data?: mixed, meta?: object, errors?: object}

Every response also carries the header X-Api-Envelope: v1, so a client can detect the new shape without guessing from the version number.

Before (a bare model, a bare list, or a hand-built resource key such as plan, user, security_group or vpc):

{"id": "9a89c953-...", "name": "web-1", "status": 1}

After:

{"success": true, "data": {"id": "9a89c953-...", "name": "web-1", "status": 1}}

A body that already used {"error": "..."} is folded the same way:

{"error": "Instance not found"}

becomes

{"success": false, "message": "Instance not found"}

This change is breaking for any admin API client. It does not touch the user API (/api), which keeps its existing per-endpoint shapes.

Paginated lists: items under data, pagination under meta

Section titled “Paginated lists: items under data, pagination under meta”

A Laravel paginator body used to ship its pagination fields (current_page, per_page, total, last_page, from, to, links, and the rest) mixed in at the top level next to the row array. It is now split: rows go under data, pagination goes under meta.

Before:

{
"current_page": 1,
"data": [{"id": "9a89c953-...", "name": "web-1"}],
"per_page": 10,
"total": 1,
"last_page": 1
}

After:

{
"success": true,
"data": [{"id": "9a89c953-...", "name": "web-1"}],
"meta": {
"current_page": 1,
"per_page": 10,
"total": 1,
"last_page": 1,
"from": 1,
"to": 1
}
}

POST /api/v1/user/{userId}/impersonate used to answer with the impersonation token and its metadata at the top level. It now nests under data, per the envelope rule above.

Before:

{"token": "...", "token_id": "...", "expires_at": "2026-09-25T12:00:00.000000Z"}

After:

{
"success": true,
"data": {"token": "...", "token_id": "...", "expires_at": "2026-09-25T12:00:00.000000Z"}
}

Failure responses now carry a real HTTP status code

Section titled “Failure responses now carry a real HTTP status code”

Before this release, a failed admin API operation frequently answered HTTP 200 with "success": false in the body, which many HTTP clients treat as a success. Every admin API response whose body is {"success": false, ...} is now rewritten to HTTP 400, with a header X-Failure-Status-Mapped: 1 marking the rewrite. The body is unchanged byte for byte.

Before: HTTP 200 with {"success": false, "message": "Selected plan is not available."}

After: HTTP 400 with the same body, plus X-Failure-Status-Mapped: 1.

A validation failure that already used Laravel’s {"message": ..., "errors": {...}} shape without a success key answers HTTP 422 as before, and is left as {"success": false, "message": ..., "errors": {...}} under the envelope.

The user API (/api) is not covered by this change; its endpoints already return the correct status code per endpoint.

  • POST /api/v1/instance/{id}/rescue - boot an instance into a rescue environment ({"enable": true|false}); the response includes the rescue session’s root/Administrator credentials only while rescue mode is active, and entering rescue on a stopped instance starts it (by design; the guest is not left stopped inside rescue mode).
  • POST /api/v1/instance/{id}/change-plan - change an instance’s plan, applying the same “plan must be enabled” and “storage cannot shrink” checks as the admin panel.
  • POST /api/v1/vpcs - create a VPC from the admin API (previously user-panel and user API only).
  • POST /api/v1/backup-policy/{id}/attach and POST /api/v1/backup-policy/{id}/detach - enrol or remove one instance from a database backup policy from the admin API (previously user API only).

Example rescue response:

{
"success": true,
"data": {
"task_id": "9a89c953-...",
"rescue": {"active": true, "since": "2026-09-25T09:00:00Z", "username": "root", "password": "..."}
}
}

Every instance payload (admin API and user API show responses) now includes a machine-readable state string: one of migrating, pending, suspended, running or stopped. It is derived the same way the admin panel’s own status column and the instance list’s status pill are, so a client no longer has to reconstruct it from the internal status integer plus the suspended/admin_suspended/suspended_network flags. The existing status integer field is unchanged for backward compatibility.

The VNC/noVNC console proxy requires a short-lived, single-use session token. A token is minted server-side (default TTL 60 seconds on the panel, 10 to 600 seconds configurable on the API) and is consumed on first use; a replayed token is rejected. Any integration opening the console directly (rather than through the panel’s own “Open console” button) needs to request a token first.

Hostname and FQDN fields across the API (instance hostname, reverse DNS, and related fields) are now validated by one shared rule instead of three slightly different regular expressions that used to disagree with each other. The rule: each label is 1 to 63 characters, starts and ends with an alphanumeric character or underscore, allows hyphens only in the middle (no leading, trailing or doubled hyphen), and the full name is at most 253 characters. A trailing dot (FQDN form) is accepted. A previously-accepted value like web--1 or a label ending in a hyphen-digit now correctly fails with "The :attribute must be a valid hostname." where it may have passed (or failed) inconsistently before, depending which of the three old copies of the check ran.

GET /api/v1/hypervisor/backup-plans, which appeared in older documentation, never had a working route and always answered 404. It has been removed from the API reference. The working equivalent is GET /api/v1/hypervisor/{id}/backup-storages, whose description now notes it was formerly (mis)documented as “backup plans”. Per-instance backup policy enrolment is a separate feature; see POST /api/v1/backup-policy/{id}/attach above.

The changes below shipped in 3.2.3.4, the patch release after 3.2.3.3.

Section titled “Console links: console_url opens without the admin token, url is WebSocket only (2026-09-26)”

POST /api/v1/instance/{instanceId}/console-session still returns the same four fields, but two of them behave differently.

console_url now points at a standalone browser page, /console/{session}, that needs no admin token, so it can be handed to the machine owner’s own customer. Opening the page does not use up the session; the page asks for password itself, and the session is spent when the console actually connects. An unknown, expired or already used session answers 404.

Before:

{"console_url": "https://panel.example.com/api/v1/instance/9fc05d99-.../novnc?session=b7df0a87-..."}

After:

{"console_url": "https://panel.example.com/console/b7df0a87-..."}

url (https://panel.example.com/vnc/?session=...) is the WebSocket endpoint for a noVNC/RFB client; connect to it as wss:// (or ws:// on a plain-http panel). A plain HTTP request to it now answers 400 “this endpoint speaks WebSocket only” and leaves the session unused. Before, the same request answered 400 and spent the session, so the real client then got 403 “session unavailable”.

The admin-token GET /api/v1/instance/{instanceId}/novnc keeps working as before.

Console sessions refuse when the panel does not know its own address (2026-09-26)

Section titled “Console sessions refuse when the panel does not know its own address (2026-09-26)”

A KVM console only works when every hypervisor lets the panel itself reach the machine’s screen port. The hypervisor learns the panel’s address from the panel’s own public hostname (Admin > System > Settings > System Domain, falling back to APP_URL) plus Admin > System > Settings > Platform Master IPs (system.master_ips). On an install where those leave only 127.0.0.1 (System Domain empty and APP_URL still http://localhost, or a hostname that /etc/hosts maps to 127.0.0.1 on the panel host), the hypervisor never let the panel through, and every console link timed out with a 502 after about 15 seconds while the session call still answered 200.

Affected endpoints: POST /api/v1/instance/{instanceId}/console-session, GET /api/v1/instance/{instanceId}/novnc (when it mints a session itself), and the user API console endpoints.

Before (200, a link that could never connect):

{"success": true, "data": {"url": "https://panel.example.com/vnc/?session=...", "console_url": "...", "password": "...", "expires_at": "..."}}

After, while the panel’s own address is unknown (HTTP 503, nothing minted, no password):

{"success": false, "reason": "master_address_unknown", "message": "The master does not know its own address, so a console could never connect. Set Admin > System > Settings > System Domain to a hostname that resolves to a real, reachable address. If that hostname resolves to 127.0.0.1 on the master itself (a common /etc/hosts entry), set the Platform Master IPs field (system.master_ips) to the real address instead, then try again."}

Branch on reason. Private (RFC 1918 / ULA) addresses count as known. The admin panel also shows a warning banner until the address is set. Nothing changes on an install whose address is already known.

Keys-only machines and SSH keys (2026-09-26)

Section titled “Keys-only machines and SSH keys (2026-09-26)”

Keys-only deploys and reinstalls keep password login off

Section titled “Keys-only deploys and reinstalls keep password login off”

A deploy or reinstall that carries SSH keys and no password (POST /api/v1/instance/{instanceId}/deploy, POST /api/instance/{id}/reinstall, POST /api/instance/{id}/deploy, the panel, billing modules) does not generate a root password, and the answer carries no password.

Keys only:

200 {"success": true, "message": "The instance is being deployed!", "task_id": "..."}
cloud-init: ssh_pwauth: false, users: [{name: root, lock_passwd: true, ssh_authorized_keys: [...]}]

A deploy with your own password uses that password (a reinstall used to replace it with a generated one), and a deploy with neither keys nor a password still gets a generated password in the answer, as before. Your own ssh_pwauth, disable_root and root lock_passwd in the cloudcfg you send always win over the platform’s values, also when a password is set (this part needs the matching hypervisor agent update).

A reinstall builds a fresh cloud-init from the reinstall request: the platform’s keys (hostname, timezone, the root user with the rule above) plus the cloudcfg you send on that call. It does not reuse the cloud-init of the original deploy.

ssh_key_id works, and keys must belong to the machine’s owner

Section titled “ssh_key_id works, and keys must belong to the machine’s owner”

ssh_key_id (a single key id) on the User API reinstall and deploy is now an alias of ssh_keys and installs the key. Before, it was accepted and silently ignored. Every key id in either field must belong to the owner of the machine; any other id answers 422:

422 {"message": "One or more SSH keys were not found for this user.", "errors": {"ssh_keys": ["One or more SSH keys were not found for this user."]}}

Admins can still install any key through the admin API and panel.

Admin deploy and reinstall always return data.task_id

Section titled “Admin deploy and reinstall always return data.task_id”

POST /api/v1/instance/{instanceId}/deploy and POST /api/v1/instance/{instanceId}/reinstall now always answer with data as an object. The admin envelope folds a single leftover key into data as a bare value, so a keys-only deploy (no password to return) answered with data holding only the task id as a string, and the name task_id was lost. The admin reinstall always answered that way.

Before (keys-only deploy, and every admin reinstall):

200 {"success": true, "message": "The instance is being deployed!", "data": "123e4567-e89b-12d3-a456-426614174000"}

After:

200 {"success": true, "message": "The instance is being deployed!", "data": {"task_id": "123e4567-e89b-12d3-a456-426614174000"}}
200 {"success": true, "message": "The instance is being deployed!", "data": {"task_id": "123e4567-e89b-12d3-a456-426614174000", "password": "aB3xK9mZ"}}

The admin reinstall now also validates and documents cloudcfg, timezone and password (it already applied cloudcfg), and returns a generated password the same way deploy does. If your client reads the admin reinstall answer’s data as a string, read data.task_id instead.

cloudcfg length limit is published, and the User API enforces it

Section titled “cloudcfg length limit is published, and the User API enforces it”

cloudcfg on POST /api/v1/instance/{instanceId}/deploy, POST /api/v1/instance/{instanceId}/reinstall, POST /api/instance/{id}/deploy and POST /api/instance/{id}/reinstall now carries maxLength: 65536 in the published schema. The User API deploy and reinstall accepted a cloudcfg of any size before; they now answer 422 on cloudcfg above 65536 characters, the same as the admin API. cloud_init on POST /api/scaling-groups and PATCH /api/scaling-group/{id} (the first-boot document applied to every instance an autoscaling group launches) carries the same limit and maxLength; it accepted any size before.

User scripts must belong to the machine’s owner

Section titled “User scripts must belong to the machine’s owner”

Every user_scripts id sent on a User API deploy or reinstall (and through the panel and the AI assistant) must belong to the owner of the machine; any other id answers 422 naming user_scripts. Admins can still use any script through the admin API and panel.

User API instance responses documented as they are

Section titled “User API instance responses documented as they are”

GET /api/instance/{id} is documented as the instance object itself (it was drawn wrapped in data, which the answer never was), and PATCH /api/instance/{id} now draws its 200 ({success, message, instance}) and 422 answers.

Five more gaps were found in the changes above. All are fixed and deployed on staging.

Kubernetes routes accept the kubernetes token scope

Section titled “Kubernetes routes accept the kubernetes token scope”

Every user API Kubernetes route lives under /api/kubernetes/ (/api/kubernetes/clusters, /api/kubernetes/search/regions, /api/kubernetes/search/vpcs, /api/kubernetes/search/lb-plans and the rest). The scope check did not map that prefix to the kubernetes family, so a token scoped to kubernetes:read and kubernetes:write was refused on all of them and Kubernetes still needed a full-account token.

Before, with a token scoped to kubernetes:read:

GET /api/kubernetes/clusters
403 {"success": false, "message": "Token scope does not permit this action."}

After:

GET /api/kubernetes/clusters
200 {"success": true, "clusters": {"current_page": 1, "data": [], "...": "..."}}

The same token is still refused outside its family, for example GET /api/instances answers 403.

Impersonation tokens minted before 3.2.3.3 are marked

Section titled “Impersonation tokens minted before 3.2.3.3 are marked”

Impersonation tokens created before the round above (named admin-impersonation-...) were stored with kind: "user", so GET /api/api-tokens listed them with the account’s own tokens. A one-time migration marks them kind: "impersonation": they are now hidden from the list unless you pass ?include=impersonation, like every newer impersonation token. They were already expired; nothing about their validity changes.

notes was an accepted field on PATCH /api/v1/instance/{id} and PATCH /api/instance/{id}, but the instance table had no column for it, so any request that sent it failed.

Before:

PATCH /api/v1/instance/{id} {"notes": "rack 4"}
500 {"message": "Server Error"}

After: 200, and notes is stored and returned on the instance (string, nullable).

Instance notes can be read back on the user API

Section titled “Instance notes can be read back on the user API”

GET /api/instance/{id} and GET /api/instances on the user API now include notes (string or null). Before, the user API stored the value on PATCH /api/instance/{id} but never returned it, so it could be written and not read. The admin API already returned it. PATCH /api/v1/instance/{instanceId} now has its 200 and 422 responses drawn in the reference: {success, message, data} where data is the updated instance.

The example values throughout the admin reference were also replaced with placeholder data (example.com addresses, documentation IP ranges); the shapes are unchanged.

POST /api/instance/{id}/iso/{slot}/mount with {"iso_id": ...} of the account’s own private ISO failed with a 500 (the request validated against a table that does not exist). It now answers 200 with a task, the same as the admin API. POST /api/instance/{id}/iso/{slot}/unmount on a running instance now clears iso_id and iso_url as soon as the eject is dispatched; before, the instance kept pointing at the old ISO (for example the rescue ISO) after the task was done.

Section titled “Load balancer type filter is validated, and the Kubernetes link fields are documented”

GET /api/v1/load-balancers and GET /api/load-balancers accept ?type=k8s (load balancers created by a Kubernetes cluster) or ?type=platform (everything else). Any other value used to be ignored and answered 200 with the unfiltered list; it now answers 422:

GET /api/v1/load-balancers?type=bogus
422 {"success": false, "message": "The selected type is invalid.", "errors": {"type": ["The selected type is invalid."]}}

The reference now documents kubernetes_cluster_id and service_uid on load balancers and cp_load_balancer_id on clusters. A cluster’s API load balancer is the one its cp_load_balancer_id names; the other load balancers carrying the same kubernetes_cluster_id belong to its Services.

Three more validation rules no longer answer 500

Section titled “Three more validation rules no longer answer 500”

Three admin API request rules checked the wrong database table, so any request carrying the field failed with a 500 before it was validated. They now answer 200 for a real id and 422 for an unknown one:

  • POST /api/v1/hypervisor/groups and PATCH /api/v1/hypervisor/group/{id}: plan_groups[]
  • POST /api/v1/volume/{id}/backup: destination_id

A test now checks every exists and unique rule in the application against the real schema, so this class of error cannot ship again unnoticed.

Additional shape and behaviour gaps were found after 3.2.3.3 shipped. This section covers them.

  • Disabled regions now close new resources. A hypervisor group whose Enabled switch is off (Admin > Hypervisor Groups, red “Disabled” badge) is now closed to new Kubernetes clusters, VPCs, load balancers, volumes, managed databases, autoscaling groups and self-provisioned instances, and no longer appears in any of those location pickers. Before this round, only the Cloud Service and MicroVM catalogs honoured the switch, so a “disabled” region still silently accepted new resources everywhere else. Admins can still place a resource in a disabled region directly (recovery, staged rollout, testing), and the external billing surface (WHMCS, Blesta, HostBill, Paymenter orders, and the instance:create console command) bypasses the gate too, since an operator configured that module and chose the region. Because enabled defaults to off and previously had no effect on most create paths, a one-time upgrade migration switches it on for any region that is already hosting live instances on a platform with billing disabled, so an existing self-service region does not get closed by this upgrade. A region an admin deliberately left disabled, or one with no live instances, is left alone.
  • max_certificates = 0 now means unlimited. It used to block every certificate upload instead, the only account limit that worked backwards from every other one.
  • Two more account limits are enforced: max_serverless_apps (MicroVMs with an HTTP ingress URL, default 5) and max_kubernetes_nodes_per_cluster (default 50, checked by the cluster autoscaler too, which records the reason it stopped scaling). An account already above a lowered default keeps what it has but cannot add more until an admin raises the limit; 0 means unlimited. max_kubernetes_snapshots_per_cluster is stored and returned by the API but not enforced yet, since cluster snapshots have no create path in this release.
  • Bandwidth allowances are now metered and enforced for managed database and load balancer plans. Usage is measured from the backing machine every billing cycle and counted per calendar month. Once a month’s usage passes the plan’s allowance, its overage policy applies: suspend the network, suspend the resource, or charge the traffic above the allowance at the plan’s per-GB rate. An allowance of 0 is unlimited.
  • Scoped API token route-family resolution is corrected. The 26 scope families documented above are unchanged, but some routes were resolving into the wrong family because the matching logic checked route names by substring before checking the actual URL: POST /api/microvm/vm/{id}/rollback matched load_balancers instead of microvm (its name contains “rollback”). It now resolves by its real URL path, so a microvm:write token can call rollback. An earlier version of this entry also listed the Kubernetes wizard search routes as fixed here; they were not, see the Kubernetes scope entry in the next section.

Backup policy detach of an unattached machine

Section titled “Backup policy detach of an unattached machine”

POST /api/v1/backup-policy/{id}/detach for an instance that is not currently attached to that policy used to answer 422 with a raw, unhelpful model error. It now answers 409, since the instance id itself is valid, it just is not attached to this policy:

Before:

422 {"success": false, "message": "No query results for model [App\\Models\\Instance]."}

After:

409 {"success": false, "reason": "not_attached", "message": "This machine is not attached to this policy."}

The 403 a non-admin gets when Cloud Service billing is switched off platform-wide (GET /api/load-balancers, /api/microvm/catalog, /api/microvm/vms, and every other billing-gated user API route) now carries a machine-readable reason and a message that names the actual switch and the escape hatch, instead of a generic sentence:

Before:

403 {"success": false, "message": "This feature is unavailable because billing is disabled."}

After:

403 {
"success": false,
"reason": "billing_disabled",
"message": "Cloud Service billing is switched off on this platform (Admin > Billing > Settings > Billing enabled). Accounts set to Unbilled provisioning can still use this feature; ask your provider to set it on your account."
}

An account whose provisioning is set to Unbilled still bypasses this check entirely, on every billing-gated route, same as before.

Kubernetes cluster create into a disabled region

Section titled “Kubernetes cluster create into a disabled region”

POST /api/kubernetes/clusters (user API) refuses a disabled region’s location_id with a validation error naming the field, as part of the disabled-regions behaviour change above:

422 {
"success": false,
"reason": "region_disabled",
"message": "This region is disabled and is not accepting new Kubernetes clusters.",
"errors": {"location_id": ["This region is disabled and is not accepting new Kubernetes clusters."]}
}

Instance change-plan names the exact address shortfall, deterministically

Section titled “Instance change-plan names the exact address shortfall, deterministically”

POST /api/v1/instance/{id}/change-plan used to reserve part of the new plan’s IP addresses before checking whether the hypervisor actually had enough of every family, so a repeated identical request could fail with a different reason each time as reserved rows leaked and were slowly reclaimed by the background cleanup:

Before (two identical requests, two different answers):

422 {"success": false, "message": "No IPv4 available"}
422 {"success": false, "message": "IPv6 is not available"}

After (the same request always answers the same way, and nothing is reserved on a refusal):

422 {
"success": false,
"reason": "plan_ipv4_unavailable",
"message": "Plan S-8 needs 1 more IPv4 address; hypervisor hv-01 has none free."
}

reason is one of plan_ipv4_unavailable, plan_ipv6_unavailable or plan_ipv6_subnet_unavailable. Change-plan works on a running or a stopped instance either way: a disk resize is dispatched immediately, while a CPU/RAM/topology change is written immediately but only takes effect on the hypervisor after the instance’s next restart.

Image refresh refuses on a catalog mismatch

Section titled “Image refresh refuses on a catalog mismatch”

POST /api/v1/image/{id}/update/release (refresh an image’s metadata from the upstream catalog) used to always apply whatever catalog row it found for the image’s catalog id, even when that row was a different distro or major version, silently rewriting the image’s url, filename and version to the wrong release. It now refuses instead:

Before:

200 {"success": true, "message": "Image updated successfully."}

(the image is silently rewritten with a different distro’s data)

After:

400 {
"success": false,
"reason": "catalog_mismatch",
"message": "Catalog entry (rocky 9) does not match this image (rocky 8); refusing to overwrite it."
}

GET /api/docs/openapi.yaml (admin) and GET /api/docs/user/openapi.yaml (user) now serve the generated spec rewritten at request time: info.version is this install’s own platform version, and servers[].url is this install’s own public address, instead of a static file frozen at whatever version and host last ran scribe:generate. The “View OpenAPI spec” link on both /api/docs and /api/docs/user pages now points at an absolute URL instead of a relative one that depended on an injected <base href> some environments did not honour.

Admin API: account limit fields, instance rename alias, forges

Section titled “Admin API: account limit fields, instance rename alias, forges”

PUT /api/v1/user/{id} now accepts max_certificates, max_dns_zones, max_serverless_apps, max_kubernetes_clusters, max_kubernetes_nodes_per_cluster and max_kubernetes_snapshots_per_cluster (null keeps the account’s current value, 0 means unlimited, a positive integer is a hard cap). These fields were already readable from GET /api/v1/user/{id} but had no way to be set through the API before this round.

PUT /api/v1/instance/{id}/modify/name now also accepts name as an alias for display_name when display_name is absent from the body. GET on an instance now returns forges identically on the admin and user API (newest session first, capped at 10); before this round the admin API did not return forges at all, while the user API returned it unfiltered and unordered.

GET /api/v1/instance/{id}/metrics’s agent.reason now also documents the value not_running, alongside the existing agent_not_reporting and null.

User API: ISO unmount alias, certificate fields documented

Section titled “User API: ISO unmount alias, certificate fields documented”

POST /api/instance/{id}/iso/{slot}/unmount (and mount) now work: the user API never normalised these documented aliases to the underlying insert/eject actions, so a real unmount request used to answer 200 {"success": false, "message": "Invalid action unmount specified."}. The admin API twin already normalised them correctly.

Certificate type (manual or letsencrypt) and status (active, expiring, expired, pending or failed, expiring meaning within 30 days of expiry) are now documented on the account certificate endpoints.

Platform: webhook delivery latency, instance status, ISO cleanup, MicroVM domains

Section titled “Platform: webhook delivery latency, instance status, ISO cleanup, MicroVM domains”

Webhook deliveries for instance events now go out within seconds of the underlying event, instead of waiting for the once-a-minute poller; the poller stays wired unchanged as a backstop for anything a delivery job never ran for. See the webhooks page for the updated timing.

A stale hypervisor status snapshot (metricsd samples virsh every 60 seconds) can no longer overwrite a status the platform already confirmed more recently through a direct callback, such as a power action or a deploy completing. Before this fix, a user could see an instance they had just stopped briefly reported as running again.

Deleting an ISO now releases it from every instance that referenced it (primary, secondary, and the rescue-mode previous ISO), instead of leaving those instances pointing at a row that no longer exists.

MicroVM node sandbox and apps domains can now be set per node in the admin panel; the sandbox wildcard DNS record and its certificate are created automatically the first time a node’s sandbox domain is set.

Image publisher_version is now only filled in from a catalog row that actually matches the image’s distro and major version; an ambiguous catalog id can no longer overwrite an image with another release’s version metadata (the same rule the image-refresh fix above enforces on the manual refresh endpoint).

Errors are enveloped too, and reason is a first-class key

Section titled “Errors are enveloped too, and reason is a first-class key”

Every admin API (/api/v1/*) answer now carries the {success, message?, data?, meta?, errors?, reason?} envelope and the X-Api-Envelope: v1 header, including the responses the framework used to render on its own: an unknown resource (404), a wrong method (405), an unauthenticated or forbidden call (401/403), validation (422), throttling (429) and a server error (500). GET /api/v1/instance/{id}/backups used to answer the bare paginator; it is now {"success": true, "data": [...], "meta": {...}} like every other list.

A failure that carries a machine-readable reason keeps it at the top level:

{"success": false, "reason": "agent_unavailable", "message": "Metrics unavailable: the guest agent is not reporting for this instance."}

Before this change the same answer arrived as {"success": false, "message": "...", "data": "agent_unavailable"}.

Instance metrics without the guest agent, and a series endpoint

Section titled “Instance metrics without the guest agent, and a series endpoint”

GET /api/v1/instance/{id}/metrics answered 503 agent_unavailable for every instance because of a parsing defect on the hypervisor agent; that is fixed in the matching agent release. The answer now always carries the hypervisor-side figures for a running instance (status, uptime, cpu, ram, network) and reports the guest agent separately:

{"name": "h1070", "status": "running", "cpu_usage": 1.2, "ram_usage": 13.6, "agent": {"available": true, "reason": null}}

agent.reason is not_running, agent_not_reporting or null. A new endpoint, GET /api/v1/instance/{id}/metrics/series?timeRange=30m|1h|12h|1d|1w, returns the recent cpu, memory, network and disk time series (the same data the user API serves at GET /api/instance/{id}/metrics).

resetPassword refuses early when the guest agent is down

Section titled “resetPassword refuses early when the guest agent is down”

POST /api/v1/instance/{id}/resetPassword and POST /api/instance/{id}/reset-password check the instance’s last known guest agent state before creating a task or generating a password. When the agent is reported down the call answers 409 {"success": false, "reason": "agent_unavailable", "message": "..."}; when the hypervisor itself did not answer, 503 with reason: "hypervisor_unreachable". A successful call keeps its shape: 200 {"success": true, "message": "...", "data": {"password": "...", "task_id": "..."}}.

Admin backup policies for customer instances

Section titled “Admin backup policies for customer instances”

POST /api/v1/backup-policies accepts an optional user_id (an account owner) and then creates a policy scoped to that account, with the schedule entered in that account’s timezone. POST /api/v1/backup-policy/{id}/attach and .../detach accept a provider (system-scoped) policy for any customer’s instance, the same way a hypervisor-group default policy already applies; a customer-scoped policy still attaches only to that account’s instances, and the 422 names both accounts. PATCH /api/v1/backup-policy/{id} works on customer-scoped policies as well.

GET /api/v1/system/settings always includes settings.system.vnc_allowed_sources (an empty string when never set). POST /api/v1/system/settings validates every entry as an IPv4/IPv6 address or CIDR and answers 422 naming a bad entry. An address on the list can reach a guest VNC port only while that instance has enable_vnc on; the list reaches the hypervisors on their next resync.

Section titled “Console session links use the panel’s public address”

POST /api/v1/instance/{id}/console-session builds url and console_url from the panel domain configured under Admin, System, Settings (“System Domain”), then a real APP_URL, then the address the request arrived on. An installer-default APP_URL of http://localhost is not used in the links. The same setting is what the hypervisors’ VNC firewall chain uses to allow the panel itself, so leave it set to the panel’s public hostname.

iso_id, iso_url and their secondary-device equivalents read null after eject and after unmount on every path; some paths used to return an empty string.

The installed API reference carries its version

Section titled “The installed API reference carries its version”

/api/docs and /api/docs/user on an installation show the installed version and link that install’s openapi.yaml, whose info.version now equals the platform version. The release build regenerates and verifies both references before packaging.

PUT /api/v1/instance/{id}/modify/name and PUT /api/v1/instance/{id}/modify/hostname now validate their bodies properly (a missing display_name or hostname answers 422 with a field-level error instead of silently doing nothing), and a new combined endpoint accepts a partial update:

PATCH /api/v1/instance/{id}
{"hostname": "web-1"}

PATCH only accepts display_name, hostname, notes, boot and enable_smtp. The instance’s platform-assigned name (for example h1039) is never editable; sending it answers 422 naming the allowed fields.

A new endpoint, GET /api/webhook-event-kinds, publishes every event kind a webhook subscription can subscribe to, as {"success": true, "data": [{"kind": "...", "resource": "...", "description": "..."}]}. Subscribing to an unrecognised kind now answers 422 naming the offending value and pointing at this endpoint, instead of accepting it silently.

Instance lifecycle events are now emitted to subscribed webhooks: instance.created, instance.deployed, instance.status_changed, instance.suspended, instance.unsuspended, instance.reinstalled and instance.deleted, alongside the existing Kubernetes cluster event kinds. See the webhooks page for the full delivery body and signature contract. Known limitation: a redeploy more than seven days after the instance’s last deploy event is reported as instance.deployed rather than instance.reinstalled, since the platform does not currently track a separate “first deploy” timestamp.

GET /instance/{id}/forge (user API) and its admin API twin now report Forge (live-snapshot) session state instead of leaving a caller to guess from a generic failure:

{"active": false, "session": {"status": "discarded", "disks": [...], "error": null}}

POST .../forge/enable no longer requires disk_ids in the body; when omitted, it defaults to the instance’s primary attached disk. Calling commit, discard or enable while the session is in the wrong state now answers 409 with a machine-readable reason (not_running, suspended, already_active, task_running, no_active_session, session_creating or session_failed) instead of 200 with "success": false, and a session that fails on the hypervisor side now carries its error text on the session object instead of disappearing silently.

Image publisher version, build date and label

Section titled “Image publisher version, build date and label”

Every image payload (list, show, and the browser grouping) now includes three additional fields:

{
"name": "Ubuntu 22.04 LTS",
"version": "22.04",
"publisher_version": "22.04.5",
"build_date": "2026-07-22",
"label": "Ubuntu 22.04.5 (2026-07-22)"
}

version is unchanged and remains the legacy catalog field, which may be a major version only and has historically contained typos (for example “24.04.01” instead of “24.04.1”). publisher_version and build_date are null when the upstream catalog has not published them yet for that image; label is meant for display.

GET /api/v1/image/{id} used to answer 404 for every image, because the controller tried to load a relation that does not exist on the model (hypervisors is a JSON column of hypervisor IDs, not an Eloquent relation). It now answers 200 with the image and its resolved hypervisor list:

{"success": true, "data": {"id": "9a89c953-...", "name": "Ubuntu 22.04 LTS", "...": "...", "hypervisors": [{"id": "9a86ef4b-...", "name": "HYD-01"}]}}

Several list endpoints (starting with GET /api/v1/tasks) previously ignored a caller-supplied per_page query parameter and always returned a fixed page size. per_page is now read consistently (1 to 100, same default as before) on every paginated admin and user API list. The image browser endpoint’s images key also changes from a nested paginator object to a plain list, matching db_images on the same response.

A new endpoint on both surfaces reports the running platform version, so an integration can gate a feature (for example, rescue mode requires hypervisor agents at or above a minimum version) instead of guessing:

  • Admin API: GET /api/v1/system/version
  • User API: GET /version
{"success": true, "data": {"version": "3.2.3.3", "api_envelope": "v1"}}

The user API (/api) now has a published reference alongside the existing admin API reference, at /api/user/. Previously only the admin API’s OpenAPI spec was generated and published; the user API’s Scribe-generated documentation existed only inside the repository and was never served.

Personal API token scopes cover every user API resource

Section titled “Personal API token scopes cover every user API resource”

POST /api/api-tokens and PATCH /api/api-token/{id} previously accepted only 13 scope names (instances, vpcs, load_balancers, databases, s3_buckets, ssh_keys, plus account:write); most of the user API had no scope at all, so a scoped token was silently refused on entire resource areas - kubernetes, images, certificates, microvm, webhooks, metrics and more - while an unscoped token kept working. The scope vocabulary now covers every resource family the user API exposes; see the API tokens page for the full, current table. account:read is new alongside the existing account:write (profile, dashboard, team and API token management now need one or the other depending on the HTTP method). Two endpoints are exempt from scope enforcement entirely and reachable by any valid token: GET /connect (bearer sanity check) and GET /version.

Before (creating a token scoped to metrics):

{"scopes": ["metrics:read"]}
422 {"message": "The given data was invalid.", "errors": {"scopes.0": ["The selected scopes.0 is invalid."]}}

After: 200, and a token minted with metrics:read can call GET /api/metrics and its sub-routes but nothing else.

Impersonation tokens are scoped at mint time, kept apart, and self-revocable

Section titled “Impersonation tokens are scoped at mint time, kept apart, and self-revocable”

POST /api/v1/user/{id}/impersonate (admin API) previously always minted a full-account-access token with no IP restriction and a fixed 60-minute TTL, regardless of what the admin’s tooling needed. It now accepts the same narrowing a customer can apply to their own tokens:

{"scopes": ["instances:read"], "allowed_ips": ["203.0.113.4"], "expires_in": 900}

All three fields are optional; omitting them preserves the previous behaviour (full account access, any IP, the configured default TTL). expires_in is in seconds, 60 to 3600.

Impersonation sessions are now a distinct token kind. GET /api/api-tokens excludes them from a customer’s own token list by default (?include=impersonation shows them). POST /api/profile/impersonation/stop, called with the impersonation token itself as the bearer, now revokes that token and answers 200 instead of 422 {"message": "No active impersonation session."} - the endpoint answers 422 only when the calling token is not an impersonation session at all. last_used_at is now stamped on every token (of any kind) on every authenticated request; it previously stayed null forever.

PATCH /api/api-token/{id} honours expires_at

Section titled “PATCH /api/api-token/{id} honours expires_at”

expires_at in the request body was accepted and validated but never applied - the response answered 200 and the token’s expiry was unchanged. It is now applied, but only to shorten a token’s lifetime: the new value must be in the future and not later than the token’s current expiry (a token that never expires accepts any future date). A later date, a past date, or clearing the expiry of a token that already expires answers 422 naming expires_at, instead of a silent no-op.

The changes below shipped in 3.3.0, the release that adds Proxmox Backup Server support for instance backups.

VPN gateway peer fields reject control characters

Section titled “VPN gateway peer fields reject control characters”

Adding or updating a WireGuard peer rejects a name, endpoint, preshared key, public key, DNS value, or allowed IP range that contains a control character (for example an embedded line break). A field with no format check of its own (for example name or endpoint) is rejected with a 422 response and reason: invalid_control_characters. On the user API, an allowed IP range is already validated as an IP or CIDR by its own field rule, so it is rejected first by the standard 422 validation response with an errors map, before the control-character check ever runs. Existing peers are unaffected; only new adds and updates are validated.

VPN gateway tunnel subnet and VPC CIDR must be a valid IPv4 CIDR

Section titled “VPN gateway tunnel subnet and VPC CIDR must be a valid IPv4 CIDR”

Creating a VPN gateway now requires tunnel_subnet to be a strict IPv4 CIDR (for example 10.99.0.0/24); creating a VPC now applies the same strict check to cidr. An invalid value is rejected with a 422 validation response, the same shape as any other rejected field on these endpoints - {"errors": {"tunnel_subnet": [...]}} on the user API and both panels, wrapped as {"success": false, "message": ..., "errors": {...}} on the admin API under its response envelope (see “Failure responses now carry a real HTTP status code” above).

On the user API and both panels:

{"errors": {"tunnel_subnet": ["The tunnel subnet must be a valid IPv4 CIDR (e.g. 10.0.0.0/24)."]}}

On the admin API:

{"success": false, "message": "The tunnel subnet must be a valid IPv4 CIDR (e.g. 10.0.0.0/24).", "errors": {"tunnel_subnet": ["The tunnel subnet must be a valid IPv4 CIDR (e.g. 10.0.0.0/24)."]}}

Creating a VPC with an invalid cidr answers the equivalent shape, keyed on cidr instead of tunnel_subnet.

VPN gateway creation now returns 422, not 400, for a rejected request body

Section titled “VPN gateway creation now returns 422, not 400, for a rejected request body”

POST /api/v1/vpc/{vpcId}/vpn-gateway (admin API) and the equivalent admin panel action used to run body validation inside the same try block as the create itself; a rejected field surfaced as a plain success: false body, with no errors object to say which field was wrong. On the admin API this answered 400 (the same envelope middleware that maps every success: false body to 400 applied here too); the admin panel has no such middleware, so the identical failure answered a plain 200 there instead (see the panel’s own before/after below). Validation now runs before that block on both surfaces, so a rejected field answers the standard 422 validation response instead.

Before, on the admin API:

{"success": false, "message": "The name field is required."}

After, on the admin API (wrapped under the same response envelope as the section above):

{"success": false, "message": "The name field is required.", "errors": {"name": ["The name field is required."]}}

The equivalent admin panel action moves from a plain HTTP 200 with {"success": false, "message": ...} to a plain HTTP 422 with {"message": ..., "errors": {...}} (the admin panel is not enveloped, unlike the admin API).

The 400 status on this endpoint is unchanged for every other kind of failure (for example the gateway’s own deploy failing after validation passes) - only a rejected request body moved from 400 to 422.

VPC location catalog only lists locations with VPC enabled

Section titled “VPC location catalog only lists locations with VPC enabled”

GET /api/vpc/locations used to list every location the account can see, including one with the VPC feature switched off, and reported it as is_available: true. Creating a VPC there (POST /api/vpcs) always refused with “VPC is not available at the selected location”, so the catalog disagreed with the endpoint it was meant to feed. A location with VPC disabled is no longer listed at all, matching how the load balancer and volume location catalogs already handle their own feature switches.

Before (a location with VPC disabled, still listed):

{"success": true, "locations": [{"id": "9a89c953-...", "name": "us-east", "display_name": "US East", "country": "US", "is_available": true, "access_status": "available"}]}

After (that location is omitted; only locations with VPC enabled remain):

{"success": true, "locations": []}

Nothing else about the response shape changed. The list also leaves out a location the operator has switched off entirely, the same as the load balancer and volume catalogs.

Instance backups on a Proxmox Backup Server destination now report read mode and verification state

Section titled “Instance backups on a Proxmox Backup Server destination now report read mode and verification state”

A backup stored on a Proxmox Backup Server destination now carries a few extra fields on the existing instance-backup response: read_mode (full or incremental), bytes_read, bytes_uploaded, chunks_reused, and verification_status. A backup on any other destination type keeps these fields null, unchanged from before.

We deliberately do not include the destination’s namespace path (pbs_namespace) in this response, on the user API or in the panel. That field identifies where on the backup server the snapshot lives, not something a customer needs to restore or delete their own backup.

On the admin API and admin panel only, a Proxmox Backup Server backup also carries pbs_namespace (the destination path the snapshot lives under) and two internal reconcile timestamps, missing_candidate_at and missing_confirmed_at; none of these reach the user API or the customer panel. chain_reason (why the next backup has to take a full read, when that applies) and consistency are on every surface. missing_on_pbs is also present everywhere, but a snapshot confirmed missing from the backup server (see Proxmox Backup Server destinations) is never listed on the user API or the customer panel at all, so in practice it only ever reads true on admin surfaces.

Before (a Proxmox Backup Server backup, before this change):

{"id": "9a89c953-...", "name": "Backup - 2026-09-26 11:39", "backup_type": "incremental", "size": 5368709120, "created": 1, "created_at": "2026-09-26T11:39:00.000000Z"}

After:

{"id": "9a89c953-...", "name": "Backup - 2026-09-26 11:39", "backup_type": "incremental", "size": 5368709120, "read_mode": "incremental", "bytes_read": 104857600, "bytes_uploaded": 20971520, "chunks_reused": 420, "verification_status": "verified", "created": 1, "created_at": "2026-09-26T11:39:00.000000Z"}

Nothing else about the response shape changed.

New admin endpoint: restore a backup into a new instance

Section titled “New admin endpoint: restore a backup into a new instance”

POST /api/v1/instance/{sourceInstanceId}/backup/{backupId}/restore-to-new creates a brand-new instance from a backup stored on a Proxmox Backup Server destination and restores every one of its disks into it, including a backup whose source instance has since been deleted. The target hypervisor must use the same backup destination the backup was taken against; a different one answers 409 with reason: "destination_mismatch". A hypervisor already at its backup/restore concurrency limit answers 409 with reason: "hypervisor_backup_busy". A backup that is not on a Proxmox Backup Server destination, has not finished, has no restorable disk archives, or has been confirmed missing from the PBS server answers 422 with a specific reason (not_pbs_backup, backup_not_complete, backup_not_addressable, backup_missing); a hypervisor that cannot be reached answers 503 with reason: "hypervisor_unreachable".

The new instance is created and its disks are restored in the background; the response carries a task_id to poll, the new instance’s own instance_id, and a billing_mode (cloud_service, self_provisioned or external) describing how, if at all, the new instance is billed.

{"success": true, "message": "Backup restore queued successfully.", "data": {"task_id": "9a89c953-...", "instance_id": "9a89c953-...", "billing_mode": "cloud_service"}}

This is an admin-only action; there is no user API equivalent.

New admin endpoint: reset a Proxmox Backup Server backup chain

Section titled “New admin endpoint: reset a Proxmox Backup Server backup chain”

POST /api/v1/instance/{instanceId}/backup/pbs-chain/reset resets a KVM instance’s Proxmox Backup Server incremental chain so the next backup of the affected disks takes a full read; omit disk_id to reset every disk. A disk that does not belong to the instance answers 422 with reason: "unknown_disk", a Proxmox guest 422 with reason: "not_supported", a hypervisor that refuses the reset 409 with reason: "hypervisor_refused", and a hypervisor that cannot be reached 503 with reason: "hypervisor_unreachable". Admin-only; no user API equivalent.

New admin endpoint: protect or unprotect a backup

Section titled “New admin endpoint: protect or unprotect a backup”

PATCH /api/v1/instance/{instanceId}/backup/{backupId}/protected marks a backup protected or removes that protection again, with a strict JSON boolean body: {"protected": true}. A non-boolean value such as 0, 1, "true", or "false" answers 422 naming protected; only a real JSON boolean is accepted. A protected backup is skipped by the scheduled retention cleanup and refused by the delete action on every destination type - local, Proxmox Backup Server, S3, and rclone alike - until an admin removes the protection again. Sending the value the backup already has is not an error: the endpoint reports the current state instead of a no-op failure. This flag lives only on the platform: on a destination with PBS-managed retention enabled, Proxmox Backup Server’s own prune job runs independently and does not read it, so a protected backup can still be pruned there even while the platform’s own retention sweep and delete action continue to refuse it.

{"success": true, "message": "Backup marked protected. Master retention and delete will skip it until an admin removes the protection.", "data": {"protected": true}}

A backup that does not belong to the given instance answers 404 with reason: "backup_not_found". A request with no bearer token answers 403 with {"success": false, "message": "Unauthorized!"}; a bearer token that does not match any enabled admin token answers 401 with the same body; a valid token belonging to a non-admin user answers 403 with {"success": false, "message": "Unauthorized"}. None of these three carry a reason field, the same as every other endpoint on this surface. This is an admin-only action; there is no user API equivalent.

Deleting a protected backup (DELETE /instance/{instanceId}/backup/{backupId}, every surface) is refused with 409 and reason: "backup_not_deletable" and the message “This backup is protected and cannot be deleted until an admin removes the protection.” Deleting several backups at once (the user API POST /api/instance/{instanceId}/backups/destroy, admin API POST /api/v1/instance-backups/destroy, and equivalent panel actions) no longer skips a protected backup silently: each refused backup is reported in the results with its own status and reason (see the backup deletion entry below). As above, the flag is enforced only by the platform: with PBS-managed retention enabled on the destination, Proxmox Backup Server’s own prune job does not read it and can still remove the underlying snapshot on its own schedule.

Restoring a Proxmox Backup Server backup validates the target disk and, on a multi-disk backup, the archive it will restore

Section titled “Restoring a Proxmox Backup Server backup validates the target disk and, on a multi-disk backup, the archive it will restore”

Restoring a Proxmox Backup Server backup in place (POST /instance/{instanceId}/backup/{backupId}/restore on every surface: admin API, admin panel, user API and user panel) now sizes the target disk against the specific archive it will receive, not the backup’s overall size, and refuses before touching the disk if it does not fit. A target disk that does not exist, has no matching storage backend, or is the wrong kind for this backup answers 422 (target_disk_not_found, target_disk_no_backend, target_disk_incompatible); one too small for the selected archive answers 422 with reason: "target_disk_too_small". On a backup with more than one disk, a disk selection that matches no archive, or an ambiguous default, answers 422 with reason: "source_archive_not_found" or reason: "source_archive_ambiguous". A backup confirmed missing from the PBS server answers 422 with reason: "backup_missing". Restoring or backing up already shares one per-hypervisor concurrency limit; a hypervisor already at that limit answers 409 with reason: "hypervisor_backup_busy" on either action.

GET /api/v1/system/failed-jobs answers a count of failed background jobs, grouped by queue, job and exception class, so an admin (or a monitoring script) can see what is failing and how often without opening a database console. It never returns a raw job payload or an exception message - only the exception’s class name - since either can carry a value a background job was passed, such as a password reset token.

{"success": true, "data": {"total": 2, "groups": [{"queue": "default", "job": "App\\Events\\InstanceBackupPolicyEvent", "exception_class": "Illuminate\\Database\\Eloquent\\ModelNotFoundException", "count": 2, "first_failed_at": "2026-09-26 03:00:00", "last_failed_at": "2026-09-26 04:00:00"}]}}

An optional ?since= query parameter (an ISO 8601 date/time, for example 2026-09-26T04:00:00Z) counts only failures at or after that instant; it is converted to the master’s own timezone before comparing, so a same-day boundary value is never off by the difference between the two. An unparseable value answers 422 naming since. This is an admin-only action; there is no user API equivalent.

Backup storage destinations gain a new storage_type, pbs, on the admin backup-storage endpoints (POST /api/v1/hypervisor/backup-storages, PATCH on the same resource). A pbs destination’s config carries host, port (default 8007), datastore, an optional root namespace, auth_id (user@realm!token), a write-only token_secret (required on create and whenever auth_id changes; omitted or blank on any other update keeps the stored secret), a mandatory TLS fingerprint pin, and optional verify_after_backup, pbs_managed_retention, live_restore and fleecing_storage switches. config remains hidden from every response, as before.

Two new admin-only helpers support setting a destination up:

  • POST /api/v1/hypervisor/backup-storages/fingerprint probes a host with no credentials and returns its certificate fingerprint for manual confirmation. The panel never saves a fetched fingerprint automatically.
  • POST /api/v1/hypervisor/backup-storage/{id}/test runs a full connection test (reachability, authentication, root namespace, token privileges) against a saved destination.

Backups taken on a PBS destination gain identity and telemetry fields (pbs_backup_id, pbs_backup_time, read mode, byte and chunk counters, verification state) on the instance-backup responses; see the read-mode and verification entry above for the exact field list, and the restore-to-new-instance entry above for the new admin restore endpoint. There is no user API change beyond those fields.

Personal API tokens act as the team member who creates them

Section titled “Personal API tokens act as the team member who creates them”

A personal API token created by a team member with the “manage API tokens” permission (POST /api/api-tokens) acts as the member who created it: requests made with it are limited to that member’s own team permissions and stay scoped to the account’s resources. GET /api/api-tokens shows each member only the tokens they created; the account owner’s list works the same way, showing only tokens the owner created themselves.

Tokens a member created before 3.3.0 keep their existing behaviour and are listed in the account owner’s token list.

Deleting a backup answers a real status code with a reason, and a bulk delete reports every row

Section titled “Deleting a backup answers a real status code with a reason, and a bulk delete reports every row”

The single-delete APIs (DELETE /api/v1/instance/{instanceId}/backup/{backupId} and DELETE /api/instance/{instanceId}/backup/{backupId}) and equivalent panel actions now return a machine-readable reason for the resolved-backup service refusals below. These refusals previously answered HTTP 200 with {"success": false, "message": ...}, remapped to HTTP 400 on the admin API. Authentication, validation and route-binding failures retain their own response contracts.

  • A protected backup answers 409 with reason: "backup_not_deletable".
  • A backup already claimed for deletion answers 409 with reason: "backup_being_deleted".
  • A failure that happens before the deletion request is dispatched to the node answers 422 with reason: "delete_not_dispatched"; nothing is left claimed.
  • A dispatch whose outcome could not be confirmed (the request was sent but the answer was lost or unreadable) answers 503 with reason: "delete_dispatch_unconfirmed"; the backup stays claimed, so no concurrent delete or unprotect can race the node.

Before (a protected backup, user API):

{"success": false, "message": "This backup is protected and cannot be deleted until an admin removes the protection."}

After (every surface, HTTP 409):

{"success": false, "reason": "backup_not_deletable", "message": "This backup is protected and cannot be deleted until an admin removes the protection."}

A successful delete is unchanged: it answers 200 and queues the deletion, and a row counts as gone only once its destination confirms the snapshot is removed (deletion has always been asynchronous this way).

Deleting several backups at once (the user API POST /api/instance/{instanceId}/backups/destroy, admin API POST /api/v1/instance-backups/destroy, and equivalent panel actions) used to answer one body for the whole request and silently skipped whatever it refused. It now answers an aggregate: data.queued counts the accepted ids, data.refused the refused ones, and data.results carries one entry per distinct requested id with that row’s own status, success, message and, where it applies, reason. On the per-instance user route, a requested id that is not a backup of this instance answers 404 with reason: "backup_not_found" in its result row. The request as a whole answers 200 only when nothing was refused; otherwise it answers the first refusal’s status, except that a later 503 always wins, because an unconfirmed dispatch matters more than a definite refusal.

{"success": false, "message": "Backup deletes: 1 queued; 1 refused.", "data": {"queued": 1, "refused": 1, "results": [{"id": "123e4567-...", "status": 200, "success": true, "message": "Backup destruction queued successfully."}, {"id": "223e4567-...", "status": 409, "success": false, "reason": "backup_being_deleted"}]}}

Destroying an instance keeps its backups unless you ask otherwise

Section titled “Destroying an instance keeps its backups unless you ask otherwise”

Destroying an instance keeps its backups by default. A kept backup remains billed, stays visible to its owner, and can still be listed and deleted on demand, including after the instance itself is gone (see the account-wide backup endpoints below).

Every surface that destroys an instance accepts an optional delete_backups boolean, default false: the admin panel’s delete action (a delete-backups confirmation in the dialog), the admin API’s DELETE /api/v1/instance/{instanceId} body, the user panel’s delete action, the user API’s hourly-billed and self-provisioning instance deletes, and the billing API’s termination calls. Sending true queues physical backup deletion only after the node’s destroy callback has completed billing and soft-deleted the instance. Backup rows are removed as their destinations confirm deletion; a protected backup is never deleted by the flag and stays. Destroys driven through the OpenTofu provider and the MCP server do not send the field, so those destroys keep backups too.

Platform-initiated destroys still request backup cleanup for autoscaling scale-down and group deletion, Kubernetes node replacement, drain-delete, cluster deletion and orphan cleanup, and the appliance instances behind load balancers, managed databases and VPN gateways. This cleanup deletes the backups physically through the normal backup delete path after the destroy callback completes. Backup cleanup is also enforced when a person destroys a system-managed appliance instance, because its backups have no customer-visible surface to manage them from. Protected backups still remain.

New user API endpoints: list and delete the account’s backups

Section titled “New user API endpoints: list and delete the account’s backups”

Three new user API endpoints address the account’s backups directly, without naming an instance, so a backup of an already destroyed instance can still be found, checked and cleaned up:

  • GET /api/backups lists every backup in the caller’s account, from live and destroyed instances alike, newest first by default, paginated (page, per_page). Each row carries the backup’s own fields (name, size, type, read mode, verification state, protection flag), its instance’s name and hostname, the destination’s name and type, and whether a deletion is already queued or running. Filters: search, instance_id, backup_storage_id, storage_type, backup_type, created, verification, protected, claim, instance_state (live or deleted), created_from/created_to, plus sort (created_at, size, pbs_backup_time or name) and dir.
  • DELETE /api/backup/{id} deletes one of the account’s own backups by id, including a backup of a destroyed instance. An id that is not the caller’s own answers 404 (“Backup not found”); an owned backup answers the same statuses and reasons as the per-instance delete above (409 backup_not_deletable or backup_being_deleted, 422 delete_not_dispatched, 503 delete_dispatch_unconfirmed).
  • POST /api/backups/destroy takes {"backups": ["<id>", ...]} and queues deletion for each id the caller owns, answering the same {queued, refused, results} aggregate as the per-instance bulk delete. An id that is not the caller’s own is dropped silently and never reported, so the response never reveals whether someone else’s backup exists. This differs from the per-instance bulk route above, where a missing or foreign id has a 404 backup_not_found result row.

All three sit in the instances permission family: a scoped API token needs the instances:read scope for the list and the instances:write scope for the two deletes, and a subuser needs the matching team permission. The listing deliberately excludes backups that belong to the platform’s own appliance instances; those are not customer resources.

Instance creation answers real status codes with a reason

Section titled “Instance creation answers real status codes with a reason”

Capacity refusals on POST /api/v1/instances, POST /api/v1/billing/instances, POST /api/cloud-service/instances and POST /api/self-provisioning/instances now answer HTTP 422 with a machine-readable reason. They previously answered HTTP 200, or HTTP 400 on the admin API. Reasons include storage_not_sufficient, ipv4_not_available, ipv6_not_available, ipv6_subnet_not_available and private_ipv4_not_available. The user API continues to mask internal capacity details.

On the admin API, a reasoned failure now keeps reason at the top level instead of folding it into data.

Before (admin API, HTTP 400):

{"success": false, "message": "The hypervisor does not have sufficient storage for this deployment.", "data": "storage_not_sufficient"}

After (HTTP 422):

{"success": false, "message": "The hypervisor does not have sufficient storage for this deployment.", "reason": "storage_not_sufficient"}

The admin panel create flow can also answer HTTP 409 with reason: "hypervisor_locked", "hypervisor_maintenance", "hypervisor_down" or "hypervisor_deploy_disabled". These state refusals are panel-only; the REST create endpoints above do not expose those 409 responses.

The admin panel also keeps a persistent error banner for create refusals. If its deploy dispatch is not confirmed, it answers HTTP 503 with reason: "deploy_dispatch_unconfirmed", task_id and instance, preserving the instance and its resources while the task determines the final outcome. This dispatch response belongs to the admin panel create flow, not the REST create endpoints above.

When a Proxmox node does not issue a console ticket, GET /api/v1/instance/{id}/novnc, POST /api/v1/instance/{id}/console-session and GET /api/instance/{id}/novnc now answer HTTP 503 with reason: "proxmox_console_unavailable", replacing the previous 409 or 500 failure.

Before (HTTP 409):

{"error": "Proxmox console unavailable: ..."}

After (HTTP 503):

{"success": false, "reason": "proxmox_console_unavailable", "message": "The console could not be opened: the Proxmox node did not issue a console ticket. Check that the node is reachable and try again."}

The existing master_address_unknown refusal now directs administrators to Settings > System > General > Platform Master IPs (system.master_ips). Loopback and unspecified addresses do not satisfy the console address check.

S3 access-key rotation requires a replacement secret

Section titled “S3 access-key rotation requires a replacement secret”

PATCH /api/v1/hypervisor/backup-storage/{hypervisorBackupStorageId} now rejects an S3 access-key change without a nonblank replacement config.secret_key. It answers HTTP 422 with an error under errors.config.secret_key: “A new secret key is required when the access key changes.” Previously it could pair the new access key with the old secret. Leaving the access key unchanged still permits an omitted or blank secret to retain the stored secret.

The matching PBS guard rejects a changed config.auth_id without a nonblank replacement config.token_secret, with HTTP 422 and errors.config.token_secret: “A new token secret is required when the token id changes.” Both guards apply to the admin panel and the admin API.

Backup destination updates preserve omitted config fields

Section titled “Backup destination updates preserve omitted config fields”

For S3 and rclone destinations, a partial PATCH /api/v1/hypervisor/backup-storage/{hypervisorBackupStorageId} config now keeps fields the request omits. Previously, changing only config.path_prefix could erase the destination endpoint, bucket or remote name. Explicit values for optional fields still apply, including null to clear an optional value and false for a boolean option. Secret fields retain the rotation rules above.

Changing storage_type requires complete configuration for the new backend, or an explicit nonblank path when switching to local storage. An incomplete switch answers HTTP 422 and leaves the destination unchanged. A successful switch does not inherit credentials from the previous backend.

Admin API instance actions return the task id as data.task_id, not a bare string

Section titled “Admin API instance actions return the task id as data.task_id, not a bare string”

Every remaining admin API (/api/v1) action that dispatches a background task and used to answer with the task id as a bare string under data now nests it as data.task_id, closing the rest of the gap deploy and reinstall already fixed in 3.2.3.3 (see “Admin deploy and reinstall always return data.task_id” above): the response envelope folds a single leftover key into data as a bare value, so the field name was lost. This covers the instance suspend/resume action, the VNC enable/disable action, self-deploy, the per-disk actions (attaching a disk, the other per-disk actions, creating a disk, deleting a disk, and the asynchronous resize), the ISO device actions, retrying a failed managed database deployment, and rebuilding a reverse DNS zone.

Before:

{"success": true, "message": "The action has been queued.", "data": "123e4567-e89b-12d3-a456-426614174000"}

After:

{"success": true, "message": "The action has been queued.", "data": {"task_id": "123e4567-e89b-12d3-a456-426614174000"}}

A client reading data as a string on any of these endpoints should read data.task_id instead. The instance power actions (start, stop, restart) answer only success and message and have no task id to nest; they are unchanged, as is POST /api/v1/instance/{instanceId}/backups (create a backup), which also answers only success and message.

A handful of other admin endpoints keep their field name under data too

Section titled “A handful of other admin endpoints keep their field name under data too”

The same envelope collapse hid the field name on five endpoints that answer a single value that is not a task id. Each used to return its value as a bare string under data; it now returns the value under its own field name:

  • POST /api/v1/hypervisors (creating a Proxmox hypervisor) returns the created hypervisor’s id as data.hypervisor_id.
  • POST /api/v1/hypervisor/storages returns the created storage’s id as data.storage_id.
  • POST /api/v1/hypervisor/backup-storages returns the created backup destination’s id as data.storage_id.
  • POST /api/v1/database/{managedDatabaseId}/reset-password returns the newly generated password as data.password; its failure shape is unchanged.
  • GET /api/v1/instance/{instanceId}/docker/{dockerDeploymentId}/cached-logs returns the container’s cached log output as data.logs.

Before (resetting a managed database password):

{"success": true, "message": "Password reset successfully.", "data": "aB3xK9mZ"}

After:

{"success": true, "message": "Password reset successfully.", "data": {"password": "aB3xK9mZ"}}

Instance create turns on VNC only when a boot ISO is attached

Section titled “Instance create turns on VNC only when a boot ISO is attached”

Creating an instance from the admin panel, the admin API, the billing API, or a Cloud Service (hourly billed) order now enables VNC only when the create attaches a boot ISO. A create with no boot media (deploying from an image, or a bare placeholder) no longer turns VNC on by default. Self-install flows are unchanged: self-deploy and self-provisioning creates still enable VNC automatically, so an interactive installer stays reachable.

Admin backups list: filter for backups whose destroy-time cleanup did not finish

Section titled “Admin backups list: filter for backups whose destroy-time cleanup did not finish”

GET /api/v1/instance-backups (the admin, platform-wide backups list) accepts a new issue query parameter. issue=cleanup_pending restricts the list to backups whose owning instance was destroyed with backup deletion requested, but the cleanup has not finished yet, or gave up. Every row on this endpoint also gains an instance_cleanup_pending boolean, independent of the filter, that reports the same thing per row. Neither the parameter nor the field reaches the user API or the customer panel; a customer’s own backup list has nothing to clean up on their side.

{"current_page": 1, "data": [{"id": "9a89c953-...", "instance_id": "9a89c953-...", "name": "web-01-full", "instance_deleted": true, "instance_cleanup_pending": true}], "per_page": 25, "total": 1}

New admin endpoint: reconcile a backup dispatch

Section titled “New admin endpoint: reconcile a backup dispatch”

POST /api/v1/backup-dispatch/{operationId}/reconcile asks a native KVM hypervisor for the durable outcome of a backup create, restore or delete it was asked to run, so an admin can find out what actually happened when the Master’s own record of a dispatch is unclear (for example after a lost acknowledgement). operationId is the backup queue id for a create, or the task id for a restore or delete. The endpoint first revokes the attempt if the node has not started it yet, then reads back its recorded state.

{"success": true, "data": {"kind": "restore", "operation_id": "123e4567-e89b-12d3-a456-426614174000", "state": "revoked"}}

A state that is not yet final answers 409 with reason: "operation_live" and the current data.state; a node that finished the work but whose success callback the Master never confirmed answers 409 with reason: "remote_done_callback_unconfirmed", since a definite delete may still need snapshot or claim reconciliation before the row can be released. This is admin-only and covers native KVM hypervisors; a Proxmox VE node is reconciled through its own existing task evidence instead. See Instance backups for when and how to use it.

Creating a hypervisor requires hypervisor_group_id and answers 200 with data.hypervisor_id

Section titled “Creating a hypervisor requires hypervisor_group_id and answers 200 with data.hypervisor_id”

POST /api/v1/hypervisors now requires hypervisor_group_id, the id of an existing hypervisor group. A request without it, or with an unknown id, is rejected with 422 before the node is contacted. Earlier releases linked the node first and then failed when the record could not be written, which left the node linked with no hypervisor record (a retry then answered that the node was already linked). The Add hypervisor form in the panel requires a group in the same way, so create a group first. New groups start disabled, so enable the group to allow deployments into it.

A successful create used to answer HTTP 500 even though the node had been linked and the record created. It now answers 200 with the new id under data.

Before:

{"success": false, "message": "Server Error"}

After:

{"success": true, "message": "Slave linked successfully!", "data": {"hypervisor_id": "9fada16d-d3d6-48e3-8e84-00d89c8f5d74"}}

A request with no group:

{"success": false, "message": "The hypervisor group id field is required.", "errors": {"hypervisor_group_id": ["The hypervisor group id field is required."]}}

Creating a hypervisor group saves enabled and the other optional fields

Section titled “Creating a hypervisor group saves enabled and the other optional fields”

POST /api/v1/hypervisor/groups accepted enabled and the other optional fields but ignored them, so a group created with enabled: 1 was stored disabled. The fields are now saved: enabled, public_networking_enabled, vpc_enabled, natgw_enabled, lb_enabled, db_enabled, microvm_enabled, image_enabled, autoscaling_enabled, static_ip_enabled, vpngw_enabled, managed_backups_billed, vpc_network_inbound_average, vpc_network_outbound_average, natgw_credit_value, natgw_bandwidth_rate, natgw_bandwidth_accounting, natgw_bandwidth_overage, ipv4_credit_value, backup_mode and default_backup_policy_id. A field you leave out keeps its default, so a group created without enabled is still disabled.

The panel’s Create group form now defaults Enabled to off, and it validates the same fields with the same rules as the API.

Network rates and bandwidth are shown from the guest’s point of view on KVM

Section titled “Network rates and bandwidth are shown from the guest’s point of view on KVM”

On a KVM node, rx is now what the instance received (its download) and tx is what it sent (its upload), the same view Proxmox nodes use. Earlier releases returned the node’s own view of the interface, so on KVM the two values change places. The change applies to GET /api/instance/{id}/metrics and the admin GET /api/v1/instance/{instanceId}/metrics/series, the bandwidth series, the packets, errors and drops pairs (which swap the same way), the monitoring pages and charts (now labelled Download and Upload), alert evaluation, the Prometheus export (the values swap; metric names and labels are unchanged), the AI assistant tools and the bandwidth command line tool. Proxmox nodes already showed the guest’s view and are unchanged.

Existing alert rules on KVM resources were migrated by swapping network_rx and network_tx, so each rule keeps watching the same physical traffic. Account-wide alert rules over a fleet that mixes KVM and Proxmox resources were left unchanged, so review them. Alert history entries written before the update still name the old metric. Dashboards built on the Prometheus export that compensated for the old view need adjusting.

Before (a KVM instance that downloaded about 5 GB a day and uploaded about 1 GB a day):

{"labels": ["Sep 29", "Sep 30"], "rx": [1.0, 1.1], "tx": [5.0, 5.2], "period": "Sep 29 - Oct 28"}

After:

{"labels": ["Sep 29", "Sep 30"], "rx": [5.0, 5.2], "tx": [1.0, 1.1], "period": "Sep 29 - Oct 28"}

used_bandwidth lives on each interface object (primary_interface and the other interface fields), in GB to two decimals for the current billing period. Instance objects have no top-level used_bandwidth, and the API reference examples now say so.

GET /api/v1/images accepts ?purpose=. The accepted values are general, managed_database, kubernetes_worker, kubernetes_control_plane, vpn_gateway, load_balancer and user. Earlier releases ignored the parameter. Without it the list is unchanged and covers every purpose. An invalid or empty value answers 422.

{"success": false, "message": "The selected purpose is invalid.", "errors": {"purpose": ["The selected purpose is invalid."]}}

The list and show responses now also carry the download diagnostics download_error, download_error_at and download_reset_at (all null when there is no problem).

PUT /api/v1/image/{imageId} used to reset every flag you left out: sending only description set public, enabled and cloudinit back to 0, and any other field in the body, such as status or size, was written as sent. It now changes only the fields you send.

Before, {"description": "Debian 13 base"} disabled the image:

{"success": true, "message": "Image updated successfully!"}

After, the same request leaves enabled, public and cloudinit as they were. An empty body changes nothing.

Writable fields: description, public, cloudinit, enabled, variant, version, type, interface, purpose, cloud_config and user_id. Send null (or "" for cloud_config) to clear a nullable field. System-managed fields are refused with a 422 that names the field: name, url, filename, id, distro, release, format, status, size, source, image_id, image_storage_id, instance_id, hypervisor_group_id, publisher_version, build_date, label, available_on, hypervisors, distro_id, cloud_cfg, created_at and updated_at. Unknown keys are ignored.

purpose accepts the six import purposes only. An image created from an instance has purpose user, and that purpose cannot be changed through the API (sending user again is accepted and changes nothing):

{"success": false, "message": "The purpose of a user image cannot be changed.", "errors": {"purpose": ["The purpose of a user image cannot be changed."]}}

Adding an image from the catalog stores its size, or refuses

Section titled “Adding an image from the catalog stores its size, or refuses”

Adding an image through the Image Browser or POST /api/v1/image-browser/add now stores the catalog file size, used only when the submitted URL equals the catalog URL. Earlier releases could store size 0, which made every node fail the download with “Remote file size is 0!” and retry it every five minutes. When no usable size exists, the image is not created:

{"success": false, "reason": "size_unknown", "message": "The image size is unknown: the catalog has no size for this url and none was supplied, so the image was not added."}

Images already stored with size 0 are repaired from the live catalog by the five-minute catalog sync, which also clears their download pause. An image whose URL uses an old host alias or plain http is not repaired automatically; use the image’s update-release action.

Media downloads back off instead of retrying forever

Section titled “Media downloads back off instead of retrying forever”

An image or ISO with size 0 no longer creates a download task. After repeated failures, downloads back off per node and per file, from 10 minutes up to 24 hours between attempts. Admins see the download error on the Images and ISO lists, and both list and show responses carry the download diagnostics (download_error, download_error_at, download_reset_at), with a Retry download action (POST /admin/media/images/{image}/retry-download and POST /admin/media/isos/{iso}/retry-download in the panel). A stuck download task no longer blocks a retry: a task counts as in flight only if it was updated within the last 15 minutes and created after the last retry.

ISO create answers with data.id; ISO URLs must be https

Section titled “ISO create answers with data.id; ISO URLs must be https”

POST /api/v1/isos now returns the new ISO’s id under data.id. It refuses, for every node type, a non-https URL and a file name nodes cannot download:

{"success": false, "message": "The ISO URL must use https: nodes only download ISOs over https.", "errors": {"url": ["The ISO URL must use https: nodes only download ISOs over https."]}}
{"success": false, "reason": "iso_filename_invalid", "message": "The ISO file name may only contain letters, digits, dots, underscores and hyphens."}

A successful create:

{"success": true, "message": "ISO created successfully", "data": {"id": "123e4567-e89b-12d3-a456-426614174000"}}

GET /api/v1/iso/{isoId} also carries the download diagnostics described above.

Ejecting an ISO detaches it from the instance and never deletes the library record: GET /api/v1/iso/{isoId} keeps answering 200, and only DELETE /api/v1/iso/{isoId} removes it. A failed insert now fails its task with the node’s own message instead of leaving it looking successful. When the ISO file is missing on the node, a download starts and the insert task fails at once with “insert it again once the download finishes”. Inserting an ISO that was registered with an http URL on a KVM node now answers 422 with reason iso_url_not_https (“This ISO uses an http URL, which nodes never download, so it cannot be inserted. Delete it and register it again with an https URL.”); Proxmox nodes still fetch http ISOs. The same applies to the admin (POST /api/v1/instance/{instanceId}/iso/{device}/{action}) and user (POST /api/instance/{instanceId}/iso/{device}/{action}) endpoints.

POST /api/v1/instances accepts an optional enable_vnc. It must be a JSON boolean; 0, 1 and string values are refused with 422. When omitted, VNC is enabled only if an ISO is attached as boot media, as before. It is an admin API input only and is not accepted by the billing API.

Body excerpt:

{"instance_plan_id": "984d52b7-5e2c-4891-b740-36c3c120dc92", "enable_vnc": true}
{"success": false, "message": "The enable vnc field must be true or false.", "errors": {"enable_vnc": ["The enable vnc field must be true or false."]}}

Updating an instance accepts vnc_keymap and protection_enabled

Section titled “Updating an instance accepts vnc_keymap and protection_enabled”

PATCH /api/v1/instance/{instanceId} now also accepts vnc_keymap (one of the 33 keymaps the panel offers, for example en-us, de, fr) and protection_enabled (a boolean; while true, reinstall and destroy are blocked). A request with none of the editable fields lists the full set in its message: display_name, hostname, notes, boot, enable_smtp, vnc_keymap, protection_enabled. An unsupported keymap answers 422 here and in the panel VNC settings.

TPM and Secure Boot: node support, and when they apply

Section titled “TPM and Secure Boot: node support, and when they apply”

Enabling TPM now answers 409 with reason tpm_unsupported_on_node unless the KVM node hosting the instance confirms swtpm support in its heartbeat, including a node that has not sent its heartbeat yet. Proxmox nodes are not gated. This applies to the admin API and the user API. New Debian and Ubuntu nodes get swtpm and swtpm-tools installed during provisioning; an existing Debian or Ubuntu node needs a one-time apt-get install swtpm swtpm-tools. On any node, TPM support is reported once the swtpm, swtpm_setup and swtpm_ioctl binaries are installed. Hypervisor statistics gain system.tpm_supported. Disabling TPM is never blocked. Migration, HA failover and rebuild never place a TPM instance on a node that cannot run it. A KVM node reports TPM support only once it runs the 3.3.1 agent, so until then these operations will not choose it for a TPM instance.

{"success": false, "reason": "tpm_unsupported_on_node", "message": "TPM cannot be enabled: the node hosting this instance does not report TPM (swtpm) support."}

The TPM and Secure Boot switches store the change immediately and take effect at the instance’s next start. Earlier descriptions said the instance had to be stopped first. On Proxmox nodes the change applies when the instance is next deployed or rebuilt. A running guest is never changed.

Web SSH: token in the path, wss URL, typed failures

Section titled “Web SSH: token in the path, wss URL, typed failures”

The session endpoints (POST /api/instance/{instanceId}/ssh-session on the user API and GET /api/v1/instance/{instanceId}/ssh on the admin API) return a ws_url that carries the one-shot token in its path and uses wss when the panel is served over https. The relay does not read the token from a query string, a header or a first frame, so the old /ssh/ws?token=... form answers 401. The response also includes expires_in, in seconds.

User API before, with no URL in the answer:

{"success": true, "token": "...", "session_id": "...", "task_id": "..."}

User API after:

{"success": true, "token": "<64 characters>", "session_id": "...", "task_id": "...", "ws_url": "wss://panel.example.com/ssh/<64 characters>", "expires_in": 900}

Admin API before (under data, because of the envelope):

{"success": true, "data": {"token": "...", "session_id": "...", "task_id": "...", "ws_url": "https://panel.example.com/ssh/ws?token=...", "expires_in": 900}}

Admin API after:

{"success": true, "data": {"token": "<64 characters>", "session_id": "...", "task_id": "...", "ws_url": "wss://panel.example.com/ssh/<64 characters>", "expires_in": 900}}

The token is single use and expires after 15 minutes if unused. A plain HTTP request to the URL, or a malformed token, does not use it up. KVM guests need the QEMU guest agent, because the platform injects its SSH key through it.

Panel updates now test the web server configuration and reload nginx gracefully at the end of the update, so new web server settings take effect with no manual step. If the configuration test fails, nginx keeps serving the previous configuration and the reason is written to the application log. After updating from the command line by hand, reload nginx to apply new web server settings.

GET /api/instance/{instanceId}/webssh used to return the terminal HTML page and answered 500 on any failure. It now returns the same JSON as the session endpoint. Failures on all of these endpoints are typed instead of 500, or a 422 for everything:

Status reason Meaning
409 instance_not_running The instance is not running.
409 no_hypervisor The instance has no hypervisor.
422 unsupported_os Web SSH is Linux only.
422 none The instance has no accessible IP address for SSH.
503 ssh_key_injection_failed The node could not inject the SSH key, typically because the guest agent is missing or not answering.
503 none Any other failure, with a generic message.

The MicroVM location catalogs (panel and user API) now mark a location available: false when no node in it has an enabled MicroVM profile. Its locked flag still reflects only account access, so a location can be unlocked and unavailable at the same time. Creating a MicroVM there answers 422 with reason no_microvm_node:

{"success": false, "reason": "no_microvm_node", "message": "No node in this location has MicroVMs enabled."}

Backup deletes no longer share the instance task lock on KVM

Section titled “Backup deletes no longer share the instance task lock on KVM”

On KVM nodes, deleting a backup no longer blocks other actions on the instance, and other running actions no longer block a backup delete. A bulk delete of several backups of the same instance now sends every row. While a backup or restore of the instance is running, a delete is still refused on the node, and a new backup or restore is refused while a delete of that instance is running. Proxmox nodes are unchanged.

The tasks array in the user API instance payload no longer lists backup delete tasks. For billing modules, a “newest task” lookup on an instance can now return a delete that ran alongside another task.

Backup rows on the admin and user APIs gain based_on_backup_id and based_on_pbs_backup_time, which name the earlier snapshot an incremental Proxmox Backup Server backup reused (both are null for a full read and for other destinations, where incrementals keep using parent_backup_id). Task objects on the admin and user APIs gain source_backup_id and source_backup_at, which say which backup a restore came from. The user surfaces return null for a source backup that belongs to another account. The panels show “based on

Backup policies accept age-based (GFS) retention

Section titled “Backup policies accept age-based (GFS) retention”

Instance backup policies on the admin API (/api/v1) and the user API gain retention_mode (count, the default, or gfs) and the rules keep_last (max 365), keep_within_days (max 3650), keep_daily (365), keep_weekly (520), keep_monthly (120) and keep_yearly (30). With gfs, at least one rule must be above 0, and retention_count is no longer required. Existing policies keep count behaviour, with one change: count retention now applies per disk, so an All disks policy keeps retention_count chains for each disk. A new “Backups Pruned” email tells owners when age-based retention removes backups. See Backup policies for how the rules combine.

Body excerpt:

{"name": "Production", "retention_mode": "gfs", "keep_last": 2, "keep_daily": 7, "keep_weekly": 4, "keep_monthly": 6}

Subnet counts use the same availability rule as placement

Section titled “Subnet counts use the same availability rule as placement”

The free figures stated by the subnet endpoints (/api/v1/subnet/{subnetId}/available-ips with its total_available, and /api/v1/subnet/{subnetId}/statistics) and the hypervisor IPv4 statistics now use one rule: an address is available when it is free, not reserved, not held by a NAT gateway and not part of a migration. Earlier releases could show too few (an address released by an instance counted as used, so a subnet could show 0 available while placement still worked) or too many (addresses held by a NAT gateway or a migration counted as free).

Storage size and free space refresh for LVM and ZFS

Section titled “Storage size and free space refresh for LVM and ZFS”

Nodes send storage probes in a flat shape, and the master now reads it. Before, size and free space for LVM and ZFS storages were not refreshed from the probe and could stay stale. There is no change to the API response shape.

VPC location catalog and hypervisor group fields

Section titled “VPC location catalog and hypervisor group fields”

The VPC name rule (lowercase letters and digits, up to 16 characters) is unchanged; its 422 message now states the format. GET /api/vpc/locations no longer lists a KVM location whose group has no L2 interface: such a location was listed before, but creating a VPC there was refused with “VPC is not available at the selected location.” The catalog lists only locations where VPC is enabled, the group is not switched off, and an L2 interface is set (not required for Proxmox clusters).

PATCH /api/v1/hypervisor/group/{hypervisorGroupId} now accepts l2_interface, vxlan_mode and lb_image_id, which were ignored before; admin API create still does not accept them. On that endpoint and the panel group form, vxlan_mode cannot be null and l2_interface must be a valid Linux interface name: 1 to 15 characters, letters, digits, ., _, @ or -, starting with a letter or digit. Anything else answers 422 instead of being stored.

Two new admin endpoints manage the domains used for automatic instance and appliance names: GET /api/v1/dns/system-domains (paginated, search, with status, delegation state and the number of instances on each domain) and POST /api/v1/dns/system-domains. Create takes domain, dns_provider_id (a PowerDNS provider), ns1 to ns4, ttl (60 to 86400), for_instances, for_lbs, for_dbs, for_k8s and optional hostmaster_mail. The domain starts in pending_delegation; a failure creating the zone on the provider does not fail the request and is stored on the domain for retry from the panel. The certificate private key is never returned. Verification and the rest stay in the panel.

System settings: rescue identity and the VNC allowed sources check

Section titled “System settings: rescue identity and the VNC allowed sources check”

settings.system.rescue_motd and settings.system.rescue_hostname_prefix are new settings. The hostname prefix must be 1 to 24 letters, digits or hyphens starting with a letter or digit; the message of the day is at most 500 characters with no control characters other than new line and tab. A blank value resets either to the neutral default. Both the admin API and the panel now validate settings.system.vnc_allowed_sources entry by entry and answer 422 naming every invalid entry. A master that carries an old invalid value there cannot save any settings from the panel until the field is corrected.

{"success": false, "message": "The rescue hostname prefix must be 1 to 24 letters, digits or hyphens, and must start with a letter or digit.", "errors": {"settings.system.rescue_hostname_prefix": ["The rescue hostname prefix must be 1 to 24 letters, digits or hyphens, and must start with a letter or digit."]}}

Admin user update: null limits are refused, change_password accepts true

Section titled “Admin user update: null limits are refused, change_password accepts true”

PUT /api/v1/user/{userId} no longer accepts null for the account max_* limits (all except max_concurrent_ci_runner_jobs). Earlier releases wrote the null; it now answers 422, and you omit the field to keep the current value:

{"success": false, "message": "The max vpcs must be an integer.", "errors": {"max_vpcs": ["The max vpcs must be an integer."]}}

change_password sent as true or "true" now makes password required, as 1 and "1" already did. Other values, such as "yes" or "on", are ignored. change_password never changes a password by itself; it only makes password required.

The reference now documents the rest of the contract, which is unchanged: first_name, last_name and email are required on every update, email must be unique excluding the user’s own address, password is optional, the success response returns the user under data, and 0 means unlimited for most limits while max_vpcs, max_s3_buckets, max_volumes and max_static_ips treat 0 as blocking any further creation.

GET /api/v1/dns/reverse/requests works again, and each row carries the requesting user and, once processed, the handling admin and admin_id. Approving or rejecting a request records the acting admin and answers “RDNS request updated successfully!” (it used to return the zone message). Zone and request delete messages are corrected: RDNS zone deleted successfully! and RDNS request deleted successfully!.

Nested appliance instances carry fewer fields

Section titled “Nested appliance instances carry fewer fields”

Responses that nest an appliance instance under another resource (VPN gateways, managed databases, load balancers, backup queue lists, migrations, IPs, and the VPN gateway create and retry answers) no longer include the nested instance’s vnc_password or cloudcfg. Customer payloads that nest an appliance instance no longer include its hypervisor. Panel and API list endpoints that nest a full instance no longer include vnc_password. Read the appliance’s own detail endpoint for anything you need.

Reset password: the task ends done when only SSH password login could not be enabled

Section titled “Reset password: the task ends done when only SSH password login could not be enabled”

When the new password is set but SSH password login cannot be enabled in the guest, the reset password task now ends done instead of failed, with the message “Password set, but SSH password login could not be enabled: . Enable PasswordAuthentication in the guest” (followed by “or install/unblock the guest agent” when the agent is the cause). The password itself is changed.

26 assistant tools that always failed now work. create_hypervisor requires a hypervisor group and create_vpc checks the name rule.

3.3.2 adds full VM backups for managed databases and Kubernetes control planes on Proxmox Backup Server, records the service version on every such backup, improves IP, subnet and secondary network interface management, and tightens several refusal paths so they answer a status code with a reason.

  • Full VM backups for managed databases and Kubernetes control planes. A customer turns backups on per database or per cluster. The administrator chooses the destination, schedule and retention for each location, and whether customers see the retention summary. Backups are billed as a backup component: a flat monthly fee plus a per GiB rate on the data the backups actually store after deduplication. Destroying a database keeps its VM backups (they stay listed in GET /api/backups with service.deleted true), and their billing continues until they are deleted. Disabling backups on a Kubernetes cluster, or destroying the cluster, deletes its control-plane backups and stops billing; administrator-protected backups are kept and not billed. A customer can restore a database in place. Restoring a Kubernetes control plane, and disaster recovery of a whole highly available control plane from one backup set, are administrator-only.
  • The service version is recorded on each backup. An in-place restore is refused across a major database version, and for a Kubernetes control plane when the backup’s Kubernetes minor version is older than the cluster’s worker version (or more than one minor ahead of it). Restoring to a new service is planned for a later release.
  • Point-in-time recovery starts a new history after every restore. After a VM restore, an offsite restore or a point-in-time restore, point-in-time recovery is re-enabled automatically and starts from the first full backup taken after that restore. Earlier targets are refused with a clear reason, and manual backups wait a few minutes while recovery is re-enabled.
  • Offsite database restore and PostgreSQL point-in-time restore are improved. Offsite restore supports MariaDB databases, every check runs before any data is touched, the restored database is proven to be up before the previous copy is removed, and the previous data is put back if the swap fails. PostgreSQL point-in-time restore now carries its archive prefix and is refused before any data is touched when its inputs are incomplete. A failed restore stays visible on the database until it is acknowledged or a later restore succeeds.
  • A PostgreSQL restore whose recovery is still running is tracked to the end. The database stays restoring until the platform has confirmed that recovery finished, and actions that would stop or reconfigure it are refused meanwhile. Administrators can end a recovery that never finishes.
  • Point-in-time targets accept ISO 8601 with Z or an offset. The target is converted to UTC, and the response names the UTC time it restores to.
  • Replicas follow a restored primary. Restoring a primary resynchronises its replicas from the restored data automatically; until then each replica shows that it needs a resync. A replica itself cannot be restored; restore its primary instead.
  • Backup policies are unencrypted unless encryption is asked for. A policy created without encryption_enabled is unencrypted, and an encrypted policy always has a key.
  • Sizes read in GiB, MiB and KiB. Backup notices and the database pages label sizes with binary units.
  • IP and subnet management is more complete. IPv6 address generation works for every prefix length and either returns exactly the number requested or refuses and says how many fit. Large requests run in the background. A single IPv6 address can be added to an IPv6 subnet. The Add IP dialog validates MAC addresses and refuses IPv6 ranges with a clear message, and subnet creation has an Enabled switch (disabled subnets are flagged in the list).
  • The secondary private network interface explains refusals. Attach, detach and delete answer a status code with a reason, and the interface uses the same NIC model as the primary interface.
  • Network throughput. Plans with unlimited bandwidth no longer get a cap on the virtual NIC. Virtio NICs use one queue per vCPU (up to 8) with the vhost driver when the node has vhost_net, and deploys and updates load that module. Each VM picks this up at its next start.
  • Managed database restores wait for a quiet VM. A restore or point-in-time restore is refused while any task is running on the database VM, and replica syncs wait for each other.
  • Generated root passwords are shown once in the admin panel. When an administrator creates or reinstalls an instance without SSH keys, the generated root password appears in a dialog that closes only through its button.

New endpoints: full VM backups of managed databases and Kubernetes control planes

Section titled “New endpoints: full VM backups of managed databases and Kubernetes control planes”

User API (/api):

Method and path Purpose
GET /api/database/{id}/vm-backups Status, price, messages and the database’s backups
PUT /api/database/{id}/vm-backups/settings Turn VM backups on (no body)
DELETE /api/database/{id}/vm-backups/settings Turn VM backups off; optional delete_backups (boolean, default false)
POST /api/database/{id}/vm-backups/{backupId}/restore Restore in place
DELETE /api/database/{id}/vm-backups/{backupId} Delete one backup, also after the database was deleted
GET /api/kubernetes/cluster/{id}/vm-backups Status and messages for the cluster
PUT /api/kubernetes/cluster/{id}/vm-backups/settings Turn control-plane backups on
DELETE /api/kubernetes/cluster/{id}/vm-backups/settings Turn them off; unprotected control-plane backups are deleted and billing stops

Admin API (/api/v1) has the database and cluster equivalents under /api/v1/database/{id}/vm-backups... and /api/v1/kubernetes/cluster/{id}/vm-backups..., plus POST .../vm-backups (take a backup now, 202), DELETE .../vm-backups/sets/{setId} (delete a restore set of a highly available control plane) and POST /api/v1/kubernetes/cluster/{id}/vm-backups/sets/{setId}/dr-restore (restore every control-plane machine of a highly available cluster from one set, 202 with cluster_task_id). Restoring a Kubernetes control plane is available on the admin API only.

The customer sends no destination, policy, schedule or retention: enable takes no body, and those settings belong to the location.

{"success": true, "message": "Backups enabled.", "data": {"offered": true, "enabled": true, "status": "active", "points": []}}

Failures answer a status code with a reason. Enabling answers 422 with service_vm_backup_not_offered when the location does not offer VM backups, and 409 with replica_not_supported, service_not_ready or external_etcd_unsupported for a resource that cannot be backed up right now. Restore answers 409 with backup_not_on_current_primary (database) or backup_not_on_current_control_plane (cluster) for a backup taken from a replaced machine, and use_dr_restore for a highly available control plane. Deleting one backup of a live highly available set answers 409 with delete_whole_set; delete the set instead. Disaster recovery answers 409 with not_ha, set_incomplete, no_etcd_snapshot, set_predates_replacement or cluster_busy.

For a database, points lists the restore points newest first; a customer also gets restore_warnings. A Kubernetes customer gets the status payload without points. Each point with kind row carries id, name, created_at, size, backup_type, read_mode, consistency, verification_status, protected, claim (held), service (type, name, deleted), set, replaced_vm, service_version, version_status and version_notice. On the admin API a point of a highly available control plane has kind set with id, created_at, status (complete, incomplete or predates_replacement), size, the version fields and its members, and admin rows also carry destination (id, name, location_class). The destination’s host and path are never shown to customers.

Service version on each backup, and the restore refusal

Section titled “Service version on each backup, and the restore refusal”

Every VM backup row carries three new fields: service_version (the database engine version, or the control plane’s Kubernetes version, recorded when the backup was taken; null for a backup taken before 3.3.2), version_status (same, compatible, mismatch or unknown, compared with the resource now) and version_notice (a sentence to show before a restore). The account backup list GET /api/backups carries service_version on database VM rows.

Before:

{"id": "9f0c1c1e-...", "name": "orders-db full VM backup", "size": 1073741824, "backup_type": "full"}

After:

{"id": "9f0c1c1e-...", "name": "orders-db full VM backup", "size": 1073741824, "backup_type": "full", "service_version": "16.2", "version_status": "compatible", "version_notice": "This backup was taken on version 16.2 and the service now runs 16.4. The restore is allowed and the recorded version follows the backup."}

An in-place restore whose version differs by a major version answers 409 before anything is started:

{"success": false, "reason": "service_version_mismatch", "message": "This backup was taken on postgresql 15.6 and this database now runs 16.4. A backup from a different major version cannot be restored in place. Restoring to a new database is coming in a later release."}

For PostgreSQL the major is the first number (15 against 16); for MySQL and MariaDB it is the first two numbers (8.0 against 8.4, 10.11 against 11.4). A minor or patch difference restores, and the database’s recorded version follows the backup once the restore succeeds. A Kubernetes control plane is refused when the backup’s Kubernetes minor version is older than the cluster’s worker version, or more than one minor ahead of it. The same 409 applies to POST /api/v1/kubernetes/cluster/{id}/vm-backups/sets/{setId}/dr-restore. A backup with no recorded version is allowed, except for a cluster whose upgrade finished after the backup was taken.

Managed database restore: refusals before anything is touched

Section titled “Managed database restore: refusals before anything is touched”

POST /api/database/{dbId}/restore (user API) and POST /api/v1/database/{id}/restore (admin API) now check the database first and answer 409 before any task is created or any data is touched:

reason Meaning
replica_restore_not_supported The database is a replica. Restore its primary instead; its replicas are then resynchronised from the restored primary.
recovery_in_progress A recovery left running by an earlier restore has not finished yet.
database_not_active The database is not active (for example while another restore runs).

Before, the restore was started whatever the state of the database:

HTTP 200
{"success": true, "message": "Database restore initiated."}

After:

{"success": false, "reason": "database_not_active", "message": "The database must be active to restore a backup. Wait for the current operation to finish."}

(409.) A replica answers:

{"success": false, "reason": "replica_restore_not_supported", "message": "A replica cannot be restored. Restore its primary database instead; the replicas are then resynchronised from the restored primary."}

Backups taken from a replica are still allowed. When the restored database is a primary with replicas, the success message says so:

Before:

{"success": true, "message": "Database restore initiated."}

After:

{"success": true, "message": "Database restore initiated. Replicas are resynchronised from the restored primary."}

Managed database restore checks before it changes data

Section titled “Managed database restore checks before it changes data”

An offsite restore (MariaDB, MySQL or PostgreSQL) and a point-in-time restore check every input and tool before the data directory is touched. The restored data is proven to start before the previous copy is removed, and the previous data is put back when the swap fails. Offsite restore supports MariaDB databases, and PostgreSQL point-in-time restore carries its archive prefix to the node.

A failed restore records its error in last_error with error_acknowledged false, and that error now stays visible until it is acknowledged (POST /api/database/{dbId}/acknowledge-error) or a later restore succeeds. A healthy health check, or the success of another action such as a backup or a password reset, no longer clears it.

PostgreSQL recovery that is still running after a restore

Section titled “PostgreSQL recovery that is still running after a restore”

When a PostgreSQL restore finishes its wait while recovery is still replaying, the database now stays restoring, the previous data is kept, and last_error reads:

[restore] Recovery is still running on the database; check its status before retrying. The previous data was kept.

The health check moves the database back to active only once it has confirmed that recovery ended. Until then, these actions answer 409 with reason: recovery_in_progress: restore, backup, restart, password reset, resize, applying a parameter group, suspend, resume, retry deploy and taking a VM backup now.

{"success": false, "reason": "recovery_in_progress", "message": "A database recovery is still running on this database. Wait until it has finished."}

Point-in-time restore and engine upgrade answer 409 with database_not_active in that state. Scheduled backups and scheduled VM backups wait until recovery has ended.

A new admin-only endpoint ends a recovery that never finishes (for example when the node can no longer report the recovery state):

Method and path Purpose
POST /api/v1/database/{managedDatabaseId}/abandon-recovery Clear the outstanding recovery. No request body. Nothing is sent to the node.

The database moves to error (never active) with last_error “[restore] Recovery was abandoned by an administrator; the database needs attention before use.” and must be checked before use. The action is recorded in the admin audit log.

{"success": true, "message": "Recovery abandoned. The database is now in error and needs attention."}

It answers 409 with reason: recovery_not_pending when no recovery is outstanding, and 404 for an unknown database. The admin panel has the same action.

POST /api/database/{dbId}/restore-pitr and POST /api/v1/database/{id}/restore-pitr now:

  • accept target_time in ISO 8601: YYYY-MM-DD, then T or a space, then HH:MM with optional :SS and fraction, then optionally Z or a numeric offset (+02:00, +0200, +02). A value without an offset is read as UTC. The target is converted to UTC and fractions of a second are dropped. A value with Z now restores on PostgreSQL;
  • answer 422 with reason: invalid_target_time for any other value, before anything is started;
  • answer 409 with reason: replica_restore_not_supported for a replica, and 409 with reason: database_not_active when the database is not active (for example while a VM backup is being restored);
  • answer 422 with reason: pitr_history_reset for a target before the first full backup taken after the last restore (VM restore, offsite restore or point-in-time restore);
  • answer 422 (was 200) when the restore itself cannot be started;
  • name the UTC time in the success message.

Before (target 2026-05-21T16:30:00+02:00):

{"success": true, "message": "Point-in-time restore initiated. The database will be restored to 2026-05-21T16:30:00+02:00."}

After:

{"success": true, "message": "Point-in-time restore initiated. The database will be restored to 2026-05-21 14:30:00 UTC."}

A value the request check accepts but that is not one of the formats above (for example a date alone, 2026-05-21), before: sent on to the node as written. After (422):

{"success": false, "reason": "invalid_target_time", "message": "The target time is not a valid timestamp. Use ISO 8601, for example 2026-05-21T14:30:00Z or 2026-05-21 14:30:00 (UTC when no offset is given).", "errors": {"target_time": ["The target time is not a valid timestamp."]}}

A database that is not active, before: HTTP 200 with {"success": false, "message": "..."}. After (409):

{"success": false, "reason": "database_not_active", "message": "The database must be active to restore it to a point in time. Wait for the current operation to finish."}

A target before the first full backup since the last restore (422):

{"success": false, "reason": "pitr_history_reset", "message": "Point-in-time recovery is only available from 2026-05-21T10:00:00+00:00, the first full backup after the last restore."}

When no such full backup exists yet, the same reason answers with “Point-in-time recovery is not available until the first full backup after the last restore has completed.”

The database payload no longer carries pitr_epoch_history. pitr_epoch_started_at remains.

Point-in-time recovery is re-enabled after a restore

Section titled “Point-in-time recovery is re-enabled after a restore”

After a successful offsite restore or point-in-time restore of a database with point-in-time recovery active, the platform now re-enables point-in-time recovery into a fresh archive history and takes a new full backup, the same way it does after a VM restore. Earlier archives are kept and are not used for later point-in-time restores. The database stays active throughout.

While this runs, POST /api/database/{dbId}/backup and POST /api/v1/database/{id}/backup answer 409 instead of starting a backup into the old history, and scheduled backups wait:

{"success": false, "reason": "pitr_reopen_in_progress", "message": "Point-in-time recovery is being re-enabled after the last restore. Backups resume when that has finished, in a few minutes."}

Retry after a few minutes. If point-in-time recovery cannot be re-enabled, last_error says so and it can be retried from the backup settings.

When a primary with replicas is restored (VM restore, offsite restore or point-in-time restore), its replicas are now resynchronised from the restored primary automatically, one at a time, once the primary is active again. From the moment the restore is confirmed until a resync started after it completes, each replica shows replication_status stopped and this last_error:

[replica-resync] The primary was restored. This replica still holds the data from before the restore until it has been resynchronised from the primary.

The health check never reports such a replica healthy, and only a resync that started after the restore clears the state.

POST /api/database/{dbId}/resync-replicas and POST /api/v1/database/{managedDatabaseId}/resync-replicas now queue a resync for every replica that is not deploying or already syncing, and answer once they are queued. A replica that cannot be resynced shows the reason in its own replication_status (error) and last_error; the response no longer carries an errors list.

Before (one replica could not be started):

HTTP 200 (400 on the admin API)
{"success": false, "message": "Some replicas failed to resync.", "errors": ["Replica orders-db-r1: Failed to resync replica."]}

After:

{"success": true, "message": "All replicas are resyncing."}

Restore and replica sync wait while the database VM is busy

Section titled “Restore and replica sync wait while the database VM is busy”

POST /api/database/{dbId}/restore, POST /api/database/{dbId}/restore-pitr and their admin API equivalents under /api/v1/database/{id}/ answer 409 with reason: database_busy while any task is running on the database’s VM (not only a pending one), before anything is created or sent to the node. Two resynchronisations of replicas of one primary run one after the other.

Before, the restore was accepted and the node refused it moments later, leaving a failed task with no message:

HTTP 200
{"success": true, "message": "Database restore initiated."}

After (409):

{"success": false, "reason": "database_busy", "message": "Another operation is still running on this database. Try again when it has finished."}

If the node still refuses a restore because of a conflicting job, the database’s last_error carries a fixed sentence beginning [restore], and it stays until acknowledged.

POST /api/database/{dbId}/upgrade and POST /api/v1/database/{id}/upgrade answer 409 with reason: database_not_active when the database is not active; nothing is started.

{"success": false, "reason": "database_not_active", "message": "The database must be active to upgrade its engine version."}

A database backup policy created without encryption_enabled (POST /api/networking/db-backup-policies on the user API, POST /api/v1/db/backup-policies on the admin API) is unencrypted. When encryption is on, a key is always generated if none is given. The create response carries encryption_enabled:

{"success": true, "message": "Backup policy created successfully.", "policy": {"name": "nightly", "encryption_enabled": false, "status": "active"}}

A restore decrypts according to how each backup was stored.

On update (PATCH /api/networking/db-backup-policy/{id} on the user API, PATCH /api/v1/db/backup-policy/{dbBackupPolicyId} on the admin API), an omitted encryption_enabled leaves the setting unchanged, and turning encryption on generates a key when the policy has none. On the admin API, an empty or null encryption_key while encryption stays on answers 422; it is ignored while encryption is off:

{"success": false, "reason": "encryption_key_required", "message": "Encryption cannot be on without a key. Omit encryption_key to keep the current key (one is generated when encryption is turned on), or turn encryption off."}

Managed database last_error is written for customers

Section titled “Managed database last_error is written for customers”

On the user API database list and show responses, and on the project database responses, last_error carries a fixed sentence written for customers. The fixed sentences for restores, recoveries and replica resyncs shown above are passed through as written. The admin API keeps the node’s error text.

Database plan delete answers 409 plan_in_use

Section titled “Database plan delete answers 409 plan_in_use”

DELETE /api/v1/db/plan/{dbPlanId} answers 409 with reason: plan_in_use when any database, including a deleted one, still uses the plan. A plan that still backs kept VM backups answers 409 with plan_in_use_by_service_backups. The same plan_in_use_by_service_backups refusal applies to DELETE /api/v1/instance/plan/{instancePlanId} when deleted instances or clusters still hold VM backups billed at the plan’s rate.

Before:

HTTP 200
{"success": false, "message": "Cannot delete plan with active managed databases."}

After:

{"success": false, "reason": "plan_in_use", "message": "Cannot delete this plan: databases (including deleted ones) still use it."}

Administrator configuration for VM backups

Section titled “Administrator configuration for VM backups”
  • POST /api/v1/hypervisor/groups and PATCH /api/v1/hypervisor/group/{id} accept vm_backup_storage_id (an enabled Proxmox Backup Server destination with location_class local; null clears it and VM backups are then not offered in that location), service_vm_backup_policy_id (an active provider-owned policy; null clears it) and service_vm_backups_show_retention (boolean, default false).
  • POST /api/v1/hypervisor/backup-storages and its update accept location_class (local or offsite).
  • Database plans (/api/v1/db/plans) and instance plans (/api/v1/instance/plans) accept backup_credit_value (credits per GiB of stored backup data per month, on the size the backups actually take on the destination after deduplication) and backup_flat_credit_value (flat credits per month while the customer has VM backups enabled). 0 turns the component off.
  • DELETE /api/v1/backup-policy/{id} answers 409 with reason: policy_in_use_by_locations while a location uses the policy.

Account backup list includes database VM backups

Section titled “Account backup list includes database VM backups”

GET /api/backups now also lists full VM backups of the account’s managed databases (also after the database was deleted). Each row carries destination (name, location_class; never host, path or credentials) and database rows carry service (type, name, deleted). Deleting a database VM backup needs the databases permission; deleting an instance backup still needs the instances permission. Kubernetes control-plane backups are never listed on customer surfaces.

GET /api/kubernetes/cluster/{id}/tasks and the tasks list of a cluster no longer carry metadata or triggered_by_id on each task, and tasks of types cp_dr_restore, cp.restore and cp_backup_set are not listed. GET /api/kubernetes/cluster/{id}/task/{taskId}/logs no longer carries source_id or data on each entry and answers 404 for those task types.

Admin instance metrics: rx and tx are the guest’s view on KVM

Section titled “Admin instance metrics: rx and tx are the guest’s view on KVM”

interfaces.<device>.rx and tx on GET /api/v1/instance/{instanceId}/metrics are now the instance’s perspective on KVM, consistent with the rest of the 3.3.1 network changes: rx is what the instance received (download) and tx is what it sent (upload). total is unchanged, and Proxmox nodes are unchanged.

Before (example: a KVM instance h1631 that downloaded 5 MiB and uploaded 1 MiB):

{"interfaces": {"virh1631": {"rx": 1048576, "tx": 5242880, "total": 6291456}}}

After:

{"interfaces": {"virh1631": {"rx": 5242880, "tx": 1048576, "total": 6291456}}}

VPC create answers a status code and a reason

Section titled “VPC create answers a status code and a reason”

POST /api/vpcs (user API) and POST /api/v1/vpcs (admin API) now answer every refusal with a status code and a reason:

Status reason Meaning
422 location_not_available The location is not available to the account (user API only).
422 invalid_cidr The CIDR is outside the private ranges.
422 location_not_vpc_enabled VPC is not enabled at the location.
422 location_vpc_not_ready The location has no L2 interface.
422 quota_exceeded The VPC quota is reached.
409 provisioning_disabled Provisioning is switched off for the account.
409 vxlan_allocation_failed No VXLAN ID could be allocated.
503 vpc_sdn_sync_failed The VPC network could not be provisioned at this location; the VPC is not kept.

Before, on the user API: HTTP 200 with {"success": false, "message": "VPC is not available at the selected location."}. On the admin API the same refusal answered 400 with X-Failure-Status-Mapped: 1.

After:

{"success": false, "reason": "location_not_vpc_enabled", "message": "VPC is not available at the selected location."}

(422.) A 503 is safe to retry.

POST /api/v1/isos answers 422 with reason: iso_url_invalid when the URL does not end in .iso, 422 with reason: size_unknown when the size cannot be read (the URL must answer a HEAD request with a successful status and a Content-Length), and 409 with reason: iso_exists when an ISO with that file name is already registered.

Before: HTTP 200 (or 400 with X-Failure-Status-Mapped: 1 on the admin API) with {"success": false, "message": "..."}.

After:

{"success": false, "reason": "iso_exists", "message": "An ISO with this name already exists."}

Adding an image by URL (admin panel and assistant)

Section titled “Adding an image by URL (admin panel and assistant)”

The admin panel’s Add image form and the assistant’s create_image tool now refuse with 422 and a reason: url_not_allowed, image_url_not_https (https only), invalid_format (qcow2 only) or size_unknown. No row is created on a refusal. The stored size is always read from the file; a size supplied in the request is ignored. An unexpected failure answers 500 with reason: create_failed and a fixed message. A legacy http image or ISO row is no longer sent to KVM nodes: the row records a download error, and Retry download answers 422 with reason: url_not_https until the row is deleted and added again with an https URL. An ISO stored with size 0 is repaired on edit or on Retry download.

  • list_backups reports a real status (completed, failed, in_progress or unknown) and backup_type, and presents a database’s VM backups to customers as “ full VM backup”.

POST /api/v1/ips accepts ip_type single on an IPv6 subnet with the address under ipv6.ips[].ip, in full or compressed notation; it is stored in canonical form. A range on an IPv6 subnet is refused. Refusals are 422 with a reason: ip_exists, ip_not_in_subnet, ip_is_gateway or invalid_ipv6.

For example, an address outside the subnet answers (422):

{"success": false, "reason": "ip_not_in_subnet", "message": "The address is outside this subnet."}

IPv6 generation returns exactly the count requested

Section titled “IPv6 generation returns exactly the count requested”

Generating IPv6 addresses (ip_type ipv6 or ipv6_subnet on POST /api/v1/ips) now works for every prefix length, never hands out the gateway, and either writes exactly count addresses or answers 422 before writing anything: not_enough_addresses or not_enough_subnets (the message names how many fit), invalid_netmask or invalid_count (a whole number from 1 to 10000). Up to 500 rows are written during the request; a larger count is queued and the answer reports it.

Before, a /124 asked for 20 addresses wrote only the gateway and answered success. After:

{"success": true, "message": "20 IPv6 addresses added.", "data": {"count": 20, "queued": false}}

and for a subnet with room for fewer (422):

{"success": false, "reason": "not_enough_addresses", "message": "Only 14 addresses fit in this subnet."}

Adding one IPv4 address through POST /api/v1/ips is now done during the request and keeps the per-row mac. Refusals are 422 with a reason and, where one input is at fault, errors keyed by that field: ip_exists, ip_is_gateway, ip_not_in_subnet, ip_is_network_address, ip_is_broadcast_address, ip_is_hypervisor, invalid_ipv4 and invalid_mac.

Subnet create and update check the geometry: 422 with invalid_netmask or invalid_gateway, and 409 with subnet_overlap. Subnets accept an enabled flag (boolean, not null); disabled subnets are skipped for new allocations. Deleting a subnet that still has addresses in use answers 409 with reason: subnet_in_use, naming how many are in use.

Before, the delete either answered a generic failure or could run into a database lock when an address was being claimed at the same moment. Now:

409:

{"success": false, "reason": "subnet_in_use", "message": "This subnet has 3 addresses in use (1 static). Release them before deleting it."}

When the lock store used to serialise IP writes cannot be reached, the admin IP and subnet endpoints answer 503 with reason: lock_unavailable and a fixed message; the request is safe to retry.

Secondary private interface answers status codes

Section titled “Secondary private interface answers status codes”

POST /api/v1/instance/{instanceId}/network/{actionName}/secondary now decides every refusal before any address is claimed or any task is created, and answers a status code with a reason:

Status reason Meaning
422 invalid_action Unknown action
409 no_private_ip_available No free IPv4 address in a private subnet attached to the instance’s node
409 secondary_interface_exists The interface is already there
409 task_running The instance has a task running
409 instance_suspended, instance_migrating, forge_active, no_hypervisor The instance is in a state that does not allow the change
404 secondary_interface_not_found Attach or detach without an existing secondary interface
503 secondary_interface_dispatch_unconfirmed The node did not confirm; the task is kept and data.task_id names it

Before, an unknown action answered {"error": ...} and other refusals were not distinguishable by status code. Now a refusal looks like this (409):

{"success": false, "reason": "no_private_ip_available", "message": "A free IPv4 address in a private subnet attached to this instance's hypervisor is required. Add one before attaching a secondary interface."}

The success message names what happens (removed, being attached or detached, or applied at the next start when the instance is stopped). The secondary interface uses the primary interface’s NIC model, falling back to the plan’s NIC type (e1000 on Windows plans) and then virtio.

Instance create names the pools when no storage pool matches

Section titled “Instance create names the pools when no storage pool matches”

When an administrator creates an instance and no storage pool fits the plan, the refusal now names the plan’s storage type and class and up to three pools attached to the node, with their type, class, enabled state and free space. Customers still see the plain sentence.

An interface set to unlimited bandwidth is no longer capped at about 8.4 Gbit/s, interfaces with a limit get a short inbound burst, and virtio interfaces use one queue per vCPU (up to 8) with the vhost driver when the node has vhost_net. The module is loaded on new deploys and updates. The change applies to each VM at its next stop and start.

The managed database scripts (replica setup and resync, backup, point-in-time recovery and restore) are delivered with Unix line endings, so they now run on Proxmox nodes.

3.3.3 lets you restore a managed database backup into a new database, adds reverse DNS zone adoption and import, a guided recovery for backups stuck in deletion, and more detailed storage health readings.

  • Restore a database backup into a new database. A full VM backup of a managed database can be restored into a brand-new database in the same location, on the user API and the admin API. The original is untouched, and the new database is billed from the moment it becomes active.
  • Overlapping database backups are refused. Full and incremental backups of one managed database go through a single admission check, and a backup stuck in pending or in_progress can be closed by an administrator with the new reconcile endpoint.
  • Reverse DNS zones can be adopted and imported. Existing PowerDNS and ClouDNS reverse zones can be linked to the panel and their PTR records imported, read only toward the provider.
  • Stuck backup deletions can be recovered. An administrator can resolve a local, S3 or rclone backup left in “being deleted” through a guided offline recovery.
  • Storage health is more detailed. Storage responses report when each reading was taken and whether it is stale, LVM thin pools that need attention are flagged, and ZFS raidz pools report their usable space.

Database backup admission and restored-database actions

Section titled “Database backup admission and restored-database actions”

The user and admin APIs now refuse overlapping full and incremental database backups through the same atomic admission check:

  • POST /api/database/{id}/backup
  • POST /api/v1/database/{id}/backup

An existing backup with status pending or in_progress answers HTTP 409 with reason: "backup_in_progress". Nonterminal work on the actual source VM answers 409 database_busy; this includes a replica selected to take a policy backup. Neither refusal creates another backup or task. A backup remains blocking until its state becomes terminal; elapsed time does not release it.

Admission also conservatively serializes the primary and all its replicas while any family member has a pending or in_progress backup. A retained backup belonging to another member answers 409 database_busy, even with missing or terminal source Task tracking. Because backup records do not retain the historical source VM, current replica health cannot safely identify that source; distinct sibling VMs are held too. An authoritative terminal backup state releases this family hold. Unrelated database families remain independent.

{"success": false, "reason": "backup_in_progress", "message": "A backup is already in progress for this database. Wait until it has finished."}

backup_type remains full by default, or incremental when explicitly requested. Both types also refuse with 409 restore_not_activated, recovery_in_progress or pitr_reopen_in_progress while the corresponding restore/recovery hold exists. Existing incremental-only refusals remain incremental_requires_policy, incremental_not_supported_on_replica, incremental_requires_pitr, incremental_requires_full_backup and incremental_parent_unavailable. An unavailable incremental never silently becomes a full backup.

A transport failure, node HTTP 5xx/408/429, or invalid dispatch acknowledgement does not prove the node refused the work. Its backup and task can remain pending until a terminal callback arrives or an administrator reconciles it with POST /api/v1/db/backup/{dbBackupId}/reconcile (see below). Caught dispatch errors on the user backup API currently answer HTTP 200 with success: false and message; the admin API maps that failure to HTTP 400. No generic dispatch reason is promised. Check both the HTTP status and success, inspect database backups/tasks before retrying, and do not automatically replay this mutation. OpenTofu’s shared client and the MCP backup tool now send it once. A successful acknowledgement means the work was accepted, not that a backup finished.

Until a restore-to-new database activates successfully, including when its restore failed, these actions on both /api/database/{id} and /api/v1/database/{id} answer 409 restore_not_activated: POST /restart, POST /reset-password, PATCH /resize and PATCH /parameter-group, as well as the backup above. No password, plan or configuration change starts. This extends the documented pending-restore lifecycle guard; monitor repair is also held internally. Admin refusals retain success, reason and message at the top level of the v1 envelope.

New endpoint, with an admin panel twin and a Reconcile button on the admin database Backups tab:

  • Admin API: POST /api/v1/db/backup/{dbBackupId}/reconcile

The body {"confirm_not_running": true} is required; any other body answers 422. Send it only after you have confirmed that the node is not running the backup.

  • A backup stuck in pending or in_progress is marked failed, so new backups of the database can start again.
  • For a backup that already completed or failed while its task still shows running, only the task is closed and the backup result is unchanged; data.task_closed names the status the task was closed with (completed or failed).
  • When there is nothing to reconcile the endpoint answers 409.
  • Nothing is ever deleted from the backup destination, and a late completion report for a reconciled backup is ignored.
POST /api/v1/db/backup/4c1d7e52-0b3a-4a5e-8f61-3a9d2b7c1e10/reconcile
{"confirm_not_running": true}
200
{"success": true, "message": "Backup marked as failed. Nothing was deleted from the backup destination.",
"data": {"backup_id": "4c1d7e52-0b3a-4a5e-8f61-3a9d2b7c1e10", "status": "failed", "task_failed": true}}

The same call on a backup that already finished, whose task still showed running:

200
{"success": true, "message": "The backup had already finished. Its running task was closed; nothing else was changed.",
"data": {"backup_id": "4c1d7e52-0b3a-4a5e-8f61-3a9d2b7c1e10", "status": "completed", "task_failed": false, "task_closed": "completed"}}

Appliance responses carry fewer nested instance fields

Section titled “Appliance responses carry fewer nested instance fields”

These admin responses no longer include the nested instance’s cloudcfg and vnc_password fields: GET /api/v1/vpn-gateway/{vpnGatewayId} and POST /api/v1/vpc/{vpcId}/vpn-gateway (instance under the gateway), GET /api/v1/database/{databaseId} (instance under the database), and the admin panel pages for the same resources and the load balancer page’s backend targets.

Reverse DNS: adopt an existing zone and import its PTR records

Section titled “Reverse DNS: adopt an existing zone and import its PTR records”

When you move from another panel, reverse zones often already exist on PowerDNS or ClouDNS. Three admin operations now cover them:

  • POST /api/v1/dns/reverse/zones accepts a new boolean adopt_existing. The panel links to the zone on the provider and does not rewrite its NS or SOA records.
  • GET /api/v1/dns/reverse/zone/{rdnsZoneId}/import/preview lists, per record, whether it would be imported, already matches, differs from the panel’s value, has no matching IP in the zone’s subnets, or is skipped with a reason.
  • POST /api/v1/dns/reverse/zone/{rdnsZoneId}/import runs the import as a background task. By default it fills only IPs whose reverse DNS is empty; send overwrite: true (a strict boolean) to replace differing values.

The import only reads from the provider and never writes to it. Deleting an adopted zone in the panel removes only the panel’s record, never the zone on the provider. Run the import before any Rebuild, because a Rebuild rewrites provider PTR records that were not imported.

A backup on a local, S3 or rclone destination could stay “being deleted” forever when a node without deletion fencing received the delete and never confirmed it. POST /api/v1/instance-backup/{backupId}/reconcile now accepts the resolutions request_unfenced_recovery and cancel_unfenced_recovery. After a request, the operator drains the node the delete was sent to and runs php artisan backup:recover-unfenced <id> as root. The node blocks new deletes of that backup, checks that no delete process is alive and reads the backup file itself. If the file exists, the backup is kept and released. If it is provably gone (local and S3 only), the backup record is removed. Nothing is deleted automatically and nothing is released because time has passed. A refused recovery shows its reason and can be requested again.

GET /api/v1/hypervisor/storages and GET /api/v1/hypervisor/storage/{id} add three fields. last_probe_at is the time of the last probe attempt, last_probe_error is a short reason when that attempt failed (otherwise null) and metrics_stale is true when the last probe failed or the last good reading is older than 15 minutes. last_metric_at remains the last successful reading and existing fields are unchanged. LVM thin pools that are read only, need a check, are out of data space or have failed are now flagged, and ZFS usable space is reported instead of raw raidz space.

Restore a database backup into a new database

Section titled “Restore a database backup into a new database”

New endpoints:

  • User API: POST /api/database-vm-backups/{backupId}/restore-to-new
  • Admin API: POST /api/v1/database-vm-backups/{backupId}/restore-to-new

Every body field is optional: name (default <source name>-restored), db_plan_id (default the plan of the backup; its disk must hold the backup), vpc_id and vpc_subnet_id (default the original database’s network). The engine, engine version, owner and node always come from the backup and cannot be set. The new database gets a new admin password; its other credentials come from the backup.

Before: a database backup could only be restored in place, and a backup whose database major version no longer matched answered 409 service_version_mismatch with the message “coming in a later release”. After the same request still answers 409 service_version_mismatch, now with the message “Restore it to a new database instead”, and the new endpoint accepts it.

POST /api/database-vm-backups/9f0c1c1e-3f43-4a55-9a7b-2f1c3d2f8a10/restore-to-new
{"name": "orders-restored"}
202
{"success": true, "message": "The restore to a new database was started.",
"data": {"managed_database_id": "c5d1f0a2-...", "task_id": "0a8e4b19-...", "status": "restoring"}}

The new database stays restoring until the restore has finished and the guest was prepared, then becomes active. Nothing is billed while it restores or if it fails, and a failed restore leaves the database in error with no charge. Refusals carry a reason (for example destination_unavailable, plan_storage_too_small, no_hypervisor_available, account_busy, quota_exceeded); a dispatch that cannot be confirmed answers 503 deploy_dispatch_unconfirmed and keeps the new database and its task. Backup rows in the list gain a can_restore_to_new boolean. Deleting a backup that a restore is reading from answers 409 with reason: "backup_in_use_by_restore" (always this reason) until the restore ends, and starting a restore from a backup that is being deleted answers 409 with reason: "backup_being_deleted".

A restore into a new database now needs a network. When neither the request (vpc_id, vpc_subnet_id) nor the source database has a VPC, the request answers 422 with reason: "vpc_required" and nothing is created. The panels hide the action for such a database and show the reason.

Before: the request was accepted (202) and the restore then failed.

After:

{"success": false, "reason": "vpc_required", "message": "Choose a VPC and subnet for the new database."}

A database created this way also gets a new restored_from field on its detail response (GET /api/database/{id}, GET /api/v1/database/{id} and the panel pages): {"backup_id": "9f0c1c1e-...", "source_name": "orders", "taken_at": "2026-10-01T09:30:00+00:00", "engine_version": "8.0.36"}. It is null for every other database. It is a display summary only; the backup’s destination, namespace and claim details are never included.

Until a restored database has been activated (including one whose restore failed), redeploying, suspending and resuming it, restarting its database engine, resetting its admin password, resizing it, taking a database backup, applying a parameter group, and starting, restarting or resetting its instance, answer 409 with reason: "restore_not_activated". A failed restore cannot be redeployed into a free database: delete it and restore again.

The three Kubernetes version-mismatch messages no longer say “coming in a later release”; the reason service_version_mismatch is unchanged.

Managed database create: busy, quota and NAT gateway refusals

Section titled “Managed database create: busy, quota and NAT gateway refusals”

POST /api/databases (user API) and POST /api/v1/databases (admin API) answer these refusals with a status code and a reason, before anything is created:

Status reason Meaning
409 account_busy Another create for the same account is still being processed. Try again in a few seconds.
422 quota_exceeded The account has reached its managed database limit.
409 nat_gateway_required The chosen VPC subnet is private and has no active NAT gateway.

The same refusals apply to creating a replica (POST /api/database/{id}/replica) and to the user and admin panels.

{"success": false, "reason": "account_busy", "message": "Another request for this resource is already being processed for your account. Please try again in a few seconds."}
{"success": false, "reason": "nat_gateway_required", "message": "A NAT gateway is required for private subnet deployments. Please attach a NAT gateway to this subnet first."}

Kubernetes regions must be ready for VPC networking

Section titled “Kubernetes regions must be ready for VPC networking”

Every Kubernetes cluster runs in a VPC. GET /api/kubernetes/search/regions and the panel’s cluster create wizard list only locations whose network is ready for VPCs (an L2 interface is set on the location, or the location is a Proxmox cluster). POST /api/kubernetes/clusters in a location that is not ready answers 422:

{"success": false, "reason": "location_vpc_not_ready", "message": "This region is not ready for VPC networking. Kubernetes requires it.", "errors": {"hypervisor_group_id": ["This region is not ready for VPC networking. Kubernetes requires it."]}}

Deleting a user who still owns network resources

Section titled “Deleting a user who still owns network resources”

DELETE /api/v1/user/{userId} (admin API) and the admin panel answer 409 with reason: "user_has_network_resources" while the user still owns VPCs, NAT gateways, VPN gateways or static IPs. data.resources lists the count of each kind the user still owns; delete those resources first, then delete the user.

{"success": false, "reason": "user_has_network_resources", "message": "Cannot delete user who still owns VPCs, NAT gateways, VPN gateways or static IPs. Delete them first so the nodes are cleaned up and the public IPs are released.", "data": {"resources": {"vpcs": 1, "static_ips": 2}}}

A successful delete also removes the user’s personal API tokens.

The admin and user panels now label VPC DNS zones “Private DNS zones”. This is a label change only; the API paths and fields are unchanged.

3.3.4 adds update to latest for the Master and its nodes, long operations that keep running through service restarts, migrations that show each step as it happens, live updates across the panels, improved NVIDIA GPU support, an optional source address filter for public interfaces, certificate reuse for Kubernetes load balancers, and specific deployment error reasons.

  • Update to latest. An administrator can update the Master and then every node from one action, one node at a time, or update a single node from its page.
  • Long operations keep running. Downloads, image creation, deploys, backups, database operations and migration transfers keep running through service restarts and report progress while they run.
  • Migration steps. Migrations show each step as it happens, and an action requested while a step is running answers with a specific reason.
  • Live updates. Admin and user pages follow changes over websockets for many more resources.
  • Improved NVIDIA GPU support. The create pages and plan APIs show a GPU plan’s requirement and whether a GPU is free in the location, GPUs busy with workloads on the node itself are skipped, and plan changes can add, change or remove a GPU.
  • Source address filter. A new setting turns on a source address filter for public interfaces. It is off by default, and each instance has a Source check switch for its VPC interface and one for its public interface.
  • Kubernetes load balancers reuse account certificates. ssl-mode=certificate on a Service picks a certificate from your account through annotations.
  • Subnets know their bridges. A subnet bridge that a reporting node lacks is refused, and placement skips nodes that lack it.
  • Deployment error reasons. A location with no deployable node answers with a specific reason, and node refusals and outages use distinct status codes.
  • Search and filters on many admin and user list pages now run on the server, over the full result set.
  • Plan responses carry a gpu object: null for a plan without a GPU, otherwise count, vendor, vram_min_gb, mode, profile and a label. The label names the GPU model when the location has one free and otherwise states the requirement. Where the plan is read for one location (GET /api/cloud-service/location/{id}/plan-group/{id}/plans) the object also carries available.
  • The same object appears on GET /api/v1/instance/plans, GET /api/v1/instance/plan/{id}, GET /api/v1/cloud-service/plan-group/{id}/plans, GET /api/v1/cloud-service/plan-group/{id} and the Kubernetes plan search endpoints. The OpenTofu iaas_plan and iaas_kubernetes_plan data sources expose it as a computed gpu attribute, and the MCP plan tools pass it through.
  • Creating an instance on a GPU plan allocates the GPU as part of the create. When no GPU can be assigned, the request is refused with a reason such as gpu_not_available or gpu_not_enabled and nothing is created. The HTTP status of the refusal depends on the endpoint, so read the reason field and the endpoint’s documented responses.
  • GPU plans are not available for Kubernetes clusters yet.
  • An instance that holds a GPU is bound to its node. POST /api/v1/migrations answers 409 with reason: "gpu_instance_migration_unsupported" for it, cold and live, and HA evacuation and node rebuild leave it in place. Create the workload again on the target node to move it.
  • GPU allocation checks for workloads running on the node and skips a GPU it detects in use. The node attempts to restore a known previous host driver when the instance that held the GPU is destroyed.
  • Plan changes (POST /api/v1/instance/{id}/change-plan and the panels) can add, change or remove a GPU on a supported node. An added GPU is reserved immediately and attached at the next start. Removing or changing an allocated GPU requires an idle, stopped instance with a fresh node status, and the GPU stays reserved until the node confirms release. Refusals include gpu_release_requires_stopped_instance, gpu_release_requires_idle_instance, gpu_release_pending, gpu_not_available and gpu_not_enabled. GPU-changing plan moves on Proxmox nodes answer 409 with reason: "gpu_plan_change_unsupported_on_node". A plan change overtaken by a newer plan change answers 409 with reason: "plan_change_in_progress".
  • A successfully created VPC is marked active. Its routers are provisioned on demand on nodes that host a member, with or without a NAT gateway. Router provisioning and network synchronization can still be pending; active does not guarantee immediate traffic readiness.

Image and ISO downloads, image creation, instance deploy and destroy, backup deletion, managed database backups, restores and other database operations, and migration file transfers keep running through a service restart on the node and report progress while they run. A run that cannot be completed is reported as failed.

  • Each migration step is reported as it happens. A step that is still running answers an operator rollback, restart or complete request with 422 and reason: "migration_step_running".
  • A migration whose node has reported nothing for 15 minutes is marked failed, and the operator can then roll it back once.
  • POST /api/v1/migrations: instance_storage_mapping is optional. Without it, disks on shared storage attached to the destination stay where they are and local disks move to dest_storage_id. A local disk with no mapping and no dest_storage_id answers 422 with reason: "local_disk_mapping_missing", and a destination storage that is not attached to the destination node answers 422 with reason: "storage_not_attached_to_destination". Other refusals answer 422 or 409 with a reason (for example destination_not_enough_ips, invalid_vpc_mapping, database_restore_in_progress), and an unexpected error answers 500 with reason: "migration_start_failed". A successful cold migration start returns migration_id and migration_type.
  • Before: {"success": true, "message": "..."}. After: {"success": true, "message": "...", "data": {"migration_id": "...", "migration_type": "cold"}}.
  • Cold migration works to AlmaLinux and Rocky Linux destination nodes, and live migration of instances with local disks keeps the disk path on the destination.
  • A migration whose destination is the instance’s own node answers 422 with reason: "destination_is_origin". delete_source (cold only) removes the source copy (domain, disk files, instance directory) once the guest is confirmed on the destination; without it the source copy is kept. A live migration always removes the source copy of node-local disks after a confirmed cutover. Shared storage is never removed.
  • Live migration opens TCP 49152-49215 on the destination only for the source node and only while the migration runs (firewalld, ufw and CSF).
  • POST /api/v1/hypervisor and PATCH /api/v1/hypervisor/{id} keep the saved node when the node cannot be reached for the last step. The response is a success that carries warning and warning_reason (node_unavailable, node_refused or node_update_failed).
  • Nodes report their clock. A node whose clock differs from the Master by more than 30 seconds shows a clock warning on its page and in its readiness checks.
  • Update to latest endpoints: POST, GET and DELETE /api/v1/hypervisors/update-to-latest start, read and cancel the one-at-a-time run over all nodes; POST /api/v1/hypervisor/{hypervisorId}/app-update/latest and GET and DELETE /api/v1/hypervisor/{hypervisorId}/app-update/chain do the same for one node. A node is left alone while it still has work in flight.

New endpoints:

  • POST /api/instance/{id}/vpc/source-check
  • POST /api/v1/instance/{id}/vpc/source-check

The body selects the interface type. Without interface it applies to the VPC interface; with interface: "public" it applies to the public interface. When an instance has several interfaces of that type, the switch applies to the first one by name.

{"source_check": false}
{"source_check": false, "interface": "public"}

The switch reaches the node within about a minute without a restart, and is also available on the Networking tab of the instance in both panels.

Public interface source address filter (setting)

Section titled “Public interface source address filter (setting)”

The new setting network.public_antispoof turns on a source address filter on public interfaces. It is off by default on every install. A value that is not valid answers 422 with reason: "invalid_setting" on the panel and on the API. The Settings page explains that IPv6 addresses must be EUI-64 style for the filter to pass neighbour discovery. A public Source check switch overrides the setting for the public interface of one instance.

  • Backups to a mounted backup storage are written directly into the destination and the space check is made there. Remote destinations use a temporary location the node selects by free space. No node configuration is needed.
  • A failed backup sets last_backup_failed_at on the instance.
  • System backup policies (admin API POST /api/v1/backup-policies and PATCH /api/v1/backup-policy/{id}, and the panel) accept an optional timezone (an IANA name). Leaving it out keeps the stored zone, null clears it. Customer policies refuse the field.
  • The ISO hypervisors field lists the nodes that hold the complete file and changes when a download finishes or a file is removed.
  • Instance create and reinstall answer 409 with reason: "image_not_ready" while the image is still downloading on the node. A new instance is removed again; a reinstall leaves the instance unchanged.
  • Subnet create, update and attach answer 422 with reason: "subnet_bridge_missing_on_node" when a node that reports its bridges lacks the bridge. Deployment placement skips such nodes (subnet_bridge_missing).
  • A node that refuses a request answers 502 node_refused, and an unreachable node answers 503 node_unavailable. hypervisor_down is 409.
  • A location where no node can host the request is refused with the specific reason; customers see one fixed sentence per reason and administrators also see the per-node detail. The HTTP status depends on the endpoint, so check each endpoint’s documented responses.
  • A cloud-config that is not valid answers 422 with reason: "invalid_cloud_config". An empty cloud-config is accepted.
  • Each instance gets one default route per address family on KVM and Proxmox nodes, and an instance without an IPv6 gateway gets no IPv6 default route. Instances with several public interfaces or IPv6 allocations get a default route per address family on first boot.
  • Windows instances on KVM nodes apply Remote Desktop and the first-logon setup on first boot and keep an IPv6 link-local address derived from the MAC address.

Kubernetes load balancers and certificates

Section titled “Kubernetes load balancers and certificates”

A Service with ssl-mode=certificate can reuse a certificate from the account through annotations instead of uploading one.

Admin and user pages update over websockets for many more resources. Admin and user list pages search the whole result set before pagination, including the KYC status filter, media groups, owner names, clusters and the Pending filter. The VPC create page lists every VPC-ready location.