Joy Partner API — v1
The Partner API is what a reseller's own website talks to. The reseller ("partner") keeps the customers, the storefront, the prices and the payment gateway; Joy keeps the hypervisors, the IP pools, the provisioning, the console and the books between Joy and the partner.
Base URL: https://<joy host>/api/partner/v1 — every response is JSON.
{ "ok": true, ...payload }
{ "ok": false, "error": "human readable", "code": "machine_code" }
HTTP status mirrors the outcome: 200 ok · 400 bad input · 401 signature/key problem · 402 wallet or fees · 403 not allowed for this partner · 404 not yours / not found · 409 conflict · 422 cannot do that right now · 429 rate limited · 503 Joy side unavailable.
1. Authentication — every request is signed
A partner has a key id (pk_…, public) and a secret (sk_…, shown once, stored only by the partner). Nothing is ever sent as a bearer token; each request carries an HMAC:
X-Joy-Key: pk_live_3f9c…
X-Joy-Timestamp: 1757606400 (unix seconds, UTC; ±300 s accepted)
X-Joy-Nonce: 9f2a… (16–64 hex/base64url chars, unique per request, single use)
X-Joy-Signature: hex( HMAC-SHA256( secret,
timestamp + "\n" + nonce + "\n" + METHOD + "\n" + path + "\n" + sha256hex(body) ) )
METHODupper-case;pathis the path only, starting at/api/partner/v1/…, without the query string.bodyis the raw request body (empty string for GET/DELETE).sha256hex("")=e3b0c442…b855.- A nonce that is seen twice within 10 minutes is a replay → 401
replay. - Optional per-partner IP allow-list (set in the Joy console). Wrong IP → 403
ip_not_allowed. - Rate limit: 600 requests/min per key, 60/min for provisioning calls. → 429
rate_limited. - Reference implementation (PHP):
function joy_sign(string $secret, string $method, string $path, string $body, int $ts, string $nonce): string {
return hash_hmac('sha256', $ts . "\n" . $nonce . "\n" . strtoupper($method) . "\n" . $path . "\n" . hash('sha256', $body), $secret);
}
Partner status gates what is allowed: active everything · read_only (fees overdue, or set by staff) GET only, writes → 402 fees_due · suspended → 403 partner_suspended.
2. Money model
- The partner is a Joy customer with a wallet. Servers built on Joy's nodes are charged to that wallet at the partner's Joy prices (list price minus the partner's discount, if staff gave one) — the order is created and paid from the wallet in one call; insufficient balance → 402
- Servers built on the partner's own Proxmox nodes cost nothing at Joy (
price_usd0, no renewals). - Every payment the partner collects from their customers is reported (§8). Joy charges the platform fee — 2 % by default (
- Prices from
/catalogand/quoteare what Joy charges the partner (USD). The partner's retail prices are their business.
insufficient_funds with needed_usd and balance_usd in the payload. Renewals auto-renew from the wallet like any Joy customer; unpaid renewals follow Joy's expiry rules (the server is suspended, then removed).fee_pct in /ping) — on each reported payment, debited from the wallet; if the wallet cannot cover it the fee is booked as due. Fees due above the grace (default $25 or 30 days) put the partner in read_only until paid (Joy console → Billing).3. Ping / identity
GET /ping
{ ok, partner: { id, name, status, fee_pct, discount_pct, max_servers, max_nodes, servers_n, nodes_n },
wallet_usd, fees_due_usd, server_time, api_version: 1 }
4. Catalogue
GET /catalog
{ ok, locations: [{id, code, name, country, flag, active, ip_stock}],
plans: [{code, name, family, cores, ram_mb, disk_gb, bandwidth_tb, price_usd_month, price_usd_hour, windows_addon_usd}],
os: [{code, name, family, platform}], // family: linux|windows ; platform: ubuntu|windows
cycles: [{code, name, months, discount_pct}], // hourly has months 0
os_by_location: { "<location_id>": ["ubuntu-22", …] } // what actually has a verified template there
}
POST /quote body: { plan_code | custom:{cores, ram_gb, disk_gb}, os_code, location_id, cycle, extra_ipv4?, coupon? } — with a coupon the reply carries coupon (code, label, discount_usd) or coupon_error
{ ok, quote: { total_usd, monthly_usd, hourly_usd, months, cores, ram_mb, disk_gb, lines:[{label, usd}] } }
A quote for node_id of a partner node returns total_usd: 0 (own hardware).
5. Customers (the partner's end customers, mirrored for support and ownership)
GET /customers?page=1&q= → { ok, customers:[…], page, more } POST /customers body { ref, email, name?, country? } — ref is the partner's own id for the customer (≤64 chars, unique per partner). Idempotent: posting the same ref again updates e-mail/name and returns the same record. → { ok, customer:{ id, ref, email, name, country, servers_n, created_at } } GET /customers/{ref} · DELETE /customers/{ref} (only when they own no server → else 409).
6. Servers
GET /servers?customer=<ref>&page=1&status= → { ok, servers:[server…], page, more }
server object:
{ ihash, customer_ref, hostname, status, ip, ips:[…], location:{id,code,name}, node:{id,name,own:bool},
os_code, platform, cores, ram_gb, disk_gb, bandwidth_tb, cycle, price_usd, next_due_at, auto_renew,
created_at, live:{ status, cpu, mem, maxmem, uptime, bps_in, bps_out, ts } | null }
POST /servers
{ customer_ref, hostname?, os_code, location_id, cycle,
plan_code | custom:{cores, ram_gb, disk_gb},
node_id?, // one of the partner's own nodes → built there, no Joy charge
extra_ipv4?, ssh_key?, password?, coupon? }
→ { ok, server:{…status:"provisioning"…}, job, order:{id, no, total_usd}, root_password? } The initial root/Administrator password is returned once here (password if you supplied one). Poll GET /jobs/{job} until done; the server then shows status: running.
GET /servers/{ihash} · GET /servers/{ihash}/stats (last 60 samples) POST /servers/{ihash}/action { action: start|stop|shutdown|reboot|reset } → { ok, job } POST /servers/{ihash}/reinstall { os_code, hostname? } → { ok, job, root_password } POST /servers/{ihash}/password { password?, username? } → { ok, job, password } (12+ chars, 3 of 4 classes) POST /servers/{ihash}/resize { cores, ram_gb, disk_gb } → { ok, job, quote:{pay_now_usd, new_monthly_usd} } POST /servers/{ihash}/console → { ok, job } then GET /servers/{ihash}/console → { ok, ready, url, until } url is a one-time browser link (valid until), open it in a new tab/iframe for the customer. POST /servers/{ihash}/customer { customer_ref } — move a server between the partner's customers. DELETE /servers/{ihash} body { confirm: "<hostname>" } → { ok, job } — destroys the VM and its disks.
GET /jobs/{id} → { ok, job:{ id, type, status: queued|running|done|failed, step, progress, error, instance, created_at, finished_at } }
7. Partner nodes (the partner's own Proxmox)
GET /nodes → { ok, nodes:[{ id, name, host, port, pve_node, location:{…}, bridge, storage, health, active, servers_n, templates:[{os_code, vmid, verified}] , ip_ranges:[{id, cidr, gateway, free_n}] }] }
POST /nodes
{ name, host, port?:8006, pve_node, // pve_node = node name inside Proxmox (hostname)
auth: { type: "token", token_id: "user@pve!name", secret } | { type: "password", username: "root@pam", password },
bridge?:"vmbr0", storage:"local-lvm", vlan_tag?, cpu_type?:"host",
location_code?: "own" // omit → the partner's own location "Partner — <name>"
}
Joy connects, reads version/cores/RAM/storages, stores the credential encrypted, and answers { ok, node:{…}, discovered:{ version, cores, ram_gb, storages:[…] } }. The API user needs VM.Allocate VM.Clone VM.Config.* VM.PowerMgmt VM.Console VM.Monitor VM.Audit Datastore.AllocateSpace Datastore.Audit Sys.Audit on /. The node is usable at once; Joy staff can disable it.
PATCH /nodes/{id} (same fields) · DELETE /nodes/{id} (only with no live servers → else 409) POST /nodes/{id}/templates { map: { "ubuntu-22": 9000, "win-2022": 9010, … } } → verifies each VMID is a template and answers { ok, templates:[{os_code, vmid, verified, error}] } POST /nodes/{id}/ip-ranges { cidr, gateway, dns1?, dns2?, vlan_tag?, from?, to? } → adds the range and fills its addresses → { ok, range:{id, cidr, free_n} } DELETE /nodes/{id}/ip-ranges/{rid} (no assigned address → else 409)
8. Payments and fees
POST /payments
{ ref, // the partner's own payment id, unique — posting it twice is idempotent
customer_ref, amount, currency, // what the customer paid, in the partner's currency
gateway, gateway_ref?, paid_at?, note? }
→ { ok, payment:{ id, ref, amount, currency, amount_usd, fee_pct, fee_usd, fee_status: charged|due } } Joy converts to USD at the day's rate, charges the fee from the wallet or books it due.
GET /payments?page= → the partner's reported payments with fee state. GET /account → { ok, wallet_usd, fees_due_usd, fees_charged_month_usd, revenue_month_usd, unpaid_invoices:[…], topup_url } GET /invoices?page= → Joy invoices to the partner (renewals, fee invoices).
9. Webhooks (optional)
Set webhook_url (+ Joy shows a webhook_secret) in the Joy console → Partner. Joy POSTs { event, at, data } with header X-Joy-Signature: hex(HMAC-SHA256(webhook_secret, body)). Events: job.done, job.failed, server.status ({ihash, status}), server.expiring ({ihash, next_due_at, days}), server.suspended, server.terminated, partner.status ({status}), fees.due ({fees_due_usd}). Delivery is retried 5× with back-off; polling /jobs/{id} and /servers is always sufficient without webhooks.
10. Errors worth handling
| code | meaning |
|---|---|
bad_signature clock_skew replay unknown_key | fix signing / clock / nonce; the key may have been rotated |
partner_suspended fees_due read_only | the partner's standing with Joy — show it in the reseller's admin |
insufficient_funds | top up the Joy wallet (topup_url in /account) |
limit_servers limit_nodes | staff-set ceilings |
no_capacity no_ip os_unavailable | try another location / add an IP range to the node |
not_found | the object is not this partner's |
busy | a job is already running on that server |
invalid (+ field) | validation |