Customer API

betav1.0.092 operationshttps://my.voxa.host/api/v1

Everything a customer can do in the client panel, from a script. Authenticate with an API key from Profile → API Keys. This page is generated from the same sources as openapi.json and guide.md.

Beta. The API is stable enough to build on, but endpoints and response shapes may still change before the stable release; changes are announced in this guide.

Base URL: https://my.voxa.host/api/v1 Machine-readable spec: https://my.voxa.host/api/v1/openapi.json · Rendered docs: https://my.voxa.host/api/v1/docs

This API lets a customer manage what they already own in the client panel: VPS and dedicated servers, plugin-provisioned services, add-ons, upgrades, orders, invoices and the credit balance. It is scoped to the customer whose key is used and enforces the same permissions the panel does.

1. Authentication

Create a key in the client panel under Profile → API Keys. The plaintext is shown once. Send it on every request:

Authorization: Bearer fbk_<8 hex>_<96 hex>

A key has scopes chosen at creation (they cannot be widened later; make a new key):

scopeunlocks
readevery GET (always included)
servicespower, reinstall, password reset, snapshots, backups, plugin actions, SSH keys, withdraw a cancellation
orderscreate orders, buy add-ons, request or cancel upgrades
billingpay invoices/proformas from credit, request a renewal
destructivecancel services (end of term or immediate termination), cancel add-ons

A key may also carry an IP allowlist and an expiry. Team members' keys are additionally limited to the member's account permissions; the account owner is not. A member can only mint a scope their role covers: read needs services.view, invoices.view or billing.view; services needs services.manage or services.power; orders needs services.manage; billing needs invoices.pay; destructive needs services.cancel. At request time the member's LIVE permissions are re-checked on every route (403 FORBIDDEN, or PERMISSION_DENIED on the generic actions endpoint), so a key never outranks the role it was minted under; a member removed from the account keeps a working key that only reaches their own services. Members share the account's services, invoices and orders, but credit balances are per user: paying from credit always spends the CALLER's balance, and a member needs invoices.view / invoices.pay to read / pay the account's invoices and proformas.

Keys can only be created, edited and revoked from the panel (or by the operator), never through the API itself.

1b. Who am I?

GET / answers the identity behind the key and is the right first call (wrapped in the usual { "success": true, "data": … } envelope like every other response):

{ "version": "1", "base_url": "…", "user": { "id", "email", "name" },
  "account": null | { "id", "name", "owner": true|false },   /* team account context, when any */
  "key": { "id", "label", "scopes": ["read", "services"],
           "project": null | { "id", "slug", "name" } },   /* what this KEY is confined to */
  "project": null | { "id", "slug", "name", "bound": true|false },  /* what THIS REQUEST is scoped to */
  "rate_limit": { "limit": 1000, "remaining": 999, "reset": 1788305614 },
  "scopes": [ { "scope", "description" } ], "links": { "openapi", "guide", "account", "services", "vps", … } }

GET /account adds the profile (company, country, currency), credit_balance and, for team members, the account permissions in force.

2. Response envelope

Every response is JSON.

Success: { "success": true, "data": … } — some endpoints add "message" or "pagination".

Refusal: { "success": false, "error": "<human message>", "code": "<MACHINE_CODE>", "details"?: [ { "field", "message" } ] }

