The provisioning API lets a billing system (WHMCS, Blesta, Paymenter, a home-grown portal) create and manage NuvoPanel hosting accounts. It is a small, versioned, server-to-server surface under
https://<panel-host>:<port>/api/provisioning/v1
A ready-made WHMCS module ships with NuvoPanel: on an installed server it is in /opt/serverpanel/src/integrations/whmcs/. Any other billing system can call the API directly.
Authentication
Every request carries a provisioning token:
Authorization: Bearer ap_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
A provisioning token is an ordinary NuvoPanel API token created with the scope Provisioning (billing) on the API Tokens page (administrators) or the Billing API page (resellers). It is shown once; only its SHA-256 is stored.
| Owner | Port to use | Reaches |
|---|---|---|
| Administrator | 2087 (admin tier) | every hosting account on the server |
| Reseller | 2083 (client tier) | only the reseller's own accounts; reseller quotas apply |
The scopes are disjoint, in both directions:
- a provisioning token can call only
/api/provisioning/v1/*— any other panel route answers403; - no other credential can call the provisioning API — panel API tokens get
403 TOKEN_SCOPE, browser sessions and "Login As" sessions get401 AUTH_REQUIRED.
Everything else about tokens applies unchanged: expiry, revocation, the optional IP allowlist (strongly recommended: the billing server's address), and automatic revocation when the owner's password is changed or reset, the owner is suspended or deactivated, or the owner's role changes. An admin token is refused on 2083 and a reseller token on 2087, exactly like interactive logins.
Firewall. Port 2087 is restricted to an IP allowlist on the panel host. Add the billing server's public IP to the panel firewall (Security › Firewall) before testing the connection. Port 2083 is public.
Conventions
- Request and response bodies are JSON (
Content-Type: application/json). :usernameis the panel username of the hosting account (the same name WHMCS stores as the service username).- Plans may be given by id (
10,"10") or by exact name (case-insensitive). UseGET /plansto discover them. - Sizes are in MB.
nulllimits mean unlimited / unmetered. - Unknown fields in
POST /accountsare rejected (VALIDATION_FAILED).
Errors
Every error has the same shape:
{ "statusCode": 404, "error": "ACCOUNT_NOT_FOUND", "message": "Account not found" }
Branch on error; message is a human sentence suitable for showing to staff. Codes are stable within v1.
| HTTP | error | Meaning |
|---|---|---|
| 401 | AUTH_REQUIRED | No ap_ bearer token (e.g. a browser session was sent) |
| 401 | AUTH_FAILED | Token unknown, revoked, expired, wrong IP, wrong port/tier, owner suspended |
| 403 | TOKEN_SCOPE | The token is not a provisioning token |
| 403 | FORBIDDEN | Not allowed for this owner |
| 403 | QUOTA_EXCEEDED | Reseller account / disk / bandwidth allocation limit |
| 403 | SUSPENDED_BY_ADMIN | A reseller tried to lift an administrator's suspension |
| 400 | VALIDATION_FAILED | Bad or missing input |
| 400 | CONFIRM_REQUIRED | Terminate without {"confirm": true} |
| 400 | NOT_SUPPORTED | e.g. mail-only accounts with a reseller token |
| 404 | ACCOUNT_NOT_FOUND | No such account owned by this token (never 403, so names cannot be probed) |
| 404 | PLAN_NOT_FOUND | Plan unknown or not assignable by this token |
| 409 | DOMAIN_EXISTS | Domain belongs to another account (or is an addon/subdomain) |
| 409 | USERNAME_TAKEN | Requested username is in use |
| 409 | ACCOUNT_SUSPENDED | SSO into a suspended/disabled account |
| 409 | CONFLICT | Other conflicts (e.g. two plans with the same name) |
| 422 | OPERATION_FAILED | The panel started but could not finish (e.g. termination kept the account) |
| 429 | RATE_LIMITED | Too many requests |
| 500 | INTERNAL_ERROR | Unexpected failure; details are in the panel log, not the response |
Rate limits
Per client IP, in addition to nginx's API zone:
- all endpoints: 10 requests/second, 120/minute;
POST /accounts,POST …/password: 2/second, 30/minute;POST …/terminate: 2/second, 20/minute;POST …/sso: 5/second, 60/minute.
Exceeding a limit answers 429 RATE_LIMITED; retry after a short pause.
Idempotency
Billing systems retry after timeouts, so the API is safe to repeat:
| Call | On repeat |
|---|---|
POST /accounts | Same domain, same owner (and same username if given) → 200 with "created": false and the existing account. Concurrent duplicates are serialised. |
POST …/suspend | Already suspended → 200, "changed": false |
POST …/unsuspend | Not suspended → 200, "changed": false |
POST …/plan | Already on that plan → 200, "changed": false |
POST …/password | Sets the same password again (harmless; ends open sessions) |
POST …/terminate | Second call → 404 ACCOUNT_NOT_FOUND (the account is gone) |
Audit
Every call — reads included — is written to the panel audit log (Security › Audit Log) with the token id, the token owner, the client IP and the route. Request bodies are redacted (password never appears). Password changes, terminations and SSO tickets are also written to the panel log.
Endpoints
GET /test
TestConnection.
{
"ok": true,
"api": "arenapanel-provisioning",
"apiVersion": "1",
"panelVersion": "1.7.0",
"owner": { "username": "admin", "role": "admin" },
"token": { "id": 12, "name": "WHMCS" }
}
GET /plans
Plans this token may assign: for an administrator, the active server plans; for a reseller, its own plans plus the administrator plans it was granted.
{ "plans": [ {
"id": 10, "name": "Basic", "description": null,
"diskMB": 1000, "bandwidthMB": null,
"maxSites": 1, "maxDatabases": 2, "maxFtpAccounts": 1,
"maxSubdomains": 10, "maxAddonDomains": 0,
"maxMailboxes": 10, "maxMailDomains": 1,
"phpVersions": ["83"]
} ] }
POST /accounts
Create an account through the same code path as the admin panel (or the reseller panel for reseller tokens): same validation, plan copy-on-assign, reseller ownership and quota checks.
{
"domain": "shop.example.org",
"username": "shopexam",
"password": "…",
"email": "owner@example.org",
"plan": "Basic",
"accountType": "hosting",
"phpVersion": "83"
}
| Field | Required | Notes |
|---|---|---|
domain | yes | Lowercase FQDN |
email | yes | Account contact |
plan | hosting: yes; mail: optional | id or exact name |
username | no | ^[a-z][a-z0-9_-]{0,15}$; derived from the domain when omitted |
password | no | 8–128 chars. If omitted the panel generates one and returns it once as generatedPassword |
accountType | no | hosting (default) or mail (administrator tokens only; the plan, if given, sets mailbox limits) |
phpVersion | no | e.g. 83; must be installed and allowed by the plan |
201 when created, 200 when the account already existed (idempotent retry):
{ "created": true, "account": { …account object… }, "warnings": [] }
A password you sent is never echoed back.
GET /accounts/:username
{ "account": {
"username": "shopexam",
"domain": "shop.example.org",
"email": "owner@example.org",
"accountType": "hosting",
"status": "active",
"suspended": false,
"suspendedReason": null,
"plan": { "id": 10, "name": "Basic" },
"disk": { "usedMB": 321, "limitMB": 1000 },
"bandwidth": { "usedMB": 12, "limitMB": 20000, "month": "2026-11" },
"createdAt": "2026-11-07T10:00:00.000Z"
} }
status is active, suspended or disabled.
GET /usage
All accounts the token owns, for usage sync (WHMCS UsageUpdate).
{ "month": "2026-11", "generatedAt": "…", "accounts": [ { …account object… } ] }
POST /accounts/:username/suspend
Body: { "reason": "Overdue on Invoice #42" } (optional, max 255 chars). Websites show the suspended page, PHP pools stop, FTP / database / shell / panel logins are blocked. Nothing is deleted. A reseller suspension is marked as such; resellers cannot lift an administrator's suspension.
POST /accounts/:username/unsuspend
Reverses the suspension exactly.
POST /accounts/:username/terminate
Body: { "confirm": true } (a JSON boolean; anything else is CONFIRM_REQUIRED).
Uses the panel's safe termination: a safety archive (every database dump, the home directory, each maildir and the crontab) is written first, and if any part of it cannot be written nothing is deleted. The archive is kept on the server for the panel's retention period. If the OS user cannot be removed the account is kept (suspended, annotated) and the call answers 422 OPERATION_FAILED.
{ "terminated": true, "username": "shopexam", "safetyArchive": "/root/terminated-accounts/shopexam-…", "warnings": [] }
POST /accounts/:username/plan
Body: { "plan": "Pro" }. Applies the plan's limits (copy-on-assign) and re-applies the disk quota; reseller quotas are checked. For mail-only accounts only the mailbox limits are taken from the plan.
POST /accounts/:username/password
Body: { "password": "…" } (8–128 chars). Sets the account's panel password and ends all of its open sessions and API tokens. The response never contains the password: { "changed": true, "username": "shopexam" }.
POST /accounts/:username/sso
Single sign-on into the customer's own client panel.
{
"url": "https://panel.example.com:2083/?imp=3f1c…",
"expiresInSeconds": 60,
"expiresAt": "2026-11-07T10:01:00.000Z",
"singleUse": true
}
Flow:
- The billing system calls
POST …/ssoand redirects the customer's browser tourl. - The URL carries only an opaque 32-byte ticket — never a session token. The ticket is valid for 60 seconds, once, and only on the client tier (2083).
- The panel's front end immediately strips the ticket from the address bar and redeems it with
POST /api/auth/impersonation/exchange(body, not URL). A ticket redeemed on the wrong port, a second time, after 60 s, after the account was suspended, or after its password changed is refused. - The resulting session is the customer's own panel, marked as delegated by the token owner (audited as a billing SSO, 2-hour limit). From such a session the password, 2FA and API tokens cannot be changed — the customer does that from a normal login.
Suspended or disabled accounts answer 409 ACCOUNT_SUSPENDED.
Example
TOKEN=ap_…
BASE=https://panel.example.com:2087/api/provisioning/v1
curl -sH "Authorization: Bearer $TOKEN" $BASE/test
curl -sH "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"domain":"shop.example.org","email":"o@example.org","password":"S3cret-Pass-123","plan":"Basic"}' \
$BASE/accounts
curl -sH "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"confirm":true}' $BASE/accounts/shopexam/terminate