# Customer API — usage guide

**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):

```json
{ "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-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:

```json
{ "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

```bash
# 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}`)

```json
{
  "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:

```json
{ "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:

```json
{
  "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:

```bash
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.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

| 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}/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.