HTTPcodemeaning
400BAD_REQUESTinvalid input or the action does not apply to this service; details lists field errors
401UNAUTHORIZEDmissing, malformed, unknown, expired or revoked key
403SCOPE_REQUIREDthe key lacks the scope ("scope" names it)
403FORBIDDENaccount permission, IP allowlist, account type or service state refused it
403ACCOUNT_SUSPENDEDsuspended customers are read-only
403PERMISSION_DENIEDONLY on POST /services/{id}/actions/{action}: the member's account permission does not cover this verb (backups.create and any provider mutation need services.manage). Every other route answers a plain 403 FORBIDDEN "Insufficient permissions" for the same situation
403ACTION_NOT_ALLOWEDthe provider plugin does not expose this action to customers
403FEATURE_DISABLEDthe operator switched the API off (every route, the three public docs routes included), or the module the route belongs to (/dedicated/* → inventory, /vps/{id}/snapshots* → native VPS). With the native VPS module off, power/reinstall/password/stats on a native VPS answer 403 FORBIDDEN "The VPS module is not available" while the read routes still work
404NOT_FOUNDno such resource, or not owned by this account (never distinguishes the two)
409CONFLICT / RENEWAL_PENDINGstate conflict, e.g. an unpaid invoice or proforma already open on the service (any open document blocks a renewal, not only a renewal document), an instance still building
403 / 404 / 409game.*game servers keep the panel's dotted codes: game.module_disabled (403), game.server_not_found (404), game.capability.runtime_stubbed (409, no runtime attached yet), game.power_action_unknown (400)
429RATE_LIMITEDhourly per-key window exhausted — see headers below
429ACTION_RATE_LIMITEDper-service cool-down on this action; retry_after_seconds in the body and a Retry-After header
424PROVIDER_ERRORa dedicated server's BMC did not complete the power action (the attempt still counts against the 5/min cool-down)
503PROVIDER_ERROR / PROVIDER_UNAVAILABLEthe hosting provider refused or is unreachable (a provider plugin whose flow reported a failure answers this, never a 200); retry later
500 / 502 / 503INTERNAL_ERROR / UPSTREAM_ERROR / UNAVAILABLEplatform-side failure; see the retry rule below

The hourly window is kept in the process: after a platform deploy the counters and X-RateLimit-Reset start over. Browser clients cannot read the rate-limit headers (CORS exposes them from this release on; older deployments expose only Content-Disposition).

Malformed ids (not a UUID) are a 400 BAD_REQUEST with details: [{ "field": "id", … }], not a 404. A body that is not valid JSON is a 400 INVALID_JSON from the parser (no details).

Retry rule: 5xx and 429 are retry-able — except on money-moving calls (/pay, /orders, /options, /upgrades). A 5xx there may have applied: re-read the resource first (GET /proformas/{id}, GET /invoices/{id}, GET /orders/{id}, GET /upgrades/{id}, balance) and only retry if it shows nothing happened. Never retry a 4xx blindly.

POST /orders gives you both halves of that rule outright:

  • Send an Idempotency-Key header (or idempotencyKey in the body) and the retry is safe by construction — see the Idempotency section under Orders.
  • If you did not send one, find the attempt with GET /orders?hostname=<the hostname you sent> before deciding. It matches the order, its lines and any service built from it.

3. Rate limits

Per key: X-RateLimit-Limit requests per hour (the operator's setting, default 1000, the same for every key on this platform — it is not configurable per key), X-RateLimit-Remaining, X-RateLimit-Reset (unix seconds). Every request counts, including refused ones. On this 429 (RATE_LIMITED) a Retry-After header is set.

Per-resource cool-downs (keyed by your user + the id in the path — the service for service verbs, the invoice or proforma for credit payments — so a second key does not open a second window), independent of the hourly window: power actions 10/min, reinstall 1 per 5 min, password reset 1 per 10 min, renewal 1 per 10 min, cancellation 5/min, orders 30 per 15 min, credit payments 10/min. Only accepted (2xx) calls consume a cool-down — a refused attempt (wrong image, still building, validation error) does not lock you out of the corrected retry. On this 429 (ACTION_RATE_LIMITED) both a Retry-After header and retry_after_seconds in the body are set.

4. Pagination

List endpoints accept ?page= (from 1) and ?limit= (max 200 — a larger value is silently clamped to 200, never refused; defaults: 20 on /services, /invoices, /proformas, /orders, /account/transactions, 50 on /vps and /dedicated). Some also accept ?status=. Responses carry pagination: { page, limit, total, pages } either inside data (/services, /invoices, /proformas, /orders — /orders omits pages; /account/transactions spells it totalPages) or beside it (/vps, /dedicated). /services rows carry serviceType but no provider; the provider slug is on /vps rows and on GET /services/{id}/actions. /services and /invoices also add a stats object (counts and money totals by status).

4b. Projects — isolating environments inside one account

If you run more than one environment (production and staging, or one per customer) against this account, put them in separate projects. A project is a visibility boundary: a request scoped to one can only ever see, change or destroy that project's resources.

It is NOT a billing boundary. Invoices, proformas, the credit balance and VAT all stay at the account, and you still get one consolidated invoice.

In scope: services (VPS, dedicated, game, plugin), orders, custom images, SSH keys. Not in scope: invoices, proformas, credit, and the products catalogue — all account-wide.

Two ways to scope a request

1. Bind the key. In the client panel, Profile → API Keys → New key → Project. A bound key is confined for its whole life: it cannot see another project, cannot be re-pointed at one (mint a new key instead), and cannot create, rename, delete or move projects. This is the one to give an automated environment. 2. Send a header. An unbound key narrows per request with X-Project-Id: <id|slug> (?project= does the same). Use this when one credential serves several projects.

A bound key that sends X-Project-Id for a *different* project is refused with 403 PROJECT_SCOPE_MISMATCH — never silently ignored, so a script that believes it is talking to staging is never handed production.

An X-Project-Id that matches nothing is 400 UNKNOWN_PROJECT. A filter that is not understood must never widen the result set, so it refuses instead of returning everything.

The default project

Every account has one, named default. It holds everything that was created before you started using projects and anything never assigned to one — so nothing is hidden by the upgrade, and a request scoped to default sees exactly what an unscoped request used to.

Where the project shows up

GET /vps, GET /vps/{id}, GET /dedicated and GET /dedicated/{id} carry it:

{ "id": "…", "hostname": "web-1", "project": { "id": "…", "slug": "staging", "name": "Staging" }, … }

Compare project.id. Do not parse hostnames.

Worked example: prod and staging that cannot touch each other

# once, with an UNBOUND key (or from the panel)
curl -sX POST $BASE/projects -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' -d '{"name":"Production","slug":"prod"}'
curl -sX POST $BASE/projects -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' -d '{"name":"Staging","slug":"staging"}'

# move what already exists (ids from GET /vps)
curl -sX POST $BASE/projects/<staging-id>/move -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' -d '{"services":["<id>","<id>"]}'

# then mint ONE BOUND KEY PER ENVIRONMENT in the panel, and give each
# environment only its own. From here on:
curl -s $BASE/vps -H "Authorization: Bearer $STAGING_KEY"     # staging's VPS. Only staging's.
curl -sX DELETE $BASE/vps/<a-production-id> -H "Authorization: Bearer $STAGING_KEY"
# → 404. Not "forbidden" — from staging's point of view that server does not exist.

That last line is the guarantee. A cleanup routine holding a bound key cannot see, and therefore cannot destroy, anything outside its own project — whatever it decides is an orphan.

Placing new resources

POST /orders, POST /custom-images and POST /ssh-keys accept projectId (an id or a slug) in the body. With a bound key you can omit it — the key decides — and naming a different project is refused. Services created by an order inherit the order's project.

Moving and deleting

- PATCH /services/{id} or PATCH /vps/{id} with {"projectId": "prod"} moves one resource; null returns it to the default project. Billing and the running server are untouched. - POST /projects/{id}/move moves many at once, and reports ids it could not resolve in skipped rather than dropping them quietly. - DELETE /projects/{id} never deletes what the project holds. While it holds anything you get 409 with a count; pass ?reassignTo=<id|slug> to say where its resources should go. Any key bound to a deleted project stops working — it is not widened to the account.

Two things worth knowing

- SSH keys are scoped for management, not for installation. Each project has its own list, and the same public key may be added to several. But at (re)install time a server still receives every key on the account, as it always has — the boundary keeps staging from *deleting* production's key, it does not change which keys reach a machine. - Names are per project. Two projects may each hold a custom image called base and a server called web-1.

5. Resources

VPS (GET /vps, GET /vps/{id})

{
  "id": "36d05681-…", "name": "Smoke VPS", "hostname": "vps1.example.com",
  "status": "active", "kind": "vps", "provider": "native",
  "product": { "id": "…", "name": "VPS 2G" },
  "billing": { "cycle": "monthly", "amount": "5.0000", "currency": "EUR", "next_due_date": "2026-09-21T21:00:00.000Z" },
  "cancellation": null,
  "location": "Bucharest",
  "primary_ip": "203.0.113.10",
  "ip_addresses": [ { "address": "203.0.113.10", "version": 4, "prefix_length": 24, "gateway": null, "subnet_cidr": null, "purpose": "public_ip" } ],
  "instance": { "state": "running", "vcpus": 2, "memory_mb": 2048, "disk_gb": 40, "os": "Debian 12", "rescue_mode": false, "updated_at": "…" },
  "os_installation_status": null,
  "capabilities": { "actions": ["start", "shutdown", "reboot"], "console": true, "reinstall": true, "password_reset": true, "snapshots": false, "shared": false, "frozen": false, "unavailable_reason": null },
  "specs": { "cpu": 2, "ram": "2 GB", "storage": "40 GB" },
  "failure": null,
  "created_at": "…"
}
  • status is the billing state (pending, active, suspended, terminated). instance.state is the machine state (creating, running, stopped, paused, error).
  • provider is native for the platform's own hypervisors, otherwise the plugin slug.
  • failure is null unless status is provisioning_failed, and then carries { "code", "message" } — a stable code to branch on (ip_capacity, storage_capacity, capacity, install_timeout, configuration, name_conflict, provider_unreachable, unknown) and one sentence safe to show a user. The operator's own error text is never returned. capacity / ip_capacity mean another location may work; configuration and name_conflict mean the order itself needs changing; the rest are worth one retry. /services rows and /dedicated rows carry the same field.
  • capabilities (detail only) says which verbs the operator allows for this product and is ENFORCED before the hypervisor is asked: a power verb not in capabilities.actions answers 403 naming the allowed set; reinstall / password_reset / snapshots false makes those routes answer 403. An empty actions list means the operator switched customer power control off for this product.
  • Order of evaluation on an action: scope → account permission → service billing state (suspended/terminated → 403) → capabilities (→ 403) → cool-down (→ 429) → the hypervisor (→ 409 while the instance is still being built / has no libvirt uuid / is terminated, or 503). instance.state is only the last state the hypervisor reported.
  • A VPS whose build has not produced a machine yet — or whose build failed — has instance: null and answers 404 "No VPS instance is linked to this service yet" to every power/reinstall/ password call. That 404 is about the machine, not the service: the service id is valid, keep reading it. Treat 404 as "invalid id" only when GET /vps/{id} itself is 404.
  • instance is null until the machine exists (a freshly ordered or failed service); billing.currency, hostname, location and primary_ip may be null.
  • /vps/{id} is the authoritative address list (it falls back to the hypervisor's NIC data); the panel document at /services/{id} may show an empty vps_ip_addresses for the same VM.
  • A failed build reads status: "provisioning_failed". The reason is not exposed here — the operator sees it; open a ticket.
  • os_installation_status becomes in_progress / completed / failed around a reinstall.
  • billing.amount is what THIS service bills — it can differ from the product's list price in GET /products (legacy pricing, discounts, negotiated rates). Proration uses the service price.
  • Additional fields may appear over time; ignore what you do not know.

Services (GET /services, GET /services/{id})

The panel's own service document, nested as data.service (with liveInfo beside it on /services/{id}). Every service has serviceType (vps, dedicated, game, unknown) and, for native VPS, vps_capabilities (same document as capabilities above). Use GET /services/{id}/actions to learn which verbs a service accepts:

{ "service_id": "…", "kind": "vps", "provider": "native", "actions": ["start", "stop", "reboot", "restart", "status", "stats"], "capabilities": { … } }

Dedicated servers (GET /dedicated, GET /dedicated/{id})

The list carries hardware: { name, manufacturer, model, specs, status } when the machine is bound. The detail adds location, datacenter, provisioning (provision_state, power_state), os_installation and features. Power state lives on GET /dedicated/{id}/power-status, addresses on /ips.

Locations (GET /locations, and locations on GET /products/{id})

The value an order carries as location. Do not guess it, and do not assume the operator's full country list is orderable — a datacentre is listed here only while the product can actually be BUILT there (a node that is online, within the plan's targeting, or a delivery location that is healthy), which is usually fewer places than exist. A VPS datacentre whose every node has stopped taking new servers stays listed with sold_out: true, so it can be shown as sold out rather than silently disappearing.

GET https://my.voxa.host/api/v1/locations                       → data.locations
GET https://my.voxa.host/api/v1/locations?productId=<uuid>      → the same, narrowed (repeatable, or comma-separated, max 50)
→ 200 { "success": true, "data": { "locations": [
          { "value": "romania", "name": "Romania", "id": 1, "flag": "🇷🇴", "country_code": "RO",
            "product_ids": ["…"] },
          { "value": "poland",  "name": "Poland",  "id": null, "flag": "🇵🇱", "shared": true,
            "product_ids": ["…"] } ], "count": 2 } }

GET /products/{id} answers the same question for one product, and adds the money:

→ "locations": [ { "value": "germany", "name": "Germany", …, "surcharge": { "monthly": 6, "setup": 0 } },
                 { "value": "romania", "name": "Romania", …, "surcharge": null } ],
  "location_required": true
  • value is what you send. name is for humans; do not send it.
  • surcharge is per month, added to the item price for the cycle you buy (annual = ×12) and snapshotted onto the service, so every renewal carries it. null = no premium.
  • location_required: true → an order without location is refused 400. false → it is optional and placement chooses.
  • An empty locations beside location_required: true means nothing can be built for that product anywhere right now — ask the operator rather than retrying.
  • A location the product is not sold in is refused 400 LOCATION_UNAVAILABLE, and the message names the accepted values. Re-read this list rather than trying other strings; a value that is merely a real place is not necessarily one this product reaches.
  • sold_out: true means the operator has closed that datacentre to new servers. It is listed so you can show it, but an order for it is refused 409 LOCATION_SOLD_OUT (the message names the locations that are still open). On GET /locations it is true only when the location is sold out for every product that lists it.
  • shared: true marks capacity the operator resells from a partner. It is ordered exactly like any other value.
  • On GET /products/{id} each row also carries available (the catalogue-wide GET /locations does not — availability is per product). A location is listed because the product SELLS there; available: false says a node there cannot take it right now — see the next section.

Availability (GET /products/{id}/availability)

"This location sells the product" and "a node there has room for it" are two different questions. This one answers the second, without creating anything — no order, no service, no charge — so it is safe to poll from a monitoring check.

GET https://my.voxa.host/api/v1/products/<uuid>/availability
→ 200 { "success": true, "data": {
          "available": true, "code": "available", "reason": null,
          "product_id": "…", "location": null, "location_required": true,
          "locations": [ { "value": "romania", "name": "Romania", "available": true,  "reason": null },
                         { "value": "germany", "name": "Germany", "available": false,
                           "reason": "RAM limit reached on 'DE-VIRT1': …" } ] } }

GET https://my.voxa.host/api/v1/products/<uuid>/availability?location=germany
→ 200 { "success": true, "data": { "available": false, "code": "no_capacity",
          "reason": "RAM limit reached on 'DE-VIRT1': …", "location": "germany" } }
  • available is the boolean to alert on. The response is 200 whatever the answer is — an unavailable product is a state, not an error, and a 4xx here could not be told apart from a bad key.
  • code: available · no_capacity (the fleet is full — reason names the node and the limit) · location_unavailable (the product is not sold there at all; the message lists the values that are) · forced_unavailable (the operator is holding the product back) · plan_disabled · no_location · unknown · sold_out (every node the plan can use there has stopped taking new servers — closed by the operator, not a momentary shortage; per location, and for the whole product when every location is sold out). Each locations[] row carries its own code.
  • unknown is not "sold out". It means the check could not be computed; it never refuses an order, and a monitor should treat it as "no signal" rather than as an outage.
  • For a VPS product the check runs the plan's real sizing — vCPU, RAM, disk and addresses — through the same placement rules the provisioner applies (overcommit ratios, host reserves, absolute sell ceilings, free disk, per-node IPv4). For everything else it reports whether the product is sellable anywhere, because stock there belongs to the upstream provider, not to this panel.
  • An order for a product this says no_capacity / forced_unavailable / sold_out about is refused 409 (NO_CAPACITY / PRODUCT_UNAVAILABLE / LOCATION_SOLD_OUT, or SOLD_OUT when no location was named) instead of being taken, invoiced and then failing to provision. Checking first is still worth it: it lets you route the order elsewhere before the customer sees anything.

6. Workflows

Reboot a VPS

POST https://my.voxa.host/api/v1/vps/{id}/reboot
→ 200 { "success": true, "data": { "completed": true, "job_id": "…", "state": "running", "status": "completed" } }
   or  { "success": true, "data": { "completed": false, "pending": true, "job_id": "…" } }   (poll GET /vps/{id})
→ 409 { "code": "CONFLICT" }   e.g. the instance is still being built

stop sends an ACPI shutdown and forces power-off after the grace window. start is refused on a suspended service.

Reinstall a VPS (erases the disk)

GET  https://my.voxa.host/api/v1/vps/{id}/os-images        → data.templates: [ { "id", "name", "is_default" } ]
     (exactly what the reinstall gate accepts: the plan's catalogue, narrowed by the product's linked
      images only when the product links any — a product with no links does not restrict reinstall,
      though it cannot be ordered. An EMPTY list means no image is enabled for this plan — contact support.
      `is_default` here is the plan's default, which can differ from `/products/{id}`.)
POST https://my.voxa.host/api/v1/vps/{id}/reinstall        body { "image_id": "<uuid from the list>", "password": "≥ 8 chars" }
→ 200 { "success": true, "message": "OS reinstall initiated", "data": { "job_id": "…" } }
GET  https://my.voxa.host/api/v1/vps/{id}                  poll os_installation_status until "completed"

Only images from /os-images are accepted; anything else is 404/409.

Reset the root password of a VPS

POST https://my.voxa.host/api/v1/vps/{id}/password         body { "password": "≥ 8 chars" }
→ 200 { "success": true, "message": "Password change started", "data": { "available": true, "job_id": "…", "user": "root" } }
→ 409 if the VM is not running (start it first)

Order a new service and pay it

GET  https://my.voxa.host/api/v1/products                  → data.products (id, name, monthly_price, hourly_price, …)
GET  https://my.voxa.host/api/v1/products/{id}             → billing_cycles, options, os_images (ONLY images the order
                                             gate accepts, each "orderable": true), default_os_image_id,
                                             os_images_all (includes linked-but-not-orderable ones),
                                             locations, location_required
POST https://my.voxa.host/api/v1/orders
     { "productId": "…", "billingCycle": "monthly", "hostname": "srv1.example.com",
       "location": "<locations[].value, when location_required>",
       "configOptions": { "os_image_id": "<default_os_image_id or any os_images[].id>",
                          "user_data": "#cloud-config\n…",  /* VPS: first-boot script or cloud-config, see below */
                          "ssh_keys": ["ssh-ed25519 AAAA… you@laptop"],  /* VPS: authorised for root on first boot */
                          "ssh_key_ids": ["<id from GET /ssh-keys>"] },  /* VPS: keys already on the account */
       "items": [ … ]   /* optional multi-item form; each item may carry "options": [ { "optionId", "quantity" } ] */ }
