Connecting a billing panel
A billing panel (WHMCS, Blesta, HostBill or your own) runs the server lifecycle through the API: create when an order is paid, suspend when an invoice is overdue, unsuspend when it’s paid, terminate when it’s cancelled, and upgrades and add-ons in between. /admin/api in the panel lists these calls.
1. Create a token
- Your own billing: as staff, create a token with the Billing module preset (
servers:read,servers:create,servers:resize,servers:suspend,servers:terminate,servers:power,servers:reinstall,customers:read,customers:write). - A reseller’s billing: the reseller creates a token with their Billing module preset (
reseller:read,reseller:customers,reseller:servers,servers:read,servers:power,servers:reinstall).
Limit it to your billing server’s address with the IP allowlist. See Authentication and tokens.
2. Map your products
Put each package’s ID into the matching billing product. GET /admin/packages (staff) or GET /reseller/account (resellers) lists the packages and locations with their IDs. Packages and backup plans also have a Billing product ID field you can fill in with your billing’s product ID, e.g. whmcs:12.
3. The lifecycle
| When | Call |
|---|---|
| A new client | POST /admin/customers { name, email, organization }, or POST /reseller/customers with your client ID as externalId |
| An order is paid | POST /admin/servers or POST /reseller/servers |
| An invoice is overdue | POST /servers/{id}/actions/suspend { "reason": "Invoice #1042 overdue" } |
| It’s paid | POST /servers/{id}/actions/unsuspend |
| The service is cancelled | DELETE /servers/{id} |
| An upgrade or downgrade | POST /servers/{id}/actions/change-package { "packageId": "…" } |
| An extra IPv4 address | POST /servers/{id}/ips { "version": 4 } |
| A traffic top-up | POST /servers/{id}/bandwidth { "action": "add", "gb": 500 } |
| A faster port or CPU add-on | PATCH /servers/{id}/speed |
| A backup add-on | PUT /servers/{id}/backups/plan { "planId": "…" }; null removes backups |
| A resource pack (self-service) | PUT /customers/{id}/allowance |
Send an Idempotency-Key on every POST, such as the order or invoice number, so a retry after a timeout never creates a second server or suspends twice.
Creating a server
POST /api/v1/admin/servers
Authorization: Bearer pnl_…
Content-Type: application/json
Idempotency-Key: order-10482
{
"ownerId": "usr_…",
"packageId": "pkg_vps2",
"nodeGroupId": "grp_fra",
"nodeId": null,
"imageId": "debian-12",
"hostname": "web-01.example.com",
"access": { "mode": "password" }
}
| Field | Notes |
|---|---|
ownerId |
The customer account |
packageId |
A package that isn’t archived |
nodeGroupId |
The location |
nodeId |
null lets the group’s placement rule pick the node. Staff only; resellers leave it out. |
imageId |
A published image. Leave it out (with hostname and access) to create the server waiting for setup. |
hostname |
Letters, numbers and hyphens: a short name like web01, or a full one like web01.example.com |
name |
Optional. What the panel shows for the server, such as Client website, up to 64 characters. Left out, it shows the hostname. Change it later with PATCH /servers/{id}/name. |
access |
{ "mode": "password" }, or { "mode": "ssh_keys", "sshKeyIds": [ … ] } with keys belonging to the owner. Needed with an image. |
POST /reseller/servers takes the same fields without nodeId; the owner must be the reseller or one of its customers.
The answer comes straight away:
{
"jobId": "job_…",
"serverId": "srv_abc123",
"ipv4": "203.0.113.10",
"rootPassword": "…"
}
rootPassword is only there for password access, and only this once. Follow GET /jobs/{jobId} until state is succeeded, or wait for the server.provisioned webhook.
Waiting for setup
Without an imageId the server is created with the status pending_setup: its node, disk space and addresses are held, but nothing is installed and jobId is null. Until it has a hostname of its own it’s called server<number>. The customer is notified and sees a setup page in place of the server’s tabs; you can do the same from the admin side.
To build it, call POST /servers/{id}/actions/setup with { imageId, hostname, name?, access, scriptId? } (permission servers:reinstall). Custom images and saved startup scripts work as they do for a reinstall, and SSH keys can be the owner’s or those of whoever sets it up. The answer is { jobId, rootPassword? }, followed by server.provisioned as usual.
Until then power, console, reinstall, backups, disk and hardware changes are refused with not_set_up. Moving it to another node (POST /admin/servers/{id}/actions/move) happens at once and answers with jobId: null, since nothing is copied. It can be suspended (unsuspending brings it back to waiting), and terminating it only frees what it held: there’s nothing on the node to destroy.
Why an order can be refused
| Status | Code | Meaning |
|---|---|---|
| 422 | validation_failed |
A bad owner, package, location, image, hostname or SSH key |
| 422 | disk_too_small |
The image needs a bigger disk than the package has |
| 409 | account_locked |
The customer’s account is locked |
| 409 | location_closed |
The location isn’t taking new servers right now |
| 409 | hostname_taken |
The customer already has a server with that hostname |
| 409 | no_capacity |
No node in the location has room: treat it as out of stock |
| 409 | no_addresses |
No free IPv4 address in the pools attached to the chosen node |
| 409 | reseller_suspended |
The reseller is suspended |
| 409 | package_not_allowed |
The reseller isn’t allowed to sell that package |
| 409 | location_not_allowed |
The reseller can’t place servers in that location |
| 409 | quota_exceeded |
The server would take the reseller over a quota; the message says which |
GPU instances
GPU containers are sold the same way, with a GPU offer in place of a package. Put each offer’s ID (from GET /admin/gpu-offers) into the matching billing product, or fill in the offer’s Billing product ID. The same staff token works: the GPU calls use the server scopes.
| When | Call | Scope |
|---|---|---|
| An order is paid | POST /admin/gpu-instances { ownerId, offerId, nodeGroupId, imageId?, name?, sshKeyIds? } |
servers:create |
| An invoice is overdue | POST /admin/gpu-instances/{id}/suspend { "reason": "Invoice #1042 overdue" } |
servers:suspend |
| It’s paid | POST /admin/gpu-instances/{id}/unsuspend |
servers:suspend |
| The service is cancelled | DELETE /admin/gpu-instances/{id} (its /workspace goes too) |
servers:terminate |
| Show it in the client area | GET /gpu-instances/{id}: its status, address, SSH command and Jupyter link |
servers:read |
| Rename it from the client area | PATCH /gpu-instances/{id} { "name": "train-2" } |
servers:settings |
| Charge by the hour, or check a month | GET /gpu-instances/{id}/usage?from=&to= |
servers:read |
| A billing run for every instance | GET /admin/gpu-usage?from=&to=&owner_id= |
servers:read |
Usage answers, for a period (ISO times; this calendar month in UTC up to now when left out), how many seconds the instance existed (allocatedSeconds: its cards, ports and address are held whether it runs or not) and how many of those it was running, stopped or suspended, with each spell in intervals and the offer’s billingProductId. Suspension counts from the moment it’s asked for. A deleted instance’s usage stays readable by staff for 400 days, so the last period can still be invoiced, and the billing run includes instances deleted during the period. The panel doesn’t price anything: charge running hours, allocated hours, or both, as your products say.
For GPU instances, the useful webhooks are gpu_instance.created, ready, started, stopped (with reason requested or crashed, and for a crash the exitCode and whether it ran out of memory), failed, image_changed, suspended, unsuspended and deleted. An instance whose container stops on its own and stays down is marked stopped within a minute or so of its host noticing.
Leave out imageId and it runs the offer’s first available image; the customer can change it in the panel. Leave out sshKeyIds and root’s SSH login takes every key on the owner’s account; the customer can change which with PUT /gpu-instances/{id}/ssh-keys { sshKeyIds }. Send an Idempotency-Key here too. An order is refused with 409 no_capacity when no GPU container host in the location has the cards or room, and no_addresses when an own-IPv4 offer’s host has no address left. Check locations[].available and free in GET /admin/gpu-offers before selling.
4. Listen for webhooks
Add a webhook so billing hears about changes made elsewhere, such as staff suspending a server or a reseller’s customer being created. See Webhooks. Useful events for billing are server.provisioned, server.suspended, server.terminated, server.resized, floating_ip.created and floating_ip.released.
Usage reports
Operations → Usage reports (staff) and Reseller → Usage show each account’s servers, server-days per package, and traffic for the current month and the two before, with the billing ID. Download CSV gives one line per server, for checking against billing or for a reseller’s invoice run. Billing can fetch it with GET /reports/usage?month= (staff tokens need customers:read).