Version: 1.0 · Date: 4 November 2026 · Owner: SrvCloud Technologies (srvcloud.tech)
Companion files: srvcloud-panel-api-openapi.yaml (machine-readable spec) ·
srvdaddy-cloud-whmcs-module/API_CONTRACT.md (the WHMCS-facing contract — the WHMCS
module already built and tested against §6 of this document)
The SrvCloud panel is a multi-tenant VPS control panel. Customers, resellers and
admins use the web panel; WHMCS sells and bills; the Panel API is the single
backend both of them talk to; and the API alone talks to Proxmox VE through
its official API. Nothing else in the system may call Proxmox — no SSH, no UI
scraping, and Proxmox addresses, node names and credentials never reach a browser.
WHMCS ──┐
├──► Panel API ──► Proxmox VE API (per node, token auth)
Panel UI ─┘ │
└──► Panel DB (tenants, VMs, jobs, audit, KYC)
https://<panel-domain>/api/v1 — see §2: any panel domain works. { "success": true, "data": { … } } or
{ "success": false, "error": { "code": "…", "message": "…" } }.
message is written for humans — WHMCS and the panel show it verbatim.
srv-mum-1042);Proxmox VMIDs are integers; WHMCS-sold VMs also carry
external_id = "whmcs-<serviceid>".
/api/v1). Breaking changes ship as /api/v2; v1 keeps working for at least one release cycle.
**Rule zero of this API: no response, link, email or page may ever assume the
domain is panel.srvcloud.tech.** The same API binary serves the master domain
and every reseller's white-label domain (panel.yourdomain.com, …). Anything less
leaks the SrvCloud name to a reseller's customers and breaks their brand.
1. Every request's Host header is looked up in the tenant-domain registry
(table tenant_domains: domain → tenant_id, verified flag, SSL state).
2. The resolved tenant scopes everything that follows: which VMs a token may
see, which branding a page renders, which email templates and sender name are
used, which locations/plans are sellable.
3. An unknown or unverified host gets the master tenant only for the public
status page; API calls on an unknown host return 404 unknown_domain.
4. A domain becomes active through the reseller flow already in the panel:
reseller adds the domain → panel shows the CNAME target → DNS is verified →
a Let's Encrypt certificate is issued automatically (HTTP-01) → domain flips
to Connected. Certificates renew automatically; an expiring/failed cert
raises an admin alert (Audit Log + announcement draft).
| Surface | Behaviour on a reseller domain |
|---|---|
| Panel UI | Same app, tenant branding (name, logo, colour, support email). No SrvCloud name anywhere. |
| API base URL | https://panel.yourdomain.com/api/v1 works identically. WHMCS server records for that reseller point at their domain. |
| Tokens | Issued per tenant; a token minted on one domain is valid on any domain of the same tenant, invalid for every other tenant (404, never 403). |
| Console URLs | Minted on the requesting domain (https://panel.yourdomain.com/console?…), short-lived, single-session. The Proxmox host/ticket never appears. |
| SSO links (WHMCS → panel) | One-time URLs on the tenant's domain, scoped to the VM's owner. |
| Status page | https://<any panel domain>/status/ renders that tenant's view: tenant brand, only the locations/services that tenant sells. Master domain shows the full fleet. Incidents are fleet-level facts, wording stays brand-neutral. |
| API docs | https://<any panel domain>/docs/ renders these docs with the tenant's brand and that domain pre-filled as the base URL in every example. |
| Emails | Sent from the tenant's domain/sender with the tenant's templates (the editable templates in Reseller → Branding); every link inside (reset, console, panel) uses the tenant domain. |
| CORS | Access-Control-Allow-Origin echoes only verified tenant domains; the master domain is not a wildcard. |
| Webhooks/callbacks | WHMCS return URLs are taken from the tenant's own WHMCS, never hard-coded. |
a stored master domain.
rendered per-request).
contain the string srvcloud anywhere in HTML, JSON, email or PDF output
except the audit trail visible to the master admin.
| Caller | Credential | Header |
|---|---|---|
| WHMCS module | Module token (sd_live_…), one per tenant | Authorization: Bearer <token> + X-Client-Id, X-Request-Source: whmcs, X-Request-Id |
| Customer scripts | Personal access token (sd_pat_…) | Authorization: Bearer <token> |
| Panel UI | Session cookie (HttpOnly, Secure, SameSite=Lax) after login | cookie |
| Public | none | /api/v1/health (limited), /api/status |
only a SHA-256 hash is stored. Regeneration invalidates the old token
immediately. The panel UI follows this rule today (green show-once box).
tenant's VM returns 404 (existence is never leaked with a 403).
POST /auth/login answers 2fa_required and the code is sent to POST /auth/login/2fa.
API tokens are not subject to 2FA (they are the automation path) but are
revocable instantly.
429 withthe standard error envelope.
Deploys, reinstalls, migrations and restores take minutes, so they are jobs:
1. The mutating call returns 202 with data: { "job_id": "job_…", "vm": {…} }.
2. The panel UI opens the Live Task Terminal and polls
GET /api/v1/jobs/{job_id} every 2 s.
3. A job carries steps[] — plain-language lines translated from the Proxmox
task (UPID) — plus percent, status: running|success|failed, and the raw
upid for admins.
4. On failure the job's last step is a human sentence ("Frankfurt node is out of
storage"), the VM is rolled back (half-built VM deleted, IP released), and
the failure lands in Tasks & Activity and the Audit Log.
domains[], discount_pct (0–90 — the reseller's personal discount on admin
plan prices, set per reseller by the admin; there is NO resource-pool
allotment), and their own retail price per plan (their margin is the gap).
rejected`) gates VM management after the first purchase.
external_id (WHMCS),cluster/node/VMID, plan, location, template, IPs, label, tags, app
(one-click install), status (running|stopped|suspended|provisioning|error),
bandwidth used/quota.
token (stored encrypted, never returned).
Mumbai), label, ping note, visitor note, status (live|coming_soon|maintenance).
only. A reseller's cost for a plan = list price × (1 − discount_pct/100);
a template is
VMID = slug (e.g. 9001 = ubuntu-24.04) exactly as registered in the panel.
Base path /api/v1/whmcs/servers/{external_id} unless noted. This section is a
summary; API_CONTRACT.md in the module project is the exact contract and wins
in case of any difference.
| Endpoint | Purpose |
|---|---|
GET /api/v1/health | TestConnection → { panel_version, clusters_ok } |
POST /api/v1/whmcs/servers | Create/deploy (idempotent on external_id; 201 + full vm object incl. IPs) |
GET …/{external_id} | Status for client area + admin tab (usage %, last task) |
POST …/suspend {reason} | Graceful stop, then force; data kept |
POST …/unsuspend | Start |
DELETE …/{external_id} | Terminate: VM + disk deleted, IP quarantined 48 h |
POST …/resize {plan,…} | ChangePackage; disk only grows |
POST …/password | Reset root password inside the VM |
POST …/power {action} | start / shutdown / force_stop / restart |
POST …/reinstall {template, password} | Wipe + fresh template, same VMID/IP/plan |
POST …/snapshots | Create snapshot |
POST …/console | Short-lived noVNC URL on the requesting domain |
POST …/sso | One-time panel login URL on the tenant's domain |
**Wallet rule — every create order, whatever its origin (owner's rule,
5 Nov 2026). A create/deploy order for a reseller tenant** — placed in
the panel, arriving from the reseller's WHMCS (POST /api/v1/whmcs/servers),
or sent to the panel API — executes only when that reseller's wallet
covers the plan cost (the reseller's discounted or per-plan custom cost,
§3). The wallet debit and the VM creation are atomic: on success the wallet
is debited and a wallet transaction + audit entry are written; when the
balance is short, the call fails with wallet_insufficient (402) — no VM
is created, no IP is reserved, and nothing is charged. The platform's own
(Direct) tenant is billed by invoice instead and has no wallet gate. Creates made with the master (admin) token — the admin's own API deploys, for any tenant — are likewise exempt from the wallet gate.
Module v2 (auto catalog, addon, premium client area). The module never
asks an admin to type a plan or location by hand again:
| Endpoint | Purpose |
|---|---|
GET /api/v1/plans | Catalog sync → { plans: [{ slug, name, vcpu, ram_gb, disk_gb, bandwidth_gb, price }] } |
GET /api/v1/locations | Catalog sync → { locations: [{ slug, label, status }] } (only live is orderable) |
GET /api/v1/templates | Catalog sync → { templates: [{ slug, label, family }] } |
GET …/{external_id}/snapshots | Snapshot list for the client area |
DELETE …/snapshots/{name} · POST …/snapshots/{name}/rollback | Client-area snapshot management |
GET /api/v1/resellers | Master token only → [{ name, email, domain, api_client_id, api_token }] for personalized downloads |
The VM object also carries usage (CPU/RAM/disk %, bandwidth used/limit)
and permissions (power / reinstall / snapshot / console / sso) plus
locked_reason when suspended: the WHMCS client area renders exactly the
controls the panel grants that customer — the same rule as the panel's own
UI. The SrvCloud Manager addon syncs the catalog into WHMCS
configurable options (with INR prices) and can create one product per
plan. Personalized module downloads (panel WHMCS screen, or the
addon's Downloads tab) bake a tenant's host + API token into a one-time
Auto-Setup addon inside the ZIP; activating it in WHMCS creates the
server record with the key already applied.
Password rule (panel-wide, also §7): at deploy/reinstall the root password is
one of Random (generated, shown after completion + emailed), Custom
(min 8 chars, validated before the job starts), or Preset (the customer's
saved preset, stored encrypted, never displayed).
POST /auth/login · POST /auth/login/2fa · POST /auth/logout ·
GET /auth/sessions · DELETE /auth/sessions/{id} ·
POST /account/2fa/setup → returns the TOTP secret once ·
POST /account/2fa/enable {code} · POST /account/2fa/disable {code} ·
GET/POST /account/api-tokens (create returns the full token once) ·
DELETE /account/api-tokens/{id} · PUT /account/profile
GET /servers (tenant + ownership scoped) · GET /servers/{id}POST /servers — deploy: `{ plan, location, template, hostname,password_mode: random|custom|preset, password?, ssh_key_id?, app?,
owner_id? (admin/reseller) } → 202` + job. Placement picks a node in the
location by capacity; if no node there can fit the plan, the job fails
with capacity_unavailable. Resellers are never blocked by a pool —
their cost is simply computed from their discount_pct.
POST /servers/{id}/power {action: start|shutdown|force_stop|restart}POST /servers/{id}/console → { wss_url, expires_in } — the API calls Proxmox vncproxy, holds the ticket server-side, and proxies the WSS tunnel
on the requesting domain; the browser never learns the node address.
POST /servers/{id}/reinstall { template, password_mode, … } → job (type-to-confirm is a UI rule; the API requires confirm: "<hostname>").
POST /servers/{id}/resize {plan} → proration preview first via GET /servers/{id}/resize-quote?plan=… { diff_inr, effective }.
PUT /servers/{id} { label, tags[] } — rename/tags (hostname immutable).GET /servers/{id}/metrics?range=24h|7d|30d — CPU/RAM/disk/bandwidth series.POST /servers/{id}/assign {customer_id} · POST /servers/{id}/unassign · POST /servers/{id}/suspend ·
DELETE /servers/{id} (terminate) · POST /servers/{id}/migrate
{target_node} → job · bulk: POST /servers/bulk {ids[], action}.
Proxmox has no "install app" API call, and the panel never pretends otherwise.
Apps ride on cloud-init, which is already mandatory on every template:
1. Deploy arrives with app (Linux templates only — an app sent with a
Windows template is rejected at validation with app_not_supported).
The deploy UI asks app-or-clean-OS first, then offers only the Linux
templates that app supports; the API applies the same per-app support
matrix, so an unsupported app + template pair can never be ordered.
2. After the clone, the API makes sure the app's user-data snippet exists
on the target node's snippet storage — uploaded once per node via
POST /nodes/{n}/storage/{s}/upload (content=snippets), cached by
app + snippet version.
3. PUT /qemu/{id}/config sets cicustom: user=<storage>:snippets/<app>.yaml
together with the normal cloud-init fields (user, password per the
3-way password rule, SSH keys, IP config), and the VM starts.
4. On first boot the guest's cloud-init runs the app's official install
script (WordPress: stack + wp-cli · Docker: get.docker.com · aaPanel /
CyberPanel: the vendor installer · Nextcloud: AIO · Node: NodeSource +
PM2 · LAMP: distro packages). Snippets contain no secrets — app admin
passwords are generated per deploy and delivered like root passwords
(done screen + email).
5. Completion is verified, not assumed: the API polls the QEMU guest
agent (POST /qemu/{id}/agent, guest-exec on cloud-init status) until
cloud-init reports done. Only then does the job's "Installing <app>" step
close. If cloud-init errors or a 15-minute timeout hits, the job ends
app_install_failed with the guest's cloud-init log excerpt in the job
log — the server itself stays up and usable, and the failure is what the
panel and WHMCS show. Never a fake success.
Templates must therefore ship cloud-init + qemu-guest-agent. A template
without the agent can still deploy, but app installs on it are refused up
front (the panel cannot verify what it cannot see). Heavy apps may
alternatively be shipped as pre-built templates — the deploy flow is
identical, cloud-init then only carries credentials.
GET/POST /servers/{id}/snapshots · POST …/snapshots/{snap}/restore · DELETE …/snapshots/{snap}
GET /servers/{id}/backups · GET/PUT /servers/{id}/backup-schedule {enabled, frequency: daily|weekly}
GET /servers/{id}/network · PUT …/network/ptr {ip, ptr} · POST …/network/extra-ip (creates the WHMCS-billable order hook)
GET/POST /servers/{id}/firewall/rules · DELETE …/firewall/rules/{rule}(the final default-deny rule is locked and cannot be deleted)
GET/POST /nodes — connect: { name, host, location, pool, api_token }.The API verifies before saving as Running: it calls the node's
/api2/json/version and /nodes; an unreachable node is stored as
unreachable, never running. (The panel's standalone checker service
already implements this reachability rule today.)
POST /nodes/{name}/test · PATCH /nodes/{name} (incl. capacity) · DELETE /nodes/{name} (refused while it hosts VMs unless force after
migration)
GET/POST/PATCH/DELETE /locations · GET/POST/PATCH/DELETE /plans · GET/POST/PATCH/DELETE /templates ·
GET/POST/PATCH/DELETE /ip-pools
{ name, subnet, total, node, bridge } — the pool lives on ONE node's
bridge (Proxmox vmbr0, vmbr1, …). Nodes carry bridges[] and a
default_bridge; the Panel API reads the real bridge list from
GET /nodes/{node}/network (type bridge) when a node connects.
Deploy resolution is fixed: location → node (Running, capacity) → the
node's default bridge → the pool bound to that (node, bridge) → next
free IP. The VM is created with net0: …,bridge=<bridge> and
ipconfig0 from that pool — a VM never receives an IP from another
bridge's subnet. An admin may override the bridge at deploy (only
bridges with a bound pool are selectable for public VMs); customers
and resellers never pick a bridge. The VM record stores bridge and
pool, and a pool with live assignments cannot be deleted.
GET /nodes/{name}/discovered-vms returns the node's live VM list from Proxmox (GET /nodes/{node}/qemu
plus each VM's config) MINUS the VMs the panel already manages on that
node, matched by VMID — so VMs created directly on Proxmox (or before
the panel existed) surface here with their real name, status, vCPU/RAM/
disk and IP (from the config, or the guest agent when it reports one).
POST /vms/import { node, vmid, customer_id } registers one
discovered VM under a customer (and that customer's tenant). Nothing is
moved or copied on the hypervisor: the panel creates its server record
from the live Proxmox config — plan recorded as Custom (imported)
with the VM's actual specs — and from then on the VM is a normal
managed server (power, console, reinstall, snapshots, billing hooks).
Re-scans are idempotent: a known VMID never shows as discovered again,
and importing the same VMID twice returns the existing record.
GET /capacity — per location: sold vs capacity (vCPU/RAM/NVMe), free IPs,verdict. Computed from live VM + node rows.
GET/POST /customers · POST /customers/{id}/suspend|unsuspend · POST /customers/{id}/login-as (audited, time-boxed)
GET/POST/PATCH /resellers (fields include discount_pct and per-plan retail prices) +
/resellers/{id}/team (members + roles: Admin/Servers/Billing/Support)
GET/POST /kyc (customer submits documents) · GET /admin/kyc · POST /admin/kyc/{id}/approve ·
POST /admin/kyc/{id}/reject {reason} — documents are fetched through the
API only by the owning customer and admins; files are never public URLs.
GET/POST/DELETE /announcements — `{ title, body, kind: info|warn, scope:all|<tenant> }`; resellers may post only to their own scope.
GET /audit?category= — the immutable audit trail.GET/PUT /branding (per tenant: name, logo, colour, support email, domain) · GET/PUT /branding/email-templates/{key} with {variables} preview.
GET /whmcs/calls — recent WHMCS module calls with request ids and results.GET /api/status — live fleet check (already running today): monitoredservices, reachability, response ms, incident history.
GET /status/ — the status page (tenant-branded per §2.2).GET /docs/ — this documentation, tenant-branded, examples bound to thecurrent domain.
| Panel operation | Proxmox VE API calls | |||
|---|---|---|---|---|
| Deploy | POST /nodes/{n}/qemu/{template}/clone → PUT …/qemu/{id}/config (cores, memory, cloud-init user/password/SSH keys/IP) → disk resize → POST …/status/start → poll task UPID | |||
| Power | `POST …/qemu/{id}/status/{start | shutdown | stop | reboot}` |
| Console | POST …/qemu/{id}/vncproxy → API proxies the websocket; ticket stays server-side | |||
| Serial console | POST …/qemu/{id}/termproxy on the VM's serial port (serial0, socket) → API proxies the websocket. Deploys add the serial port to every VM: Linux templates expose it through cloud-init images, Windows templates through cloudbase-init, which listens on that port — it doubles as the automation/recovery channel when networking inside the VM is down | |||
| Reinstall | stop → delete VM → clone template to the same VMID → cloud-init with the new password rule → start | |||
| Resize | config change (cores/memory) + disk resize (grow only); one restart if RAM/CPU change requires it | |||
| Snapshot | POST …/qemu/{id}/snapshot · rollback · delsnapshot | |||
| Migrate | POST …/qemu/{id}/migrate {target} (live, shared/named storage rules enforced) | |||
| Metrics | GET …/qemu/{id}/status/current + rrddata | |||
| Firewall | …/qemu/{id}/firewall/rules CRUD | |||
| One-click app | Upload snippet (once per node) POST /nodes/{n}/storage/{s}/upload → PUT …/qemu/{id}/config (cicustom) → start → poll …/qemu/{id}/agent (guest-exec cloud-init status) | |||
| Node test | GET /version + GET /nodes with the node's API token | |||
| Suspend (billing) | graceful shutdown, force after timeout; a panel-side flag blocks customer power actions |
Cloud-init is mandatory on every template: password modes, SSH keys, hostname,
one-click apps (first-boot scripts) and IP configuration all ride on it.
Stable machine codes + human messages. The common ones:
validation_failed (422) · unauthorized (401) · forbidden_scope (404 by
design for cross-tenant) · not_found (404) · capacity_unavailable (409) ·
location_unavailable (409 — maintenance/coming soon) · node_unreachable
(502) · proxmox_error (502, message carries the translated reason) ·
kyc_required (403 — management locked until KYC is verified) ·
job_failed (500, with job_id for the terminal/audit trail) ·
wallet_insufficient (402 — the reseller tenant's wallet balance is below
the plan cost; the order is not executed, nothing is created or charged).
Never a bare HTML 500; never a stack trace.
Proxmox user per node purpose (provisioning vs console) with least privilege.
shown in full once in the UI — the same rule the panel already follows.
request id, result). WHMCS calls are logged with their X-Request-Id.
one-time and expire in ≤120 s.
1. Core + auth + tenant/domain resolution (§2, §3) and the node registry
with real verification (§7.4) — against the local Proxmox test install.
2. Deploy + jobs + Live Task Terminal (§4, §7.2) with the password rule.
3. Server detail set: power, console proxy, reinstall, snapshots, network,
firewall, metrics.
4. WHMCS endpoints (§6) — the module is already built and tested against a
mock of this contract; it must work unchanged.
5. Reseller white-label: domains + SSL automation, branding, scoped
announcements, team roles, email templates.
6. Hardening: capacity planner, migration, bulk actions, monitoring hooks
(the 5-minute fleet monitor already runs as the checker service).
End of document — SrvCloud Panel API v1.0
SrvCloud Panel API v1.0 · Service status · Open the panel