→ 201 { "success": true, "data": {
          "order": { "id", "order_number", "status", "payment_status", "items": [ … ] },
          "services": [ { "id", "status": "pending_payment" } ],   /* "pending" once paid, then installing → active */
          "invoice": { … } | null, "proforma": { … } | null,          /* the row as built — may lag settlement */
          "document": { "type": "proforma", "id", "status", "total", "settled": false } | null,
          "settled": false, "next_step": "Pay it: POST /proformas/{id}/pay", "credit_balance": 25.5 } }

Branch on settled, not on the nested rows. When the operator has enabled auto-credit for the account, the document is paid inside the same request: settled: true, document.status is paid (invoice) or converted (proforma), and the nested proforma/invoice/services objects may still read unpaid/pending_payment because they were serialised before the settlement. When auto-credit settled it, the nested proforma / invoice row also carries autoCreditApplied ({ creditApplied, newBalance, remainingAmount, proformaPaid, invoice }) — the invoice inside it is the document the proforma converted into (converted_to_invoice_id on the nested row still reads null; re-read GET /proformas/{id} for the settled row — its invoice_id names the conversion invoice when the platform recorded one; on some settlement paths it stays null, so keep the invoice from the response that settled it). Paying a settled document again answers 400. A VPS product with no orderable image cannot be ordered (os_images empty → ask the operator).

