Developers · Resellers

Partner API

Sell servers under your own brand. Your site keeps the customers, prices and payment gateway; Joy runs the hypervisors, addresses, provisioning and console behind this signed API. Join from your console → Partner programme, where the ready-made storefront kit is also downloaded.

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) ) )
  • METHOD upper-case; path is the path only, starting at /api/partner/v1/…, without the query string.
  • body is 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 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).
  • Servers built on the partner's own Proxmox nodes cost nothing at Joy (price_usd 0, no renewals).
  • Every payment the partner collects from their customers is reported (§8). Joy charges the
  • platform fee — 2 % by default (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).
  • Prices from /catalog and /quote are what Joy charges the partner (USD). The partner's retail
  • prices are their business.

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

codemeaning
bad_signature clock_skew replay unknown_keyfix signing / clock / nonce; the key may have been rotated
partner_suspended fees_due read_onlythe partner's standing with Joy — show it in the reseller's admin
insufficient_fundstop up the Joy wallet (topup_url in /account)
limit_servers limit_nodesstaff-set ceilings
no_capacity no_ip os_unavailabletry another location / add an IP range to the node
not_foundthe object is not this partner's
busya job is already running on that server
invalid (+ field)validation