Customer API
betav1.0.092 operationshttps://my.voxa.host/api/v1Everything 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):
| scope | unlocks |
|---|---|
read | every GET (always included) |
services | power, reinstall, password reset, snapshots, backups, plugin actions, SSH keys, withdraw a cancellation |
orders | create orders, buy add-ons, request or cancel upgrades |
billing | pay invoices/proformas from credit, request a renewal |
destructive | cancel 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" } ] }
| HTTP | code | meaning |
|---|---|---|
| 400 | BAD_REQUEST | invalid input or the action does not apply to this service; details lists field errors |
| 401 | UNAUTHORIZED | missing, malformed, unknown, expired or revoked key |
| 403 | SCOPE_REQUIRED | the key lacks the scope ("scope" names it) |
| 403 | FORBIDDEN | account permission, IP allowlist, account type or service state refused it |
| 403 | ACCOUNT_SUSPENDED | suspended customers are read-only |
| 403 | PERMISSION_DENIED | ONLY 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 |
| 403 | ACTION_NOT_ALLOWED | the provider plugin does not expose this action to customers |
| 403 | FEATURE_DISABLED | the 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 |
| 404 | NOT_FOUND | no such resource, or not owned by this account (never distinguishes the two) |
| 409 | CONFLICT / RENEWAL_PENDING | state 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 / 409 | game.* | 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) |
| 429 | RATE_LIMITED | hourly per-key window exhausted — see headers below |
| 429 | ACTION_RATE_LIMITED | per-service cool-down on this action; retry_after_seconds in the body and a Retry-After header |
| 424 | PROVIDER_ERROR | a dedicated server's BMC did not complete the power action (the attempt still counts against the 5/min cool-down) |
| 503 | PROVIDER_ERROR / PROVIDER_UNAVAILABLE | the hosting provider refused or is unreachable (a provider plugin whose flow reported a failure answers this, never a 200); retry later |
| 500 / 502 / 503 | INTERNAL_ERROR / UPSTREAM_ERROR / UNAVAILABLE | platform-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-Keyheader (oridempotencyKeyin 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": "…"
}
statusis the billing state (pending,active,suspended,terminated).instance.stateis the machine state (creating,running,stopped,paused,error).providerisnativefor the platform's own hypervisors, otherwise the plugin slug.failureis null unlessstatusisprovisioning_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_capacitymean another location may work;configurationandname_conflictmean the order itself needs changing; the rest are worth one retry./servicesrows and/dedicatedrows 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 incapabilities.actionsanswers 403 naming the allowed set;reinstall/password_reset/snapshotsfalse makes those routes answer 403. An emptyactionslist 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.stateis only the last state the hypervisor reported. - A VPS whose build has not produced a machine yet — or whose build failed — has
instance: nulland 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 whenGET /vps/{id}itself is 404. instanceisnulluntil the machine exists (a freshly ordered or failed service);billing.currency,hostname,locationandprimary_ipmay benull./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 emptyvps_ip_addressesfor 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_statusbecomesin_progress/completed/failedaround a reinstall.billing.amountis what THIS service bills — it can differ from the product's list price inGET /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
valueis what you send.nameis for humans; do not send it.surchargeis 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 withoutlocationis refused 400.false→ it is optional and placement chooses.- An empty
locationsbesidelocation_required: truemeans 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: truemeans the operator has closed that datacentre to new servers. It is listed so you can show it, but an order for it is refused 409LOCATION_SOLD_OUT(the message names the locations that are still open). OnGET /locationsit istrueonly when the location is sold out for every product that lists it.shared: truemarks capacity the operator resells from a partner. It is ordered exactly like any other value.- On
GET /products/{id}each row also carriesavailable(the catalogue-wideGET /locationsdoes not — availability is per product). A location is listed because the product SELLS there;available: falsesays 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" } }
availableis 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 —reasonnames 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). Eachlocations[]row carries its owncode.unknownis 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_outabout is refused 409 (NO_CAPACITY/PRODUCT_UNAVAILABLE/LOCATION_SOLD_OUT, orSOLD_OUTwhen 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:
| code | meaning | what to do |
|---|---|---|
| 403 | snapshots are not offered on this VPS | read capabilities.snapshots first; nothing to retry |
| 403 | the service is suspended or terminated | settle the service; the list is refused too |
| 400 | the name is not [A-Za-z0-9][A-Za-z0-9._-]{0,119}, or starts with a reserved prefix | fix the name |
| 400 | confirm missing or wrong on a restore | send the snapshot's own name; the message lists what would be lost |
| 409 | you already hold max snapshots | delete one first |
| 409 | a snapshot of that name already exists on this VPS | pick another name |
| 409 | another disk job is running on this VM (build, reinstall, resize, migration, another snapshot) | retry when it finishes — they are serialised on purpose |
| 409 | the snapshot is not available, or was not on the host at the last read | re-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:
| code | meaning | what to do |
|---|---|---|
| 403 | capture is not offered on this platform or product | read capabilities.images; nothing to retry |
| 403 | the service is suspended or terminated | settle the service first |
| 409 | you already hold the maximum number of images | delete one, then retry |
| 409 | the VPS is building, migrating or being deleted | retry when it is running or stopped |
| 409 | ordering from an image still capturing | poll until available |
| 409 | the plan's disk is smaller than virtual_size_bytes | order a plan at least that size |
| 409 | the node holding the image cannot take another server | the image is pinned to ONE node and it is full — capture it again from a VPS elsewhere, or order against that copy |
| 400 | custom_image_id sent together with os_image_id | send 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.commatches the order, its lines and any service built from it (case-insensitive), andGET /orders?idempotencyKey=…finds one by key. An emptyordersarray 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/ordersonly.
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
| Method | Path | Scope | Purpose | ||||
|---|---|---|---|---|---|---|---|
| GET | / | read | identity, key, scopes, links | ||||
| GET | /account, /account/balance, /account/usage, /account/transactions | read | profile, balance, monthly usage, credit ledger | ||||
| GET / POST | /projects | read / services | list projects (with resource counts); create one | ||||
| GET / PATCH / DELETE | /projects/{id} | read / services / destructive | one project; rename; delete (refuses while non-empty — pass ?reassignTo=) | ||||
| POST | /projects/{id}/move | services | move services / orders / custom_images / ssh_keys into it | ||||
| PATCH | /services/{id}, /vps/{id} | services | move ONE resource between projects ({"projectId": "…"}) | ||||
| GET | /services, /services/{id}, /services/{id}/actions, /services/{id}/stats | read | any service | ||||
| POST | /services/{id}/actions/{action}, /services/{id}/power/{action} | services | start / stop / restart / status / stats / plugin verbs | ||||
| POST | /services/{id}/reinstall, /services/{id}/password | services | any provider (native VPS or plugin) | ||||
| GET / POST | /services/{id}/backups, POST …/backups/{backupId}/restore | read / services | provider backups (plugin services) | ||||
| POST / DELETE | /services/{id}/cancel | destructive / services | request or withdraw cancellation | ||||
| POST | /services/{id}/renew | billing | issue the next renewal document now (active, non-hourly, none open) | ||||
| GET / POST | /services/{id}/game, `…/game/power/{start | stop | restart | kill}` | read / services | native game servers | |
| GET | /vps, /vps/{id}, /vps/{id}/ips, /vps/{id}/stats, /vps/{id}/os-images | read | VPS collection | ||||
| POST | `/vps/{id}/start | stop | shutdown | reboot | restart, /vps/{id}/power/{action}` | services | power (stop = shutdown; restart = reboot) |
| POST | /vps/{id}/reinstall, /vps/{id}/password | services | see workflows | ||||
| GET / POST / DELETE | /vps/{id}/snapshots, …/{snapshotId}, POST …/{snapshotId}/revert | read / services | only if the operator enabled snapshots | ||||
| DELETE | /vps/{id} | destructive | cancel (immediate:true destroys the disk) | ||||
| GET | /dedicated, /dedicated/{id}, …/ips, …/power-status, …/os-images, …/stats, …/traffic, …/provision-status | read | bare metal | ||||
| POST | /dedicated/{id}/power `{action: on | off | restart}, /dedicated/{id}/power/{action}` | services | BMC power (graceful off/restart) | ||
| POST | /dedicated/{id}/reinstall {osImageId}, /dedicated/{id}/password | services | both ERASE DATA on provisioning-managed servers | ||||
| GET / POST / DELETE | /services/{id}/options, …/options/available, …/options/{optionId} | read / orders / destructive | add-ons | ||||
| GET / POST | /services/{id}/upgrades, …/upgrades/options; GET /upgrades/{id}; POST /upgrades/{id}/cancel | read / orders | plan upgrades | ||||
| GET | /locations | read | datacentres the catalogue sells in (?productId= to narrow) | ||||
| GET | /products/{id}/availability | read | can 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 / orders | catalogue and orders; POST takes Idempotency-Key | ||||
| GET / POST | /invoices, /invoices/{id}, /invoices/{id}/pay; /proformas, /proformas/{id}, /proformas/{id}/pay | read / billing | documents and credit payment | ||||
| GET / POST / DELETE | /custom-images, /custom-images/{id} | read / services | your own VPS base images. POST body { "name", "sourceUrl", "sha256", "osFamily"? } (source_url / source_sha256 accepted as aliases) | ||||
| POST | /vps/{id}/capture | services | capture 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 / services | account 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.md | none | this documentation |
7. Good practice for scripts and agents
- Read
GET /services/{id}/actionsor the VPScapabilitiesbefore acting; do not assume. - Power and reinstall calls are asynchronous: a
job_idmeans "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-Keyon everyPOST /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, links | read |
| GET | /openapi.json | This document (no authentication required) | none |
| GET | /docs | Human-readable documentation page (this guide rendered, plus an endpoint index) — no authentication required | none |
| GET | /guide.md | Usage 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 | /account | Profile, account context and credit balance | read |
| GET | /account/balance | Credit balance | read |
| GET | /account/usage | Service counts, spend this month, unpaid invoices | read |
| GET | /account/transactions | Credit ledger | read |
Services
Every service regardless of type: detail, generic actions, stats, cancellation, renewal, provider backups
| GET | /services | All services with serviceType | read |
| GET | /services/{id} | Service detail (VPS capabilities and IPs included) | read |
| GET | /services/{id}/actions | Actions supported by this service | read |
| 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}/stats | Usage statistics | read |
| POST | /services/{id}/power/{action} | Power action by name (any provider) | services |
| POST | /services/{id}/reinstall | Reinstall the operating system (native VPS or provider plugin) | services |
| POST | /services/{id}/password | Reset the root password (native VPS or provider plugin) | services |
| GET | /services/{id}/backups | List provider backups | read |
| POST | /services/{id}/backups | Create a provider backup | services |
| POST | /services/{id}/backups/{backupId}/restore | Restore a provider backup | services |
| POST | /services/{id}/cancel | Request 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 400 | destructive |
| DELETE | /services/{id}/cancel | Withdraw a pending cancellation | services |
| POST | /services/{id}/renew | Issue 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 | /vps | List VPS instances with state and addresses | read |
| GET | /vps/{id} | VPS detail: instance, IPs, capabilities, specs | read |
| DELETE | /vps/{id} | Cancel the VPS: at the end of the paid period by default; immediate:true terminates now and DESTROYS THE DISK | destructive |
| GET | /vps/{id}/ips | IP addresses | read |
| GET | /vps/{id}/stats | CPU, memory, disk and network series | read |
| GET | /vps/{id}/os-images | Images 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}/start | Power 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}/stop | Shut down: ACPI shutdown, forced power-off after the grace window (native VPS); provider stop (plugin VPS) | services |
| POST | /vps/{id}/shutdown | Same as /stop | services |
| POST | /vps/{id}/reboot | Reboot | services |
| POST | /vps/{id}/restart | Reboot (alias) | services |
| POST | /vps/{id}/power/{action} | Power action by name | services |
| POST | /vps/{id}/reinstall | Reinstall 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}/password | Set 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}/snapshots | List 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}/snapshots | Take 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}/revert | Restore 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 | /dedicated | List dedicated servers | read |
| GET | /dedicated/{id} | Hardware, location, provisioning and OS state | read |
| GET | /dedicated/{id}/ips | Subnets and IP addresses | read |
| GET | /dedicated/{id}/power-status | Power state | read |
| POST | /dedicated/{id}/power | Power 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-status | Provisioning / install progress | read |
| GET | /dedicated/{id}/os-images | Images available for reinstall | read |
| POST | /dedicated/{id}/reinstall | Reinstall 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}/password | Set 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}/stats | Hardware statistics | read |
| GET | /dedicated/{id}/traffic | Traffic usage | read |
Game
Native game servers: document and power control
| GET | /services/{id}/game | Game server document | read |
| 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}/options | Add-ons active on the service | read |
| POST | /services/{id}/options | Buy 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/available | Add-ons the product offers, with what is already held | read |
| 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}/upgrades | Upgrade paths with prorated previews, and any pending upgrade | read |
| POST | /services/{id}/upgrades | Request 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/options | Add and/or remove add-ons as one prorated change | orders |
| 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}/cancel | Cancel a pending upgrade request | orders |
Products & Orders
The orderable catalogue, the datacentres it sells in, and new orders
| GET | /locations | Every 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 | /products | Orderable products with prices per cycle | read |
| 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}/availability | Can 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 | /orders | List 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 | /orders | Create 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 | /proformas | List 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 paid | read |
| POST | /proformas/{id}/pay | Pay 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 | /invoices | List invoices (?status=unpaid) | read |
| GET | /invoices/{id} | Invoice with line items, plus paid_amount / remaining_amount / settled | read |
| POST | /invoices/{id}/pay | Pay from the credit balance | billing |
SSH Keys
Account SSH keys installed on bare-metal reinstalls, and nameable from a VPS order via configOptions.ssh_key_ids
| GET | /ssh-keys | Account SSH keys | read |
| POST | /ssh-keys | Add an SSH key | services |
| DELETE | /ssh-keys/{id} | Remove an SSH key | services |
Custom Images
Base images you host yourself and deploy VPS orders from via configOptions.custom_image_id
| POST | /vps/{id}/capture | Capture 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-images | Base images you own, newest first | read |
| POST | /custom-images | Register a base image to deploy VPS orders from | services |
| GET | /custom-images/{id} | One of your base images | read |
| 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 project | services |
| PATCH | /vps/{id} | Move this VPS into another project | services |
| GET | /projects | Your projects, with the resources each holds | read |
| POST | /projects | Create a project | services |
| GET | /projects/{id} | One project | read |
| PATCH | /projects/{id} | Rename a project, or change its slug, description or colour | services |
| DELETE | /projects/{id} | Delete a project | destructive |
| POST | /projects/{id}/move | Move resources into this project | services |