Otherwise exactly one of invoice / proforma is set (the operator's document type). Pay it:

POST https://my.voxa.host/api/v1/invoices/{id}/pay       body {} or { "amount": 12.5 }
→ 200 { "success": true, "message": "Invoice paid in full with credit",   /* partial: "Credit applied to invoice" */
        "data": { "payment": { … }, "newBalance": 20.5, "remainingAmount": 0, "invoicePaid": true } }
POST https://my.voxa.host/api/v1/proformas/{id}/pay      body {} or { "amount": 12.5 }
→ 200 { "success": true, "message": "Proforma paid successfully with credit",
        "data": { "creditApplied": 5.95, "newBalance": 19.55, "remainingAmount": 0, "proformaPaid": true,
                  "invoice": { "invoice_number": "INV-…", "status": "paid" } } }   /* the invoice the proforma converted into */
→ 400 "Insufficient credit balance" | "Amount exceeds the outstanding … balance" | "cannot be paid (status: converted)"

A paid proforma converts into an invoice; GET /proformas/{id} and GET /invoices/{id} report paid_amount, remaining_amount and settled beside the document — poll the detail to learn whether a document has been paid (by you, by auto-credit, or by the operator). List rows (/proformas, /invoices) carry only status and total. next_step in create responses is prose for humans; branch on settled and document.status, never on the text.

Provisioning starts once the document is paid; watch GET /orders/{id} → data.order.services[].status (pending → installing → active, or provisioning_failed). Note the shape: GET /orders/{id} returns { "order": { …, "items": [ … ], "services": [ … ] } } with items and services nested INSIDE the order, unlike the create response above.

Hourly products need the operator's minimum credit balance and are charged from credit without a document.

Run your own script on a VPS at first boot

configOptions.user_data is the same idea as user-data on any cloud: it runs once, the first time the machine boots, and it is re-applied by every later reinstall of that service. Two shapes are accepted and the first two characters decide which:

- a script starting #! — written to /var/lib/fluxbilling/order-user-data.sh, run once, output in /var/log/fluxbilling-order-user-data.log - a document starting #cloud-config — MERGED into the one the platform builds. Lists (runcmd, packages, write_files, users) are appended AFTER the platform's own entries; any other key the platform already sets (the root password, sshd, the network) keeps the platform's value.

Up to 64 KB. Refused at order time, so a malformed script is a 400 before anything is charged, never a broken machine afterwards. Not available on Windows images, where there is no cloud-init to read it.

configOptions.ssh_keys takes OpenSSH public key lines directly, and configOptions.ssh_key_ids takes ids from GET /ssh-keys so keys already on the account work here too. Both authorise root on first boot and both survive a reinstall.

Take a snapshot, and restore it

A snapshot is a point-in-time copy of the VPS's DISK, kept on the same node as the machine. It is the thing to take before an upgrade, a kernel change or anything you might need to undo — and it is not a backup: it lives on the same hardware as the server it was taken from, so it survives a bad deploy, not a dead node.

POST https://my.voxa.host/api/v1/vps/{id}/snapshots       body { "name": "before-upgrade", "description": "adding the queue worker" }
→ 201 { "success": true, "data": { "snapshot": { "id", "name", "status": "creating" }, "pending": true } }

GET  https://my.voxa.host/api/v1/vps/{id}/snapshots       poll until the row leaves "creating"
→ 200 { "success": true, "data": { "snapshots": [ … ], "used": 2, "max": 5 } }

POST https://my.voxa.host/api/v1/vps/{id}/snapshots/{snapshotId}/revert   body { "confirm": "before-upgrade" }
DELETE https://my.voxa.host/api/v1/vps/{id}/snapshots/{snapshotId}

Before you offer the button, read the capability rather than guessing: GET /vps/{id} → capabilities.snapshots. False means the operator has not switched snapshots on for this platform, or not for this product — every snapshot route then answers 403.

A row looks like this, and every field on it is there to be acted on:

{
  "id": "0e0f…", "name": "before-upgrade", "description": "adding the queue worker",
  "status": "available",          // creating · available · reverting · deleting · error
  "size_bytes": 2147483648,       // what it costs on the host NOW; it grows as the disk diverges
  "has_memory": false,            // always false for snapshots you take through the API
  "restorable": true,             // false while busy, failed, or missing from the host
  "in_progress": false,           // true while a create/revert/delete is running
  "crash_consistent": false,      // true = the guest could not be frozen; null = the host did not say
  "destroys_on_restore": ["nightly-2"],  // these die if you restore THIS one
  "comes_back_running": true,     // whether the VPS is up again when the restore finishes
  "created_at": "2026-09-19T08:12:00Z", "completed_at": "2026-09-19T08:12:40Z"
}

used and max are the same numbers the create call refuses on, so check them rather than discovering the 409. max: 0 means the plan does not include snapshots at all.

Polling. Create, restore and delete are all asynchronous and answer immediately; the row carries the state. Poll GET /vps/{id}/snapshots every few seconds — in_progress: false is the finish line, status: "error" carries the reason, and a deleted snapshot simply stops being listed. There is no webhook for these today.

The refusals, and what to do about each:

codemeaningwhat to do
403snapshots are not offered on this VPSread capabilities.snapshots first; nothing to retry
403the service is suspended or terminatedsettle the service; the list is refused too
400the name is not [A-Za-z0-9][A-Za-z0-9._-]{0,119}, or starts with a reserved prefixfix the name
400confirm missing or wrong on a restoresend the snapshot's own name; the message lists what would be lost
409you already hold max snapshotsdelete one first
409a snapshot of that name already exists on this VPSpick another name
409another disk job is running on this VM (build, reinstall, resize, migration, another snapshot)retry when it finishes — they are serialised on purpose
409the snapshot is not available, or was not on the host at the last readre-list; ask the operator to re-read the host if it stays missing

The name is the confirmation. A restore is not reversible, so confirm has to carry the snapshot's own name, typed back. A request without it is refused 400 and the message spells out what would be lost.

What a restore costs you, beyond the data. Everything written since the snapshot was taken is discarded — and so is every NEWER snapshot, because rolling a disk back cannot leave a later restore point standing. Each row lists exactly which ones in destroys_on_restore, so you can show that before asking anyone to confirm. The VPS is stopped for the restore and started again if it was running (comes_back_running).

crash_consistent is the field worth reading. The platform freezes the guest filesystems through the QEMU guest agent while it takes the snapshot. With no agent answering, it cannot, and the snapshot restores like a machine that lost power: it boots, but anything mid-write was mid-write. Install and start qemu-guest-agent in the guest and the field reads false.

Snapshots taken here are DISK ONLY — guest RAM is never captured on this path. The list is the platform's record of what the hypervisor holds; a snapshot created outside it appears once an operator re-reads the host.

A worked sequence, the one most integrations want — snapshot, change something, roll back if it went wrong:

ID=<vps id>; API=https://my.voxa.host/api/v1; AUTH="Authorization: Bearer $FLUX_API_KEY"

# 1. is it offered here at all, and is there room?
curl -s -H "$AUTH" "$API/vps/$ID" | jq '.data.capabilities.snapshots'
curl -s -H "$AUTH" "$API/vps/$ID/snapshots" | jq '{used: .data.used, max: .data.max}'

# 2. take one, then wait for it
curl -s -X POST -H "$AUTH" -H 'content-type: application/json' \
     -d '{"name":"before-upgrade","description":"queue worker"}' "$API/vps/$ID/snapshots"
until curl -s -H "$AUTH" "$API/vps/$ID/snapshots" \
      | jq -e '.data.snapshots[] | select(.name=="before-upgrade") | .in_progress | not' >/dev/null
do sleep 5; done

# 3. …and if the change went badly, roll it back (this DISCARDS everything since)
SNAP=$(curl -s -H "$AUTH" "$API/vps/$ID/snapshots" \
        | jq -r '.data.snapshots[] | select(.name=="before-upgrade") | .id')
curl -s -X POST -H "$AUTH" -H 'content-type: application/json' \
     -d '{"confirm":"before-upgrade"}' "$API/vps/$ID/snapshots/$SNAP/revert"

Deploy a VPS from your own image

Bake the slow parts of your first boot into a disk image once, and every later machine starts with them already in place. This takes a DISK IMAGE (qcow2 or raw), not an installer ISO: an ISO would run a full OS install on every deploy, an image is the finished disk and installs nothing.

POST https://my.voxa.host/api/v1/custom-images
     { "name": "app base", "sourceUrl": "https://images.example.com/app_base_v4.qcow2",
       "sha256": "<64 hex>", "osFamily": "linux" }        /* osFamily optional, defaults to linux */
→ 201 { "success": true, "data": { "id", "name", "source_url", "source_sha256", "os_family", … } }

POST https://my.voxa.host/api/v1/orders
     { "productId": "…", "billingCycle": "hourly",
       "configOptions": { "custom_image_id": "<id from the call above>" } }

custom_image_id REPLACES os_image_id — send one or the other, never both (400). It is not restricted to the product's OS list, because the image is yours rather than part of the catalogue, and it composes with user_data above: put the slow work in the image and the per-machine bits in user-data. GET /custom-images lists yours, DELETE /custom-images/{id} removes one (machines already built from it are untouched).

Two rules, both refusals rather than warnings:

- sha256 is required. The hypervisor verifies the download against it and discards anything that does not match, which is what makes accepting an image we did not build safe. Re-publishing different bytes at the same URL therefore needs a NEW entry with the new digest — put a version in the filename. - The host must be publicly reachable over https. URLs carrying credentials are refused, and a host that resolves to a private address is refused both when you register it and again when you order. Presigned URLs work, but they expire, and the image stops deploying when they do.

After the first build in a location the node caches the image, so later builds there skip the download.

Capture a VPS as an image, and deploy from it

The other way round from the section above: instead of hosting an image yourself, configure a VPS once and have the platform capture it. Nothing leaves the platform, there is no URL to publish and no digest to track.

POST https://my.voxa.host/api/v1/vps/{id}/capture      body { "name": "app base", "description": "docker + agent" }
→ 202 { "success": true, "data": { "id", "name", "source_kind": "capture", "status": "capturing" } }

GET  https://my.voxa.host/api/v1/custom-images/{id}    poll until status leaves "capturing"
→ 200 { "success": true, "data": { "status": "available", "size_bytes": 6442450944,
                                   "virtual_size_bytes": 42949672960, "available_at": "…" } }

POST https://my.voxa.host/api/v1/orders
     { "productId": "…", "billingCycle": "hourly",
       "configOptions": { "custom_image_id": "<the image id>" } }

The VM KEEPS RUNNING while it is captured: the read is taken from a snapshot, not from the live disk. If the guest is running and has the QEMU guest agent, its filesystems are frozen for the instant the snapshot is taken, so the image is filesystem-consistent; without the agent it is crash-consistent, which boots but captures anything mid-write as it was. Only the BOOT disk becomes the image.

Before you offer the button: GET /vps/{id} → capabilities.images. False means capture is not switched on for this platform or this product, and every capture route answers 403. The same payload carries the price when the operator has set one, so you can show what an image will cost before anyone presses anything.

Statuses are the whole contract. capturing means the image does not exist yet and ordering from it is refused (409) rather than queued. available means it is deployable. error carries the reason in error, and the image occupies none of your quota once you delete it.

Polling. The capture answers 202 and runs for as long as the disk takes — minutes, not seconds. Poll GET /custom-images/{id} every 15-30 s; status leaving capturing is the finish line either way.

The refusals, and what to do about each:

codemeaningwhat to do
403capture is not offered on this platform or productread capabilities.images; nothing to retry
403the service is suspended or terminatedsettle the service first
409you already hold the maximum number of imagesdelete one, then retry
409the VPS is building, migrating or being deletedretry when it is running or stopped
409ordering from an image still capturingpoll until available
409the plan's disk is smaller than virtual_size_bytesorder a plan at least that size
409the node holding the image cannot take another serverthe image is pinned to ONE node and it is full — capture it again from a VPS elsewhere, or order against that copy
400custom_image_id sent together with os_image_idsend one or the other

virtual_size_bytes is the disk the image needs. Deploying it onto a plan with a smaller disk is refused at order time, so check it before ordering a smaller plan than the VPS you captured.

An image is a file on ONE node, and it is never copied between them. Every server you order from it is therefore built on that same node, and an order is refused (409) when that node cannot take another server — with the reason. If you are building a fleet from one image and start seeing that refusal, the node is full: capture the image again from a VPS in another location and order against whichever copy has room.

The copy is the whole disk. SSH host keys, authorized_keys, logs, shell history, anything saved on it — all of it lands on every server built from the image. Before capturing, remove what should not be there, and consider cloud-init clean --logs plus truncating /etc/machine-id so each new server gets its own identity rather than a copy of this one's.

Storage is billed, per GB of virtual_size_bytes per month — the SERVER DISK the image represents, not the compressed file. A 40 GB VPS is billed as 40 GB however well it compressed, because that is the disk it can be deployed onto. size_bytes still reports what the file actually occupies. Whether capture is offered at all, how many images you may keep and what it costs are the operator's settings.

The charge is prorated by the day and collected once a month, in whatever document the operator's billing is configured to produce — an invoice, a proforma, or a line on your monthly summary. Deleting an image stops the charge and settles the days it was stored, so leaving mid-month costs you those days and nothing more.

Retrying an order safely (idempotency)

POST /orders is the one call that can cost money twice. Send your own key with it:

POST https://my.voxa.host/api/v1/orders
Idempotency-Key: 7f1c2c6e-checkout-4821          (or "idempotencyKey": "…" in the body)
     { "productId": "…", "billingCycle": "monthly", "hostname": "srv1.example.com" }

→ 201 { "data": { "order": { "id": "…", "idempotency_key": "7f1c2c6e-checkout-4821", … }, … } }   first call
→ 200 { "data": { "order": { "id": "…same order…", … }, "idempotent": true },
        "message": "Order already exists for this idempotencyKey" }                                any replay
  • 201 means created, 200 means replayed. Branch on the status code, or on data.idempotent.
  • The key is stored on the order row, written by the same statement that creates it — so the key exists if and only if the order does. A create that died before that statement leaves nothing behind and your retry proceeds normally; one that died after it is found by the retry.
  • One key = one order, for the life of that order. Keys are never expired or recycled; a replay with a different body still returns the FIRST order, so use a new key for a new order.
  • Two concurrent calls with one key are safe: one creates, the other gets the 200 replay.
  • A replay returns the order as it is NOW, with fresh document / settled — so it doubles as "did it get paid?". It does not re-send the original response byte for byte.
  • No key? Then re-read before retrying: GET /orders?hostname=srv1.example.com matches the order, its lines and any service built from it (case-insensitive), and GET /orders?idempotencyKey=… finds one by key. An empty orders array means nothing landed and the call is safe to repeat.
  • Everything else money-moving (/pay, /options, /upgrades) still follows the re-read rule in section 2 — the key is accepted on /orders only.

Buy an add-on for an existing service

GET  https://my.voxa.host/api/v1/services/{id}/options/available   → options with prices; "held": true if already active
POST https://my.voxa.host/api/v1/services/{id}/options              { "optionId": "…", "billingType": "recurring", "billingCycle": "monthly", "quantity": 1 }
→ 201 { "success": true, "data": { "serviceOption": { "id", "status": "pending_payment", … },
                                    "invoice"|"proforma": { … },
                                    "document": { … , "settled": true|false }, "settled": true|false, "next_step": "…" } }

If settled is false, pay the document as above. Either way the add-on becomes active shortly after payment (poll GET /services/{id}/options; /options/available then shows held: true). The recurring price is serviceOption.amount (price/unit_price are unused). Cancel with DELETE /services/{id}/options/{optionId} (destructive scope) where {optionId} is the catalogue option id from /options/available (serviceOption.option_id), not the serviceOption.id row. Cancellation is immediate: the DELETE response shows the row as cancelled with today's termination_date, and the add-on then disappears from GET /services/{id}/options (that list holds live add-ons only).

Upgrade a service to a bigger plan

GET  https://my.voxa.host/api/v1/services/{id}/upgrades
→ { "service": { "id", "productName", "amount", "billingCycle", "nextDueDate" },
    "upgrades": [ { "toProductId", "toProductName", "toSpecs", "newCyclePrice", "upgradeFee",
                    "creditAmount", "chargeAmount", "netAmount", "vatRate", "vatAmount", "totalWithVat",
                    "daysRemaining", "daysInCycle", "billingCycle" } ],
    "pendingUpgrade": null | { "id", "status", … } }
POST https://my.voxa.host/api/v1/services/{id}/upgrades   { "toProductId": "…" }
→ 201 { "success": true, "data": { "upgrade": { "id", "status": "awaiting_payment" },
                                    "billingDocument": { "id", … } | null, "billingDocumentType": "invoice" | "proforma" | null,
                                    "lineItems": [ … ], "orderId": "…" } }

Pay billingDocument with the matching /invoices/{id}/pay or /proformas/{id}/pay; the upgrade then executes: GET /upgrades/{id} → status goes awaiting_payment → executing (seconds to a minute) → completed (executed_at set), after which the service's product and billing.amount reflect the new plan. A null document means nothing was owed and the upgrade ran immediately. Credit is never applied automatically to upgrades. An upgrade is also recorded as an order (orderId; GET /upgrades/{id} returns it as data.order) and appears in GET /orders with order_type: "upgrade".

Side effects to expect after an upgrade completes: any open (unpaid) invoice of the service is re-priced to the new plan and re-taxed at the current VAT rate (its total changes in place; re-read invoices you cached before the upgrade); and the service now follows the NEW product's catalogue — /services/{id}/options/available reflects the new product, which may offer fewer (or no) add-ons; /vps/{id}/os-images follows the plan (see the reinstall workflow). GET /upgrades/{id} returns the order it created as data.order (order_type: "upgrade"). Add or remove add-ons in one prorated change with POST /services/{id}/upgrades/options { "addOptions": ["<optionId>"], "removeOptions": ["<optionId>"] } (the two sets must be disjoint and addOptions must not name an add-on already held → 400). Both upgrade requests answer 201 with the upgrade shape { upgrade, billingDocument, billingDocumentType, lineItems } plus the same top-level document / settled / next_step / credit_balance summary orders and add-on purchases carry, so one rule applies everywhere. A change that nets to zero or to a credit (a downgrade, a removal) has billingDocument: null, upgrade.status: "completed" and settled: true — it executed immediately, so POST /upgrades/{id}/cancel answers 400 "Only pending upgrades can be cancelled"; the same happens when auto-credit settles the document inside the request. A removal's credit_amount is the prorated value of what was removed; it offsets charges in the SAME change and is NOT credited to the balance on its own.

Two ways to add an add-on, two prices: POST /services/{id}/options charges the full cycle price and starts the add-on's own cycle today (its next_due_date differs from the service's); POST /services/{id}/upgrades/options prorates to the service's next_due_date and keeps the two aligned. Prefer the second on an existing service.

Cancel a service

POST https://my.voxa.host/api/v1/services/{id}/cancel     { "reason": "moving elsewhere", "immediate": false }
→ 200 { "data": { "cancellation_scheduled_date": "2026-09-21T…" } }       (end of the paid period)
POST https://my.voxa.host/api/v1/services/{id}/cancel     { "reason": "…", "immediate": true }
→ 200 { "data": { "immediate": true } }                                    (terminated now; VPS disks are destroyed)
DELETE https://my.voxa.host/api/v1/services/{id}/cancel                                     (withdraw a scheduled cancellation)

DELETE /vps/{id} is the same operation for a VPS.

Cancellation is accepted for an active service and for one whose build failed (provisioning_failed). The failed one is always terminated immediately — even with immediate: false — because nothing was ever delivered to run out the period; the response says so and the service's unpaid invoice or proforma is cancelled with it. Every other status answers 400: pending / installing are mid-build (wait for them to settle either way), cancelled / terminated are already gone, and a suspended service has to be taken up with the operator — its unpaid balance is not cleared by a cancellation.

Read the balance and spend

GET https://my.voxa.host/api/v1/account/balance   → { "balance": 20.5, "currency": "EUR" }
GET https://my.voxa.host/api/v1/account/usage     → { "services": { "total", "by_status", "active_recurring_amount" },
                                     "invoices": { "unpaid_count", "unpaid_total", "paid_this_month" },
                                     "hourly": { "charged_this_month", "charges" }, "credit_balance", "period": { "month" } }
GET https://my.voxa.host/api/v1/invoices?status=unpaid

Plugin-provisioned services (cPanel, Virtualizor, etc.)

POST /services/{id}/actions/{action} accepts start, stop, restart, status, stats, and any verb the provider plugin exposes to customers in the client panel (for example backups.list). Send verb parameters as { "params": { … } }. A verb the provider does not expose answers 403 ACTION_NOT_ALLOWED; one it does not implement answers 400 ACTION_UNKNOWN. A provider flow that REPORTS failure is a 503 PROVIDER_ERROR; a read flow that tolerates an unreachable provider (e.g. status answering "unknown" everywhere, backups.list answering []) is a 200 — treat "unknown" as "not reachable". No document lists a plugin's own verbs: GET /services/{id}/actions names the built-ins only; the provider-specific ones are whatever its client panel form offers. On a native VPS the built-in verbs are handled by the platform (backups.list answers { "backups": [], "available": false }).

6b. Every endpoint, one line each

MethodPathScopePurpose
GET/readidentity, key, scopes, links
GET/account, /account/balance, /account/usage, /account/transactionsreadprofile, balance, monthly usage, credit ledger
GET / POST/projectsread / serviceslist projects (with resource counts); create one
GET / PATCH / DELETE/projects/{id}read / services / destructiveone project; rename; delete (refuses while non-empty — pass ?reassignTo=)
POST/projects/{id}/moveservicesmove services / orders / custom_images / ssh_keys into it
PATCH/services/{id}, /vps/{id}servicesmove ONE resource between projects ({"projectId": "…"})
GET/services, /services/{id}, /services/{id}/actions, /services/{id}/statsreadany service
POST/services/{id}/actions/{action}, /services/{id}/power/{action}servicesstart / stop / restart / status / stats / plugin verbs
POST/services/{id}/reinstall, /services/{id}/passwordservicesany provider (native VPS or plugin)
GET / POST/services/{id}/backups, POST …/backups/{backupId}/restoreread / servicesprovider backups (plugin services)
POST / DELETE/services/{id}/canceldestructive / servicesrequest or withdraw cancellation
POST/services/{id}/renewbillingissue the next renewal document now (active, non-hourly, none open)
GET / POST/services/{id}/game, `…/game/power/{startstoprestartkill}`read / servicesnative game servers
GET/vps, /vps/{id}, /vps/{id}/ips, /vps/{id}/stats, /vps/{id}/os-imagesreadVPS collection
POST`/vps/{id}/startstopshutdownrebootrestart, /vps/{id}/power/{action}`servicespower (stop = shutdown; restart = reboot)
POST/vps/{id}/reinstall, /vps/{id}/passwordservicessee workflows
GET / POST / DELETE/vps/{id}/snapshots, …/{snapshotId}, POST …/{snapshotId}/revertread / servicesonly if the operator enabled snapshots
DELETE/vps/{id}destructivecancel (immediate:true destroys the disk)
GET/dedicated, /dedicated/{id}, …/ips, …/power-status, …/os-images, …/stats, …/traffic, …/provision-statusreadbare metal
POST/dedicated/{id}/power `{action: onoffrestart}, /dedicated/{id}/power/{action}`servicesBMC power (graceful off/restart)
POST/dedicated/{id}/reinstall {osImageId}, /dedicated/{id}/passwordservicesboth ERASE DATA on provisioning-managed servers
GET / POST / DELETE/services/{id}/options, …/options/available, …/options/{optionId}read / orders / destructiveadd-ons
GET / POST/services/{id}/upgrades, …/upgrades/options; GET /upgrades/{id}; POST /upgrades/{id}/cancelread / ordersplan upgrades
GET/locationsreaddatacentres the catalogue sells in (?productId= to narrow)
GET/products/{id}/availabilityreadcan it be built right now (?location= for one datacentre); creates nothing, safe to poll
GET / POST/products, /products/{id}; /orders (?hostname=, ?idempotencyKey=), /orders/{id}read / orderscatalogue and orders; POST takes Idempotency-Key
GET / POST/invoices, /invoices/{id}, /invoices/{id}/pay; /proformas, /proformas/{id}, /proformas/{id}/payread / billingdocuments and credit payment
GET / POST / DELETE/custom-images, /custom-images/{id}read / servicesyour own VPS base images. POST body { "name", "sourceUrl", "sha256", "osFamily"? } (source_url / source_sha256 accepted as aliases)
POST/vps/{id}/captureservicescapture this VPS into a reusable image (202, then poll the image's status). Billed per GB stored per month
GET / POST / DELETE/ssh-keys, /ssh-keys/{id}read / servicesaccount SSH keys (installed on bare-metal reinstalls). POST body { "name", "publicKey": "ssh-ed25519 AAAA… comment" } (public_key accepted as alias); response carries public_key (comment stripped) and fingerprint
GET/openapi.json, /guide.mdnonethis documentation

7. Good practice for scripts and agents

  • Read GET /services/{id}/actions or the VPS capabilities before acting; do not assume.
  • Power and reinstall calls are asynchronous: a job_id means "accepted", not "done". Poll the resource; do not repeat the call (the cool-down will refuse it anyway).
  • Never retry a 4xx blindly. Retry 5xx and 429 after Retry-After — but on money-moving calls re-read the resource first (section 2), because a 5xx may have applied.
  • Send an Idempotency-Key on every POST /orders. It costs nothing when the call succeeds and it is the only thing that makes a timed-out create safe to repeat.
  • On a tenant without a live hypervisor every power/reinstall verb answers 409; cool-downs count only accepted calls, so they are not observable there.
  • Destructive verbs (immediate: true, reinstall, dedicated password reset on managed servers) erase data. Confirm with a human unless explicitly authorised.
  • Keep the key out of URLs and logs; rotate it from the panel if it leaks.

Endpoint index

Every operation, grouped as in the OpenAPI document. The badge on the right is the scope a key needs.

Account

Identity, credit balance, usage summary, credit ledger, and the documentation itself

GET/API index: identity, key, scopes, linksread
GET/openapi.jsonThis document (no authentication required)none
GET/docsHuman-readable documentation page (this guide rendered, plus an endpoint index) — no authentication requirednone
GET/guide.mdUsage guide in Markdown: authentication, scopes, envelope, error codes, rate limits, pagination, resource shapes and step-by-step workflows with examples (no authentication required)none
GET/accountProfile, account context and credit balanceread
GET/account/balanceCredit balanceread
GET/account/usageService counts, spend this month, unpaid invoicesread
GET/account/transactionsCredit ledgerread

Services

Every service regardless of type: detail, generic actions, stats, cancellation, renewal, provider backups

GET/servicesAll services with serviceTyperead
GET/services/{id}Service detail (VPS capabilities and IPs included)read
GET/services/{id}/actionsActions supported by this serviceread
POST/services/{id}/actions/{action}Run an action: start, stop, restart, status, stats — or, on a provider-plugin service, any verb the provider exposes to customers in the client panel (see GET /services/{id}/actions). Reinstall and password reset have their own routes.services
GET/services/{id}/statsUsage statisticsread
POST/services/{id}/power/{action}Power action by name (any provider)services
POST/services/{id}/reinstallReinstall the operating system (native VPS or provider plugin)services
POST/services/{id}/passwordReset the root password (native VPS or provider plugin)services
GET/services/{id}/backupsList provider backupsread
POST/services/{id}/backupsCreate a provider backupservices
POST/services/{id}/backups/{backupId}/restoreRestore a provider backupservices
POST/services/{id}/cancelRequest cancellation: at the end of the paid period by default; immediate:true terminates now (VPS disks are destroyed). Allowed on an `active` service and on one left in `provisioning_failed` — the latter always terminates immediately, whatever `immediate` says, because no period was ever delivered. Any other status answers 400destructive
DELETE/services/{id}/cancelWithdraw a pending cancellationservices
POST/services/{id}/renewIssue the next renewal document now (invoice or proforma, per tenant). Active, non-hourly services only; refused while one is already awaiting payment. 1 per 10 minutes.billing

VPS

Virtual servers (native hypervisors or provider plugins): state, addresses, power, reinstall, password, snapshots

GET/vpsList VPS instances with state and addressesread
GET/vps/{id}VPS detail: instance, IPs, capabilities, specsread
DELETE/vps/{id}Cancel the VPS: at the end of the paid period by default; immediate:true terminates now and DESTROYS THE DISKdestructive
GET/vps/{id}/ipsIP addressesread
GET/vps/{id}/statsCPU, memory, disk and network seriesread
GET/vps/{id}/os-imagesImages this service may reinstall — exactly what the reinstall gate accepts (data.templates[]): the plan catalogue, narrowed by the product's linked images WHEN the product links any (a product with no links does not restrict reinstall, although it cannot be ORDERED). An empty list means no image is enabled for this plan — contact support.read
POST/vps/{id}/startPower on (refused on a suspended service; 404 "No VPS instance is linked" while the build has not produced a machine yet or failed)services
POST/vps/{id}/stopShut down: ACPI shutdown, forced power-off after the grace window (native VPS); provider stop (plugin VPS)services
POST/vps/{id}/shutdownSame as /stopservices
POST/vps/{id}/rebootRebootservices
POST/vps/{id}/restartReboot (alias)services
POST/vps/{id}/power/{action}Power action by nameservices
POST/vps/{id}/reinstallReinstall the operating system. ALL DATA ON THE DISK IS ERASED. Native VPS: a catalogue image id and a new root password are required; the job id is returned and progress is visible on GET /vps/{id} (os_installation_status). 1 per 5 minutes.services
POST/vps/{id}/passwordSet a new root/administrator password. Native VPS: applied through the guest agent, so the VM must be RUNNING (a stopped VM is refused with 409 — start it first); the job id is returned. Plugin VPS: forwarded to the provider. 1 per 10 minutes.services
GET/vps/{id}/snapshotsList snapshots, with the cap. `data.snapshots[]` plus `used` and `max` — the same numbers the create call refuses on, so a client can grey out its own button instead of discovering the 409. The list is this platform's record of what the hypervisor holds; a snapshot taken outside the panel appears after an operator re-reads the host.read
POST/vps/{id}/snapshotsTake a snapshot of the disk. Returns 201 immediately with the row in `creating`; poll the list until it leaves that state. DISK ONLY — guest RAM is never captured on this path, so a restore always comes back as if the machine had been powered off at that instant. Refusals: 403 snapshots not offered on this VPS, 409 at the cap / a name already in use / another disk job running on this VM, 400 a name outside `[A-Za-z0-9][A-Za-z0-9._-]{0,119}` or starting with a platform-reserved prefix.services
DELETE/vps/{id}/snapshots/{snapshotId}Delete a snapshot and free the disk it pins. Asynchronous: the row goes to `deleting` and disappears once the hypervisor confirms. The VPS and its current data are untouched.services
POST/vps/{id}/snapshots/{snapshotId}/revertRestore the VPS to this snapshot. DESTRUCTIVE AND NOT REVERSIBLE: everything written to the disk since the snapshot was taken is discarded, and every NEWER snapshot is destroyed with it (see `destroys_on_restore`). `confirm` must be the SNAPSHOT's own name — a request without it is refused 400 and the message lists exactly what would be lost. The VPS is stopped for the restore and started again if it was running.services

Dedicated

Bare-metal servers: hardware, addresses, BMC power, reinstall, password, statistics, traffic

GET/dedicatedList dedicated serversread
GET/dedicated/{id}Hardware, location, provisioning and OS stateread
GET/dedicated/{id}/ipsSubnets and IP addressesread
GET/dedicated/{id}/power-statusPower stateread
POST/dedicated/{id}/powerPower on / graceful shutdown / graceful restart over the BMC (Redfish). 10 per minute.services
POST/dedicated/{id}/power/{action}Power action by name (on, off, restart; start/stop/reboot accepted)services
GET/dedicated/{id}/provision-statusProvisioning / install progressread
GET/dedicated/{id}/os-imagesImages available for reinstallread
POST/dedicated/{id}/reinstallReinstall the operating system. ALL DATA IS ERASED. Your account SSH keys are installed. Refused while an installation is in progress. 1 per 5 minutes.services
POST/dedicated/{id}/passwordSet a new root password. On a provisioning-managed (Ironic) server this REBUILDS the server with its current image — ALL DATA IS ERASED; on other servers only the stored credential changes. 1 per 10 minutes.services
GET/dedicated/{id}/statsHardware statisticsread
GET/dedicated/{id}/trafficTraffic usageread

Game

Native game servers: document and power control

GET/services/{id}/gameGame server documentread
POST/services/{id}/game/power/{action}Game server power (start, stop, restart, kill)services

Add-ons & Upgrades

Add-ons on an existing service and plan upgrades; each creates a billing document that must be paid before it takes effect

GET/services/{id}/optionsAdd-ons active on the serviceread
POST/services/{id}/optionsBuy an add-on for the service. Creates an invoice or proforma and returns `document`/`settled`/`next_step`; pay the document unless `settled` is true. The add-on activates (status active) once the document is paid — poll GET /services/{id}/options.orders
GET/services/{id}/options/availableAdd-ons the product offers, with what is already heldread
DELETE/services/{id}/options/{optionId}Cancel an add-on immediately (optionId = the catalogue option id from /options/available, not the service-option row id)destructive
GET/services/{id}/upgradesUpgrade paths with prorated previews, and any pending upgraderead
POST/services/{id}/upgradesRequest a product upgrade. Returns the upgrade (status awaiting_payment → executing → completed), its prorated billing document (null when nothing is owed) and `orderId` (an upgrade is recorded as an order). Pay the document via /invoices/{id}/pay or /proformas/{id}/pay; credit is never applied automatically to upgrades.orders
POST/services/{id}/upgrades/optionsAdd and/or remove add-ons as one prorated changeorders
GET/upgrades/{upgradeId}Upgrade request: data.upgrade (status awaiting_payment → executing → completed, executed_at) and data.order (the order it created, or null)read
POST/upgrades/{upgradeId}/cancelCancel a pending upgrade requestorders

Products & Orders

The orderable catalogue, the datacentres it sells in, and new orders

GET/locationsEvery datacentre the catalogue can currently be ordered in, with the `value` to send as an order's `location`, and `product_ids` naming the products that reach it. A location appears only while something can actually be built there, so this is shorter than the operator's location list. `?productId=<uuid>` (repeatable, or comma-separated, max 50) narrows it to those products.read
GET/productsOrderable products with prices per cycleread
GET/products/{id}Product with billing cycles, add-on options, `os_images` limited to images the order gate accepts (each carries `orderable`; `os_images_all` includes the rest; `default_os_image_id` is a safe choice), and `locations` — the datacentres this product can actually be built in, each with the `value` to send as the order's `location` and any per-location `surcharge`. `location_required` says whether the order will be refused without one; an empty `locations` beside `location_required: true` means the product is not sellable right now.read
GET/products/{id}/availabilityCan this product be built RIGHT NOW. Read-only and side-effect free — nothing is created and nothing is billed — so it is safe to poll from a monitoring check. `available` is the boolean to read; `code` explains it (`available`, `no_capacity`, `location_unavailable`, `forced_unavailable`, `plan_disabled`, `no_location`, `unknown`, `sold_out`) and `reason` carries the fleet's own sentence. Without `?location=` the answer covers the product and `locations[]` gives the verdict per datacentre; with it, the answer is about that datacentre alone. For a VPS product this runs the plan's real sizing (vCPU, RAM, disk, addresses) through the same placement checks the provisioner applies — overcommit ratios, reserves, absolute sell ceilings and free disk. `code: 'unknown'` means the check could not be computed, NOT that the product is sold out: it never refuses an order.read
GET/ordersList orders. `?hostname=` and `?idempotencyKey=` answer "did the create I attempted actually land?" after a timeout or a 5xx — `hostname` matches the order, any of its lines and any service built from it, case-insensitively.read
POST/ordersCreate an order. Returns the order, its services and its invoice or proforma plus `document`/`settled`/`next_step`. Unless `settled` is true, pay the document via /invoices/{id}/pay or /proformas/{id}/pay; provisioning starts when it is paid. Hourly products need the minimum credit balance and are charged from credit without a document. Use an image with `orderable: true` from GET /products/{id}. Send an `Idempotency-Key` header (or `idempotencyKey` in the body) to make a retry safe: the key is stored on the order itself, so replaying it answers **200** with `idempotent: true` and the original order instead of creating a second one.orders
GET/orders/{id}One order: `data.order` with `items[]` and `services[]` nested inside it (upgrades also appear here as orders)read

Billing

Invoices and proformas, and paying them from the credit balance

GET/proformasList proformas (?status=unpaid)read
GET/proformas/{id}One proforma (`data.proforma`, with `items` and `payments` nested inside it), plus paid_amount / remaining_amount / settled — poll this to learn whether a proforma has been paidread
POST/proformas/{id}/payPay a proforma from the credit balance (proforma-mode tenants). A fully paid proforma converts into an invoice (returned as `invoice`); a proforma already settled by auto-credit reads status `converted` and answers 400.billing
GET/invoicesList invoices (?status=unpaid)read
GET/invoices/{id}Invoice with line items, plus paid_amount / remaining_amount / settledread
POST/invoices/{id}/payPay from the credit balancebilling

SSH Keys

Account SSH keys installed on bare-metal reinstalls, and nameable from a VPS order via configOptions.ssh_key_ids

GET/ssh-keysAccount SSH keysread
POST/ssh-keysAdd an SSH keyservices
DELETE/ssh-keys/{id}Remove an SSH keyservices

Custom Images

Base images you host yourself and deploy VPS orders from via configOptions.custom_image_id

POST/vps/{id}/captureCapture this VPS into a reusable image you can deploy new servers from. Answers 202 — the image is built on the hypervisor and its status leaves "capturing" when it is ready. Charged per GB of stored image per month when the operator prices it.services
GET/custom-imagesBase images you own, newest firstread
POST/custom-imagesRegister a base image to deploy VPS orders fromservices
GET/custom-images/{id}One of your base imagesread
DELETE/custom-images/{id}Remove a base image. Servers already built from it are untouched.services

Projects

Isolate environments inside one account: a project-bound key sees only its own services, orders, images and SSH keys, while billing stays consolidated at the account

PATCH/services/{id}Move this service into another projectservices
PATCH/vps/{id}Move this VPS into another projectservices
GET/projectsYour projects, with the resources each holdsread
POST/projectsCreate a projectservices
GET/projects/{id}One projectread
PATCH/projects/{id}Rename a project, or change its slug, description or colourservices
DELETE/projects/{id}Delete a projectdestructive
POST/projects/{id}/moveMove resources into this projectservices