Reseller API
v191 operationshttps://my.voxa.host/api/v1/resellerFor partners who resell this platform's services wholesale. Authenticate with a reseller API key from Reseller → API Settings; sign every mutation with the signing secret shown there. This page is generated from the same sources as openapi.json and guide.md.
Base URL: https://my.voxa.host/api/v1/reseller Machine-readable spec: https://my.voxa.host/api/v1/reseller/openapi.json · Rendered docs: https://my.voxa.host/api/v1/reseller/docs
Three documents, in reading order: the partner guide (how the programme works), the API reference (endpoints and payloads) and the signing guide (HMAC on requests and webhooks).
Reseller programme — partner guide
This is the guide for a partner who resells services from a FluxBilling supplier. It explains how the programme works end to end: applying, how your prices are set, how you are charged, what the portal does, how to automate with the API, and how a FluxBilling instance or a WHMCS shop of your own plugs in.
Two companion documents cover the machine interface in detail: the API reference (every endpoint, status code and payload) and the signing guide (how to sign requests and verify webhooks). All three are served together at /api/v1/reseller/docs on the supplier's platform; /api/v1/reseller/openapi.json is the exact route contract for code generators and AI agents.
1. How the programme works
- One level. The supplier (operator) sells to you at wholesale; you sell to your own customers at whatever price you choose. Your customers never interact with the supplier's platform. There are no sub-resellers. - You own what you buy. Every service you order is a normal service on the supplier's platform, owned by your client account, billed to you on its cycle, and controllable from your portal or the API exactly as the supplier's own customers control theirs. - Your customers are records, not accounts. A customer record (your own customer id, email, name) attributes orders and services to an end customer for your bookkeeping and lets you mint API keys that see only that customer. It creates no login on the supplier's platform. - Billing is yours to stop. Suspending a service is you acting against your customer; the supplier keeps billing you for it. Only terminating a service stops the wholesale charge.
2. Joining
1. Register a normal client account on the supplier's platform. 2. Open Reseller programme in the client panel (the supplier must have the programme switched on; the page shows the discount tiers) and apply with your company name, registration number and VAT id where the supplier asks for them. 3. The supplier reviews the application (or has auto-approval on). On approval you receive an email and the Reseller section appears in your sidebar. Your signing secret exists from the moment you apply; API Settings shows it in full and lets you rotate it. 4. A supplier can later suspend the profile (API and portal refuse; services keep running) and reactivate it.
Profile states: pending → active → suspended → active. A profile that is not active answers 401 Invalid API key on every API call — the same response as an unknown key.
3. What you pay
Every product the supplier has marked *reseller-available* is in your catalogue automatically. For each product and billing cycle the wholesale price is resolved in this order:
1. Your override price — an absolute price the supplier set for you on that product (per cycle, plus setup fee). 2. Your tier's price — an absolute price from the supplier's tier price book for the tier you are currently in. 3. Retail minus a percentage — otherwise the product's retail price for that cycle, discounted by: a per-product percentage override set for you, else the *better* of your tier's discount and the default discount on your profile. A supplier may set the tier discount per product category (say 20% on VPS, 10% on dedicated servers); the programme page then shows one rate per category for each tier, and a category the supplier left blank uses the tier's default rate.
A cycle that has no price in any of the three places cannot be ordered on that cycle (400 Product is not sold on the '…' billing cycle). Nothing is ever priced at zero by accident.
Prices are always in the supplier's base currency (currency on every product). If your account currency differs, the order is charged in yours at the rate in force at order time, and that rate is stored so the invoice matches the charge.
Add-on options are charged with the order at your wholesale too: the supplier's tier option price when one is set for your tier, otherwise retail minus the same percentage that applies to the product the option is ordered with (so a per-category tier rate or a per-product override reaches the add-ons as well). Option setup fees follow the same rule — the tier's option setup fee when set, otherwise retail minus the percentage. GET /products lists each add-on's pricing at your wholesale next to the retail figures and the discountRate that applied. A *one-time* option is billed once, on the setup side of the order, and carries no separate setup fee. An option's price for a cycle is its own figure for that cycle when the supplier set one, otherwise its monthly price × the months in the cycle; on an hourly order it is the monthly price ÷ 720 per hour. The same derivation applies to tier option prices. Datacentre surcharges the supplier prices on a product apply to you too.
Ordering through the client panel
You do not have to use the API or the portal's order form to get wholesale. While your reseller account is active, the supplier's ordinary order pages and checkout in the client panel quote and charge you the same wholesale as the API — products, add-ons and setup fees alike — and the checkout summary says so ("Reseller pricing applied"). A product the supplier has not marked resellable, or has disabled for you, stays at retail, and a per-product quantity limit the supplier set for you applies here as well. Services bought this way are reseller services like any other: they count towards your tier run-rate once paid, reprice at wholesale when you change package, and appear in the portal.
Every wholesale figure is VAT-exclusive. amount on an order is what the product and its options cost before tax; the supplier's document states tax separately according to your VAT status and theirs, and vat / total on the order response say what that document carries and what is collected. When the supplier charges your saved method or your credit balance, the charge is the document's total — under reverse charge or where VAT does not apply to you, that is the same as amount.
Tiers
Tiers are the supplier's configurable ladder (by default Bronze / Silver / Gold / Platinum). Your tier is derived from your monthly run-rate with the supplier: the recurring value of everything you currently hold, normalised to a month (a quarterly service counts a third per month, an annual one a twelfth, an hourly one at 730 hours), plus one-off spend in the last 30 days. Terminated and cancelled services do not count; suspended ones still do, because you are still billed for them. The portal dashboard shows your tier, your run-rate and the distance to the next tier.
Holding caps and minimums
The supplier can cap how many units of a product you may hold at once (non-terminated services count; 409 Quantity limit reached) and set a programme-wide minimum order amount (400 before anything is charged).
4. How you are charged
The supplier chooses one collection mode for the whole programme:
| Mode | What happens when you order |
|---|---|
saved_method | Your saved card / PayPal on the supplier is charged immediately; provisioning starts. |
credit | The amount is deducted from your credit balance with the supplier; provisioning starts. Your balance is held in the supplier's base currency, so the check and the deduction are in that currency even when your account is billed in another — an order that is short answers 400 naming both figures in base currency. |
invoice | An invoice (or proforma, depending on the supplier's document mode) is raised with a due date; services are created in pending_payment and provisioned when you pay it. |
For both pay-now modes a paid document is raised for your records — an invoice, or a proforma on suppliers that issue proformas first. Raising it is best-effort: the charge is never reported as failed because the paperwork could not be produced, so treat the money as the truth and the document as bookkeeping that may appear a moment later. On a supplier that issues proformas, and on one that consolidates a month's proformas into a single invoice, the order's invoiceId stays null until that invoice exists — the document number is in the response message, and GET /orders?externalOrderId=… returns proformaId.
Renewals follow the platform's normal lifecycle: each service renews on its cycle and you are invoiced for it like any customer. There is no webhook for renewals or unpaid invoices yet — watch Billing in the client panel. An unpaid wholesale renewal ends in the supplier suspending the service, which you will see as service.suspended.
Manage saved payment methods in the client panel (Billing → Payment methods); GET /payment-methods and PUT /payment-methods/{id}/default let a script pick which one auto-charges land on.
5. The reseller portal
The Reseller section of the client panel:
| Page | What it does |
|---|---|
| Dashboard | Tier, run-rate, active services, recent orders, revenue. |
| Products | Your catalogue with wholesale prices per cycle. |
| Orders | Place a new order (product, cycle, quantity, optional customer, hostname, location), retry a failed payment, see each order's services. |
| Services | Everything you hold: status, IP, next due date; power, console, reinstall, password reset, stats, backups — drawn from each service's capabilities. Suspend, unsuspend and terminate are API-only (§6). |
| Customers | Your end-customer records. |
| Peers | Connect to *another* FluxBilling platform you resell from (see §8). |
| API Settings | Your list of API keys — create, rename, set an expiry, revoke one, revoke all — plus reveal/rotate the signing secret, set the webhook URL, and the API reference card. There is no delivery log here: a test delivery is sent with POST /webhooks/test (signed), and the supplier can read the attempt history if one goes missing. |
6. Automating with the API
Base path: https://<supplier>/api/v1/reseller.
1. Mint a key (API Settings → Create key). Choose scopes: read, orders, services, destructive, customers, account — a key created without any gets read alone. Scopes are fixed for the life of a key; a label and an expiry are optional and can be edited afterwards. You may hold 20 keys that have not been revoked (an expired key still occupies a slot until you revoke it). A key may carry its own request-per-hour limit, which can only be lower than your account's. A key can be bound to one customer — it then sees and orders only for that customer. Keys are the only credential: there is no single account-wide key, and every key is listed, renamed and revoked individually (or all at once). 2. Copy the signing secret (API Settings). Every POST/PUT/DELETE carries X-Reseller-Signature: hmac-sha256=<hex> over "<X-Reseller-Timestamp>." + <raw body> keyed on it. Verify your implementation against the vectors in the signing guide. 3. Set the webhook URL first, then order. Events raised while no URL is on file are dropped. 4. Order with externalOrderId. It is your idempotency key: a retried create returns the original order (200, idempotent: true) instead of charging twice. After a timeout, GET /orders?externalOrderId=…. 5. Read status on the order: paid (wait for service.provisioned), invoiced (pay the document), payment_failed (POST /orders/{id}/retry), provisioning_failed (502 — charged, no service; contact the supplier with the order id, do not retry). 6. Before offering a control, read GET /services/{id}/capabilities; before a reinstall, GET /services/{id}/os-templates. 7. Terminate to stop paying. DELETE /services/{id} — idempotent. 8. Show your customer the same page. GET /services/{id}/document is the supplier's own service page in one read — kind (dedicated, vps, ip-transit, colocation, game, plugin), the supplier's panel switches, the kind section (hardware, location, NICs, storage; addresses and OS; port and BGP; device and cross-connects), add-ons and upgrade paths at wholesale. Live parts have their own reads (traffic, health, network), rDNS and SSH keys their own writes, and POST /services/{id}/console-session opens a console your own viewer dials at origin — or, if you would rather not build a viewer at all, POST /services/{id}/console-cast returns a URL on the supplier that IS the console page: open it in a tab, put it behind a button, or iframe it. Nobody needs an account with the supplier to use it, which is exactly why the URL is the credential — it expires, and you can revoke it. Game servers get the whole game panel under /services/{id}/game/*; plugin-drawn pages come from provider, ui-schema and ui-action. 9. Add-ons after the sale. PUT /services/{id}/options states the add-ons the service must carry (an absolute set); additions and top-ups are charged to you at wholesale the same way an order is, removals are free. 402 means nothing changed — retry the order it names.
Rate limit: an hourly window per key and per reseller (headers X-RateLimit-*). Every authenticated request counts, refused or not.
7. WHMCS
A ready-made provisioning module for WHMCS 8.x exists — ask your supplier for the fluxbilling module folder; it ships with its own README covering install, server and product setup, the webhook callback, and what maps to what. It needs a key with read, orders, services and destructive, plus the signing secret, and it covers Create / Suspend / Unsuspend / Terminate / Change Package, client-area power buttons and a status sync. account and customers are not used by it. Console single sign-on works only for backends that expose a browser URL (see the API reference, Device control) — for a native VPS or a dedicated server it reports that the console is a session, not a URL. A cast link (POST /services/{id}/console-cast) is the URL-shaped alternative, but the WHMCS module does not call it for you.
8. Reselling from another FluxBilling platform
Two arrangements exist; both use this same API on the *supplier* side.
Peer connection (your portal → their API). As an active reseller you can add a *peer* under Reseller → Peers: the other platform's base URL, the rsk_ key they issued you and the signing secret (16+ characters). Your own supplier can switch this feature off, in which case the Peers calls answer 403 Peer connections are disabled on this platform. Your portal then browses their catalogue and places and manages orders there through an authenticated proxy — the services live on the peer platform. The Peers page shows an inbound webhook URL for that peer; paste it into the supplier's API Settings as your webhook URL and their lifecycle events, verified with the same signing secret, update your peer-order records. The peer catalogue is not imported into your own product list.
Upstream connection (their catalogue → your storefront). Your operator can register the supplier as an *upstream connection* in the admin panel. Products sync into your local catalogue with a markup, your customers buy them from you, and your platform places the wholesale order upstream automatically, forwards suspend / unsuspend / terminate / package changes, and reconciles on the supplier's webhooks. An hourly job reconciles orders whose webhook was missed and retries failed terminations. This is the arrangement for a white-labelled storefront — and your customer sees the same service page they would see on the supplier: a resold dedicated server, VPS, IP transit port, colocation rack or game server renders with the supplier's own details, controls and add-ons, answered through the connection without the supplier ever being named. Add-ons your customer buys later are forwarded and charged to you at wholesale.
For it the supplier's key needs read, orders, services and destructive, plus account so your platform can register its webhook address on the supplier by itself (PUT /account/webhook); without account you paste that address into API Settings by hand. The signing secret from the same page is required — it signs every order and lifecycle call your platform makes. Give the connection the supplier's root address only (for example https://panel.supplier.example), not a link into /api/v1/reseller.
9. Troubleshooting
| Symptom | Look at |
|---|---|
401 Invalid API key | Key revoked/expired, or your profile is pending/suspended. |
401 Invalid signature | Body re-encoded between signing and sending; wrong secret; see the signing guide. |
403 … missing the required scope | Mint a key with that scope. |
400 Product is not sold on the '…' billing cycle | Read billingCycles from GET /products/{id}/schema. |
400 A datacentre must be selected | Send configuration.location from locationPricing. |
409 Quantity limit reached / 409 No upgrade path | Supplier-side limits; ask them. |
Order paid but no service.provisioned | Webhook URL set after the order, or your endpoint not answering 2xx within 30 s. Read GET /orders/{id} — provisioning_status and provisioning_error are the authoritative answer. Your portal shows no delivery history; ask the supplier to check the attempt log for your profile. |
| Console button does nothing in your own UI | Native VPS/dedicated consoles are websocket/KVM sessions, not URLs — embed a viewer, or use a cast link (POST /services/{id}/console-cast), which gives you a URL to open. |
| A cast link says it is no longer valid | One message covers unknown, expired, revoked and terminated — deliberately, since the link is held by a third party. Check GET /services/{id}/console-cast: a link not listed there is over, and minting another is free. |
10. Glossary
- Wholesale price — what you pay the supplier per cycle for one unit. - Override — an absolute price or percentage the supplier set just for you. - Tier — your discount band, derived from your monthly run-rate. - Signing secret — the HMAC key for your requests and for the supplier's webhooks; separate from the API key; rotate it in API Settings. - Customer record — your end customer as the supplier knows them: an external id and optional name/email; no login. - Delegated key — an API key bound to one customer record. - Peer — another FluxBilling platform you buy from through your portal. - Upstream — a supplier your own platform buys from automatically.
Reseller API reference
/api/v1/reseller — the machine interface a reseller's own platform calls to buy, provision and manage services wholesale.
Signing is covered separately in the signing guide (reseller-signing.md; /api/v1/reseller/guide.md serves all three documents as one Markdown file). Read that first; a wrong signature is a flat 401 with no other diagnosis. The exact route contract, with request and response schemas, is the OpenAPI document at /api/v1/reseller/openapi.json; /api/v1/reseller/docs renders both.
Authentication
Authorization: Bearer rsk_<prefix>_<secret>
The key is rsk_, an 8-character lookup prefix and a 64-character secret, joined by underscores. Keys are minted by the reseller in their client portal (Reseller → API Settings); the supplier can also issue one on the reseller's behalf. The secret half is shown once at creation and is not retrievable afterwards.
Key management is a list of keys. There is no account-wide "the API key", and no way to authenticate other than a key from that list. Each key carries a label, a fixed scope set, an optional expiry and an optional per-key hourly request limit (which may only be lower than the account's). Scopes cannot be changed after creation — mint a new key. The label and the expiry can be edited. Keys are revoked individually or all at once, and revocation is immediate. A reseller may hold 20 keys that have not been revoked; an expired key still occupies a slot until it is revoked. A key created without an explicit scope set gets read alone.
Every request needs the resellers feature enabled on the instance (otherwise 403 with code: FEATURE_DISABLED) and a reseller profile in active status. A key on a profile that is not active — pending, suspended or closed — answers 401 Invalid API key, the same response as an unknown, revoked or expired key.
Scopes
A key carries an explicit scope set. Requests to a route whose scope is absent get 403 API key is missing the required scope: <scope>.
| Scope | Grants |
|---|---|
read | products, orders, services, capabilities, OS templates, stats, backups list, customers list |
orders | placing orders and retrying payment |
services | reversible service actions — power, suspend, unsuspend, change package, create backup, open a console |
destructive | terminate, reinstall, root-password reset |
customers | create, update, delete end-customer records |
account | payment methods (including listing them), webhook configuration, webhook test |
Three GET routes are not covered by read: GET /services/{id}/console and GET /services/{id}/console-cast need services (they open a session) and GET /payment-methods needs account. Changing a service's add-ons (PUT / DELETE /services/{id}/options) needs orders, as it places an order.
A key may also be bound to a single customer. Such a key sees and acts on only that customer's orders and services, orders are always attributed to that customer, and it cannot hold account or customers.
Rate limiting
Per key, sliding one-hour window, counted against the reseller-wide ceiling as well — minting more keys does not raise aggregate throughput. Every authenticated request counts, including ones refused for scope, signature or validation.
Every authenticated response carries X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used and X-RateLimit-Reset (unix seconds; always one hour ahead, not the true edge of the sliding window). Over the limit is 429. A key with its own limit is measured against that limit *and* against the account ceiling, whichever bites first.
Response envelope
{ "success": true, "data": { } }
{ "success": false, "error": "message" }
Request-validation failures (a malformed UUID, a bad billingCycle, a quantity out of range) are 400 with error as an object:
{ "success": false, "error": { "message": "Validation failed", "details": [ { "field": "billingCycle", "message": "Invalid billing cycle" } ] } }
Paginated endpoints add pagination — in two shapes:
| Endpoint | Shape |
|---|---|
GET /orders | { "total", "limit", "offset", "hasMore" } |
GET /services, GET /customers | { "page", "limit", "total", "totalPages" } |
GET /products and GET /products/{id} add a top-level currency next to data, repeating the currency every price in the body is stated in.
Request bodies are limited to 1 MB and must be application/json.
Endpoints
"Signed" marks routes that require X-Reseller-Signature.
| Endpoint | Scope | Signed |
|---|---|---|
GET /products | read | — |
GET /products/{productId} | read | — |
GET /products/{productId}/schema | read | — |
POST /orders | orders | yes |
GET /orders | read | — |
GET /orders/{orderId} | read | — |
POST /orders/{orderId}/retry | orders | yes |
GET /services | read | — |
GET /services/{serviceId} | read | — |
POST /services/{serviceId}/power | services | yes |
POST /services/{serviceId}/suspend | services | yes |
POST /services/{serviceId}/unsuspend | services | yes |
DELETE /services/{serviceId} | destructive | yes |
POST /services/{serviceId}/change-package | services | yes |
GET /services/{serviceId}/capabilities | read | — |
GET /services/{serviceId}/os-templates | read | — |
GET /services/{serviceId}/stats | read | — |
GET /services/{serviceId}/console | services | — |
POST /services/{serviceId}/capture | services | yes |
GET /images | read | — |
DELETE /images/{imageId} | services | yes |
POST /services/{serviceId}/reinstall | destructive | yes |
POST /services/{serviceId}/password | destructive | yes |
GET /services/{serviceId}/backups | read | — |
POST /services/{serviceId}/backups | services | yes |
POST /services/{serviceId}/backups/{backupId}/restore | destructive | yes |
DELETE /services/{serviceId}/backups/{backupId} | destructive | yes |
GET /services/{serviceId}/document | read | — |
GET /services/{serviceId}/traffic | read | — |
GET /services/{serviceId}/health | read | — |
GET /services/{serviceId}/network | read | — |
PUT /services/{serviceId}/rdns | services | yes |
GET /services/{serviceId}/ssh-keys | read | — |
POST /services/{serviceId}/ssh-keys | services | yes |
DELETE /services/{serviceId}/ssh-keys/{keyId} | services | yes |
POST /services/{serviceId}/console-session | services | yes |
POST /services/{serviceId}/console-cast | services | yes |
GET /services/{serviceId}/console-cast | services | — |
DELETE /services/{serviceId}/console-cast/{castId} | services | yes |
GET /services/{serviceId}/options | read | — |
PUT /services/{serviceId}/options | orders | yes |
DELETE /services/{serviceId}/options/{serviceOptionId} | orders | yes |
GET /services/{serviceId}/upgrades | read | — |
POST /services/{serviceId}/rescue | services | yes |
POST /services/{serviceId}/unrescue | services | yes |
PUT /services/{serviceId}/transit-config | services | yes |
GET /services/{serviceId}/game | read | — |
GET /services/{serviceId}/game/addons | read | — |
PATCH /services/{serviceId}/game/vars | services | yes |
POST /services/{serviceId}/game/power/{action} | services | yes |
POST /services/{serviceId}/game/console | services | yes |
GET /services/{serviceId}/game/files | read | — |
GET /services/{serviceId}/game/files/read | read | — |
GET /services/{serviceId}/game/files/contents | read | — |
GET /services/{serviceId}/game/files/download | read | — |
POST /services/{serviceId}/game/files/{op} | services | yes |
GET /services/{serviceId}/game/sftp | read | — |
POST /services/{serviceId}/game/sftp/rotate | services | yes |
GET /services/{serviceId}/game/backups | read | — |
POST /services/{serviceId}/game/backups | services | yes |
PATCH /services/{serviceId}/game/backups/{gameBackupId} | services | yes |
DELETE /services/{serviceId}/game/backups/{gameBackupId} | destructive | yes |
POST /services/{serviceId}/game/backups/{gameBackupId}/restore | destructive | yes |
GET /services/{serviceId}/game/schedules | read | — |
POST /services/{serviceId}/game/schedules | services | yes |
PATCH /services/{serviceId}/game/schedules/{gameScheduleId} | services | yes |
DELETE /services/{serviceId}/game/schedules/{gameScheduleId} | services | yes |
POST /services/{serviceId}/game/schedules/{gameScheduleId}/run | services | yes |
GET /services/{serviceId}/game/calendar | read | — |
GET /services/{serviceId}/game/mods | read | — |
GET /services/{serviceId}/game/mods/sources | read | — |
GET /services/{serviceId}/game/mods/search | read | — |
GET /services/{serviceId}/game/mods/versions | read | — |
GET /services/{serviceId}/game/mods/project | read | — |
POST /services/{serviceId}/game/mods | services | yes |
POST /services/{serviceId}/game/mods/upload | services | yes |
DELETE /services/{serviceId}/game/mods/{gameModId} | services | yes |
GET /services/{serviceId}/provider | read | — |
GET /services/{serviceId}/ui-schema | read | — |
POST /services/{serviceId}/ui-action/{action} | services | yes |
GET /customers | read | — |
POST /customers | customers | yes |
PUT /customers/{customerId} | customers | yes |
DELETE /customers/{customerId} | customers | yes |
GET /payment-methods | account | — |
PUT /payment-methods/{methodId}/default | account | yes |
PUT /account/webhook | account | yes |
POST /webhooks/test | account | yes |
Three documentation routes need no key: GET /docs, GET /guide.md, GET /openapi.json.
Products
GET /products lists every product the supplier has enabled for you, with your wholesale price per cycle:
{ "id": "uuid", "name": "VPS 4G", "slug": "vps-4g", "description": "…", "category": "VPS", "currency": "EUR",
"pricing": { "currency": "EUR", "monthly": 8.5, "quarterly": 24, "semiAnnual": null, "annual": 90, "hourly": null, "setupFee": 0 },
"specs": { } }
currency is the supplier's base currency, which every wholesale figure is denominated in, and every figure is VAT-exclusive. A cycle priced null cannot be ordered; the non-null entries of pricing are the authoritative list of cycles POST /orders will accept for that product. GET /products/{productId} returns the same object for one product (404 when it is not in your catalogue).
GET /products/{productId}/schema
What an order for this product needs:
- billingCycles — always an array of plain cycle strings: the cycles the product itself carries a price for, minus any the supplier disabled on its category. It is the right list to build a dropdown from, but it is derived from the *retail* price columns, so a cycle priced for you only by a negotiated override or a tier price book can be missing from it while POST /orders still accepts it. Where the two disagree, the non-null entries of pricing in GET /products win. - requiredFields — always present; at least ["billingCycle"], plus location when the product prices datacentres, plus any required field of a plugin order form. - productId, productName, category, billingEnabled — identifying fields echoed back with every schema. - osImages (native VPS only) — the images you may name in configuration.osImageId; osImageField says so and hostnamePattern is the regex a hostname must match. - locationPricing (when present, alongside locationField: "location" and locationRequired: true) — the datacentres and their surcharge; send the entry's value as configuration.location. - orderSchema — the plugin's order form (fields, layout) when the product is plugin-provisioned, otherwise null; pluginDefaults alongside it.
Ordering
POST /orders
{
"productId": "uuid",
"billingCycle": "monthly",
"quantity": 1,
"externalOrderId": "your-own-id",
"externalCustomerId": "your-own-customer-id",
"externalCustomerEmail": "[email protected]",
"externalCustomerName": "Jane Doe",
"configuration": { "hostname": "web01", "location": "de1", "osImageId": "uuid" },
"options": [ { "optionId": "uuid", "quantity": 1 } ],
"paymentMethodId": "uuid"
}
billingCycle is one of monthly, quarterly, semi-annually, annually, hourly. Note the exact spelling of semi-annually — the product listing reports that cycle's *price* under the key semiAnnual, but the value you send here is semi-annually.
A cycle the product is not priced on is rejected with 400. It is never silently priced at zero.
quantity is an integer from 1 to 100 and creates that many separate services (the setup fee applies to each). It is subject to any per-product holding cap the operator has set; exceeding it returns 409. The operator may also set a programme-wide minimum order amount; an order below it returns 400 before anything is charged.
options are configurable add-ons charged with the order (up to 50 entries; each must belong to the product and be active on it — otherwise 400 naming the rejected ids; each quantity is 1–100). The ids come from the product's options[] in GET /products (and GET /products/{id}), which lists every add-on you may order with it — id, name, pricingType (recurring or one_time), maxQuantity and pricing at your wholesale for each cycle (monthly, quarterly, semiAnnual, annual, hourly, or oneTime, plus setupFee). An option costs the supplier's tier option price when one is set for your tier, otherwise its retail price minus your programme percentage — the tier's rate (per product category when the supplier runs the programme that way) or your own default rate, whichever is better, as for the product itself; option setup fees are wholesale the same way. Each option id may appear once per order; the same id twice is merged into one line with the summed quantity, and an option's own maxQuantity is enforced. A *one-time* option is charged once on the setup side and carries no setup fee of its own. A cycle without its own option price is priced at the monthly figure × months; an hourly order prices each option at its monthly figure ÷ 720 per hour. Option quantity is per service, so a quantity: 3 order buys the options three times over. They come back as data.options[] — { optionId, name, quantity, unitPrice, setupFee }.
configuration is passed to provisioning. Known keys: hostname, location (required when the schema says so; a product that prices datacentres refuses an order without one — 400 A datacentre must be selected), osImageId (native VPS; validated before the charge, as is the hostname), and externalServiceId, which is stored on the service as your own id.
paymentMethodId charges a specific saved method instead of your default; it only matters when the supplier collects by saved payment method.
Always send externalOrderId. It is your idempotency key. At most 100 characters; externalCustomerId and externalCustomerName are capped at 255.
#### Idempotency
Provisioning can take longer than your HTTP timeout. If your client retries without an idempotency key you get a second order and a second charge.
With externalOrderId set, a repeat call returns the original order with HTTP 200 and "idempotent": true, instead of 201. The value is unique per reseller, so it can be your own order number. Two truly concurrent creates with the same key resolve the same way — one lands, the other gets the 200.
If you did not capture the response, reconcile with GET /orders?externalOrderId=your-own-id rather than retrying blind.
#### Responses
| Status | Meaning |
|---|---|
201 | Created. Check data.status. |
200 | Already existed for this externalOrderId. |
502 | The service could not be created after payment. When you were charged, the charge stands and the supplier has been alerted. Do not retry; quote data.orderId. (In invoice mode nothing was charged yet — the document exists, the service record does not.) |
400 / 409 | Validation, pricing, location, minimum-order or quantity-cap refusal — nothing was charged. |
404 | The product does not exist, or the supplier has not made it available to you. The two are deliberately indistinguishable. |
{
"success": true,
"data": {
"orderId": "uuid",
"orderNumber": "RSL-...",
"externalOrderId": "your-own-id",
"status": "paid",
"paymentStatus": "paid",
"paymentMode": "saved_method",
"amount": 12.5,
"vat": 0,
"total": 12.5,
"currency": "EUR",
"invoiceId": "uuid",
"services": [ { "serviceId": "uuid", "status": "pending" } ],
"options": [ ],
"message": "Order created and paid. Service will be provisioned."
}
}
status is one of:
| Value | Meaning |
|---|---|
paid | Charged; provisioning underway. Poll the service or wait for service.provisioned. |
invoiced | An invoice or proforma was raised. Nothing provisions until it is paid. |
payment_failed | Order exists, payment did not go through. Use the retry endpoint. |
provisioning_failed | Returned with 502. |
Orders an active partner places through the supplier's ordinary client panel appear in GET /orders too (no externalOrderId, paymentMode: "storefront"); they are paid in that panel, so POST /orders/{orderId}/retry answers 409 for them. A retry while another attempt for the same order is still in flight also answers 409.
paymentMode is how the supplier collects from resellers — saved_method (your stored card or PayPal is charged immediately), credit (deducted from your credit balance with the supplier) or invoice (a document is raised and you pay it; the service is created when the document is paid). It is a supplier-wide setting, not per order.
amount and currency are the wholesale total in your account currency on the supplier, excluding VAT; vat and total are the VAT the supplier's document carries for you (zero under reverse charge or when VAT does not apply) and the gross that is actually collected. In saved_method and credit mode the charge equals total — what the paid invoice says. In invoice mode you pay the document, which is the same figure. Where your currency differs from the supplier's base currency, the rate applied at order time is recorded and reused for the invoice, so the two always reconcile.
GET /orders may also report paymentStatus: "processing" for an order whose payment attempt is still in flight.
invoiceId is the paid or payable invoice. It is null — with the service still created and the charge still taken — on a supplier that issues proformas first, and on one that folds a month's paid proformas into a single invoice at month end. In those cases the document number appears in message, and GET /orders?externalOrderId=… carries proformaId. Raising the document is best-effort on the pay-now modes: the charge is never reported as failed because the paperwork could not be produced.
The idempotent 200 replay carries the reconciliation row rather than the 201 body: orderId, orderNumber, externalOrderId, status (same four values), paymentStatus, provisioningStatus, amount, currency, invoiceId, proformaId, createdAt, services[], idempotent: true.
GET /orders
?externalOrderId= · ?status= · ?limit= (default 50, max 200) · ?offset=
status filters on the payment status: pending, processing, paid, failed, refunded.
Rows are snake_case: id, order_number, status (the platform order status, pending → active), billing_cycle, total_amount (base currency), created_at, external_order_id, payment_status, provisioning_status (provisioned / pending / failed), wholesale_amount + currency (what you were charged), payment_failed_reason, invoice_id, the customer's external_customer_* fields, product_name.
Every row carries a services array (serviceId, status, hostname, serverIp), so a reconcile after a timed-out create can link the service it bought without a second call. GET /orders/{orderId} is the same row plus provisioning_error.
A provisioning failure that happens in the background *after* the 201 (the provider refused the build) is recorded on the order as provisioning_status: "failed" with the reason, and emits service.provisioning_failed.
POST /orders/{orderId}/retry
For a payment_failed order. Optional { "paymentMethodId": "uuid" }. Charges exactly what was quoted (the order-time rate), then creates the services. 400 Order is already paid when there is nothing to retry; 400 when the order is invoiced (pay the document that was raised — a retry does not raise another); 400 with the gateway's reason when the charge fails again; 502 with the same contract as POST /orders when the charge succeeds but the service cannot be created.
Success is 200 with { orderId, externalOrderId, status, paymentMode, invoiceId, services[] } and re-emits order.paid. A retry never charges twice for one order: the services are created only if the order has none yet, and the amount charged is the one quoted at order time, not a re-conversion at today's rate. The quantity, options and customer of the original order are all carried through.
Services
GET /services — ?page= · ?limit= (default 20) · ?status= (platform service status: active, suspended, terminated, …) · ?externalCustomerId=.
Rows: serviceId, externalServiceId, externalCustomerId, productName, status, serverIp, hostname, billingCycle, wholesaleAmount (per cycle, base currency), nextDueDate, createdAt. GET /services/{id} adds serverLocation and serverPort.
Service status is one of exactly these nine values — there is no provisioning:
| Value | Meaning |
|---|---|
pending | Created; provisioning has not finished. |
pending_payment | Invoice mode: created, waiting for your payment before anything is built. |
installing | Hardware or a VM is bound and the OS is being deployed. |
active | Live. |
suspended | Suspended, by you or by the supplier. Still billed to you. |
provisioning_failed | The build failed; see the order's provisioning_error. |
cancelled | Cancelled before it ever ran. |
terminated | Torn down. Billing stops. |
fraud | Withheld by the supplier. |
?status= is matched literally against this list — an unrecognised value returns an empty page rather than an error.
Service lifecycle
These four map onto the mandatory hooks of a typical provisioning module.
| Endpoint | Hook | Notes |
|---|---|---|
POST /services/{id}/suspend | _SuspendAccount | Optional {"reason": "..."}. Does not pause what you owe. |
POST /services/{id}/unsuspend | _UnsuspendAccount | No body needed. |
DELETE /services/{id} | _TerminateAccount | Idempotent. Stops billing. |
POST /services/{id}/change-package | _ChangePackage | {"productId": "uuid"}. Returns 202. |
Suspension is a control you exercise over *your* customer. You continue to owe for the service until you terminate it — terminate is the only call that stops your billing.
Suspend and unsuspend answer { "serviceId", "status": "suspended" | "active", "actions": [ ] }. Terminate on an already-terminated service returns success with "alreadyTerminated": true, so a retry after a timeout is safe. Send the request without a body and sign the empty string (see the signing guide) — or send {} and sign that; both verify.
change-package returns 202 with serviceId, upgradeId, status, billingDocumentType (invoice / proforma / null), billingDocumentId, billingDocumentNumber, orderId and message. The change applies once that document is settled. A zero-cost change reports billingDocumentType: null and applies shortly. status is one of pending, awaiting_payment, executing or awaiting_admin — the last means the supplier requires their own approval first. There is no endpoint that reports an upgrade's progress: poll GET /services/{id} and compare productName.
A package change runs the supplier's upgrade engine, so it is only possible between two products the supplier has linked with an upgrade path. Without one the call returns 409 and says so; ask the supplier to enable the path. The service must be active and the target product must be in your catalogue, priced on the service's cycle.
When the caller is itself a FluxBilling instance reselling this one, its own upgrade engine forwards the change here automatically for upstream-backed services and settles any billing document you raise from its side.
| Status | Meaning |
|---|---|
404 | Not yours, or does not exist — the two are deliberately indistinguishable. |
409 | Wrong state for the transition, e.g. suspending something not active, or an upgrade already in progress. |
Device control
Knowing what a service can do
A service you resell sits on one of four backends, and the answer to "can I open a console on this?" depends on which — and on switches the supplier can turn off per product. Read it, do not assume it:
GET /api/v1/reseller/services/{serviceId}/capabilities
→ { "backend": "vps" | "dedicated" | "upstream" | "plugin" | null,
"power": true, "powerActions": ["start","stop","restart"],
"stats": true, "console": true, "reinstall": true,
"password": true, "backups": false }
backend: null means the service has no remote controls at all (it was never linked to hardware, a VM or a provider). A terminated service reports every control false (its backend is still named). Dedicated servers report backups: false. A native VPS reports what the supplier's per-product switches actually allow, so console, reinstall, password and backups can each be false on a live VM; upstream, dedicated and plugin advertise the full set and let the backend refuse what it cannot do.
powerActions is always a subset of start / stop / restart — shutdown never appears in it.
Power
POST /services/{id}/power with {"action": "start" | "stop" | "restart" | "shutdown"}. shutdown is an accepted alias for stop, not a separate, gentler operation: stop is already the graceful shutdown on every backend, and both spellings do exactly the same thing. data is the backend's result — for a dedicated server, { action, alreadyInState }.
A service with no backend at all answers 400; a control the supplier has switched off for that product (native VPS) answers 403 and names the verbs that are allowed; a dedicated server with no management controller configured answers 400, and one whose controller accepted the call but failed answers 424. A suspended service refuses start (403) and a terminated one refuses everything (403).
Reinstall: what osTemplate is
It depends on the backend, so list the valid values rather than guessing:
GET /api/v1/reseller/services/{serviceId}/os-templates
→ { "templates": [ { "id": "…", "name": "Debian 12" } ], "platform": "native" }
platform is native on a VPS and the plugin's name on a plugin-backed service; a dedicated server answers templates alone.
- native VPS and dedicated: osTemplate is the image UUID from that list (osImageId is accepted as the explicit spelling). On a VPS a value that is not a UUID is a 400, an unknown image is 404, and one the plan or the product does not offer — or that is disabled or has no installable source — is 409. On a dedicated server an unlinked image is 404, and a reinstall while one is already running is 409. - A native VPS requires password (8+ characters) on both reinstall and POST .../password; so does a plugin-backed service on POST .../password. A dedicated server accepts neither: it rebuilds with a password it generates, which POST .../password returns once as data.password (with data.method = automated or manual). Sending a password to a dedicated server is ignored, not an error. - plugin-backed: osTemplate is the provider's template name. - upstream (the supplier resells it from their own supplier): the catalogue belongs to that supplier; this endpoint returns 400. Reinstall requires osTemplate and forwards osTemplate and password upstream.
Reinstall answers message: "OS reinstall initiated" with the backend's job result in data; a provider plugin that refuses answers 503, as does a plugin the supplier has switched off.
Console, stats, backups
GET /services/{id}/console opens a one-shot, short-lived session. The shape is the backend's:
- native VPS — type: "novnc" with sessionId, token, wsPath, expiresAt, mode, viewerKind, an absolute websocketUrl (wss://<supplier>/api/console/ws/<sessionId>?token=…) and rfbPassword. Point a VNC-over-websocket client at websocketUrl and authenticate to the VNC server with rfbPassword; both die with the session. The VM must be running or paused — a stopped one answers 409, start it first. 409 is also the answer while the VM has no display yet. - dedicated — type: "kvm" with available, launch_endpoint, agent_online and, when unavailable, a reason. 400 when the server has no management controller configured. - plugin-backed — the provider's own shape, which may be a URL.
Neither shape is a URL you can redirect a customer to. When you do not want to host a viewer at all, use a cast link instead (below): the supplier hosts the console page and you hand out the address.
GET /services/{id}/stats returns the backend's usage object.
On a native VPS it is platform, collecting, series and a stats object — the same figures the supplier's own client panel draws:
{ "platform": "native", "collecting": false,
"stats": {
"status": "running",
"cpu": { "usage": 12.4, "cores": 4 },
"memory": { "percentage": 63, "used": 2684354560, "cached": 411041792,
"total": 4294967296, "source": "guest" },
"disk": { "percentage": 41.2, "used": 8589934592, "total": 20401094656,
"allocated": 42949672960, "source": "guest",
"read_bps": 131072, "write_bps": 262144,
"read_iops": 12, "write_iops": 31 },
"network": { "rx_bps": 4184000, "tx_bps": 1096000,
"netin_bytes": 91273849201, "netout_bytes": 40118293021,
"month_netin_bytes": 812394857291, "month_netout_bytes": 391827364512 }
},
"series": [ { "t": "…", "cpu_pct": 12.4, "mem_used_bytes": …, "net_rx_bps": …, "net_tx_bps": …, "disk_rd_bps": …, "nics": [], "disks": [] } ] }
Read the units carefully, because the two network families answer different questions:
| Field | Unit | Meaning |
|---|---|---|
cpu.usage | percent | Of the whole VM (cores is its vCPU count), from the 10-minute samples |
memory.used / total / cached | bytes | source is guest (the guest's own figure), host_rss (the hypervisor process, runs high) or null (no reading — render —, not 0) |
disk.used / total | bytes | What the guest FORMATTED. allocated is what was bought; a 60 GB volume with a 20 GB partition fills at 20 GB |
disk.source | — | guest (live), guest_daily (the once-a-day OS sweep, up to 24 h old) or null |
disk.read_bps / write_bps | bytes/sec | Plus read_iops / write_iops (requests/sec) |
network.rx_bps / tx_bps | bits/sec | The instantaneous rate |
network.netin_bytes / netout_bytes | bytes | Cumulative since the VM last BOOTED — they reset on reboot and on migration |
network.month_netin_bytes / month_netout_bytes | bytes | This calendar month, from the nightly rollup. Survives reboots and sample retention — quote this one |
Anything the supplier cannot read right now is null, never 0: an older agent, a guest with no agent, or a VM whose first two samples have not landed yet (collecting: true). Traffic is informational — the supplier does not bill or cap on it.
On a dedicated server there is no agent inside your customer's OS, so cpu, ram and storage are the hardware specs rather than utilisation, with a live powerState and health from the management controller (or powerState: "unreachable"). Its network block carries the switch-port reading — rx_bps, tx_bps, month_netin_bytes, month_netout_bytes and the sampledAt they were taken at; netin_bytes / netout_bytes are always null, because a port is sampled rather than read cumulatively. All of them are null when the port has never been sampled.
Plugin-backed services return the provider's own stats shape, unchanged; game servers have no usage telemetry and answer 400.
GET /services/{id}/backups lists snapshots — { snapshots[], used, max } on a native VPS, backups[] elsewhere; POST creates one with an optional name. A dedicated server always lists an empty backups[] and refuses creation with 400. On a native VPS both calls need the supplier's snapshot feature switched on and the service active: a suspended or terminated VPS answers 403 even for the list.
POST /services/{id}/backups/{backupId}/restore reverts to that snapshot (destructive — a native VPS wants the snapshot's name back in confirm); DELETE /services/{id}/backups/{backupId} removes it. Both need the destructive scope.
A native VPS snapshot row carries everything a restore dialog needs:
{ "id": "…", "name": "before-upgrade", "status": "available",
"size_bytes": 2147483648, "restorable": true, "in_progress": false,
"crash_consistent": false, "destroys_on_restore": ["nightly-2"],
"comes_back_running": true, "created_at": "…" }
- destroys_on_restore is the field partners miss. Rolling a disk back cannot leave a LATER snapshot standing, so restoring an older one removes every newer one. The names are listed so your customer sees them before they confirm, not afterwards. - crash_consistent: true means the guest could not be frozen when the snapshot was taken (no QEMU guest agent answered), so it restores like a machine that lost power. false is a quiesced, filesystem-consistent copy. - comes_back_running answers "will the server be up when this finishes": the platform stops it for the restore and starts it again if it was running. - Creating takes { "name", "description"? }. Snapshots taken through the API are disk-only — guest RAM is never captured. - used / max are the same numbers the create call refuses on. max: 0 means the plan does not include snapshots. Everything is asynchronous: poll the list until in_progress is false. - 409 also comes back while another disk job (build, reinstall, resize, migration, another snapshot) is running on that VM — they are serialised.
Suspended services refuse console, reinstall, password reset and backup creation, and refuse start — they still stop, restart, report stats and list OS templates.
Captured images
Turn a VPS you have already configured into a reusable image, then deploy new servers from it. Nothing is hosted outside the platform and there is no URL or digest to manage.
POST /api/v1/reseller/services/{serviceId}/capture { "name": "app base" }
→ 202 { "data": { "id": "...", "status": "capturing" } }
GET /api/v1/reseller/images poll until status leaves "capturing"
→ 200 { "data": [ { "id", "status": "available", "size_bytes", "virtual_size_bytes" } ] }
POST /api/v1/reseller/orders { ..., "configuration": { "custom_image_id": "<id>" } }
Three things decide whether an order from an image succeeds:
- Status. Only available can be deployed from. A capture still running is refused with 409 rather than queued, because the file does not exist yet. - Location. The image is a file on the node the captured server runs on and is never copied between nodes, so servers built from it are created on that same node. Ordering it against a different one is refused — and so is an order when that node has no room left (409, naming the node). Every server you sell from one image lands on one machine, so when you are building a fleet from a single image, expect to capture it again elsewhere once that node fills. - Disk size. virtual_size_bytes is the disk the image needs. A plan smaller than that is refused at order time, not at build time.
The server keeps running while it is captured. With the QEMU guest agent installed its filesystems are frozen for the instant the snapshot is taken, so the image is filesystem-consistent; without it the image is crash-consistent, which boots but captures anything mid-write as it was.
Storage is charged monthly by the SERVER DISK the image represents — a 40 GB VPS is billed as 40 GB however well it compressed — prorated by the day, when the supplier has priced it. size_bytes still reports what the file actually occupies; virtual_size_bytes is the billed figure.
The charge is collected once a month, in whatever document the supplier's billing is configured to produce: an invoice, a proforma, or a line on the monthly summary. DELETE /images/{imageId} stops the charge, settles the days the image was stored, and leaves every server already built from it untouched — so deleting mid-month costs those days and nothing more.
What the copy contains. It is the whole disk as it stands: SSH host keys, authorized_keys, logs, shell history, anything saved on it. Clean what should not reach every server built from the image before you capture — cloud-init clean --logs and truncating /etc/machine-id are the usual two.
The service document
Your customer should see the same service page on your storefront as they would see on the supplier's own panel: the dedicated server's rack and hardware health, the VPS snapshots, the IP transit port and BGP session, the colocation cross-connects, the add-ons and the upgrade paths. Rather than teaching you a dozen per-kind routes, GET /services/{id}/document returns that whole page in one read:
{ "kind": "dedicated",
"serviceId": "…", "generatedAt": "2026-09-11T10:00:00.000Z",
"service": { "id": "…", "hostname": "…", "status": "active", "serverIp": "…", "serverLocation": "…", "billingCycle": "monthly",
"nextDueDate": "…", "productName": "…", "productSpecs": {}, "osImage": { "name": "…", "logoUrl": null } },
"capabilities": { "backend": "dedicated", "power": true, "console": true, "reinstall": true, "password": true, "backups": false },
"features": { "power": true, "console": true, "reinstall": true, "password_reset": true, "stats": true,
"backups": false, "snapshots": false, "subnets": true, "nics": true, "storage": true, "location": true,
"traffic": true, "hardware_health": true, "rdns": true, "rescue": true, "ssh_keys": true },
"dedicated": { "specs": {}, "location": {}, "datacenter": {}, "rack": {}, "provisioning": {}, "os_installation": {}, "nics": [], "storage": [] },
"network": { "subnets": [], "ipAddresses": [], "allocations": [], "rdns": [] },
"options": { "priceBasis": "wholesale", "active": [], "available": [] },
"upgrades": { "available": [], "addons": null, "pendingUpgrade": null } }
kind is one of dedicated, vps, ip-transit, colocation, game, plugin, generic and names the section to render (dedicated, vps, ipTransit, colocation, game, plugin); the other kind sections are absent. features are the supplier's own panel switches — render a panel only when its switch is true. options and upgrades are priced at what you pay (priceBasis: "wholesale"), so add your margin before showing them.
The parts that hit hardware are separate reads, because they are slow and you will poll them:
- GET /services/{id}/traffic?range=24h — bandwidth samples with a summary (average, peak, 95th percentile) and the month's total; range is 1h, 6h, 24h, 7d or 30d. Native VPS, dedicated servers and IP transit. samples[].in / .out are bits per second; monthTotal.total_bytes_in / .total_bytes_out are bytes month-to-date. A VPS adds daily[] ({ day, rx_bytes, tx_bytes }) from the nightly rollup, which is the series to bill or quote from, and collecting: true while it has no samples yet. - GET /services/{id}/health — temperatures, fans and power supplies from the management controller, cached for 60 seconds. Dedicated only. - GET /services/{id}/network — subnets, addresses (with gateway and rDNS), IPAM allocations. Every kind. - PUT /services/{id}/rdns { ipAddressId, hostname } — sets reverse DNS on one of the server's addresses; hostname: null clears it. Dedicated only. - GET /services/{id}/ssh-keys, POST { sshKeyId } or { publicKey, name }, DELETE .../ssh-keys/{keyId} — keys applied on the server's next reinstall. Dedicated only. - GET /services/{id}/options and GET /services/{id}/upgrades — the same two sections of the document on their own, for pages that refresh them after a purchase. - POST /services/{id}/rescue boots a dedicated server into the rescue environment and returns the one-time rescue password; POST /services/{id}/unrescue reboots it into the installed OS. Only when the supplier allows rescue, and only from a settled provisioning state. - PUT /services/{id}/transit-config { customerAsn, irrAsSet, announcedPrefixes[], routeType } — the BGP fields the supplier lets customers edit themselves on an IP transit service; 403 when self-service editing is off.
Add-ons after the sale
Your customer buys an add-on on a running service in your storefront; the service that has to grow lives here. PUT /services/{id}/options states the add-ons the service must now carry — an absolute set, not a delta:
{ "options": [ { "optionId": "…", "quantity": 2 }, { "optionId": "…" } ],
"externalReference": "your-idempotency-key" }
externalReference (≤ 90 characters) is an idempotency key for this change, kept in its own namespace so it never collides with an externalOrderId. Repeating a request whose charge already went through replays that result (idempotent: true, nothing charged again); repeating one whose charge failed points you at the order to retry. The programme minimum order amount does not apply to add-on changes.
The service converges on it: options you no longer list are removed, listed ones are added or their quantity adjusted. Additions and top-ups are charged to you at wholesale — the first cycle on the service's own billing cycle, plus the add-on's setup fee — through your payment mode, like an order. Removals and decreases are free and immediate; there is no refund, the next renewal just bills less. The answer names what changed (added, increased, decreased, removed, unchanged), the live rows after the change (options[]) and the money (charge, or null when nothing was owed):
- 200 — applied; paid, or nothing to pay. - 202 — invoice mode: the document is raised, additions wait as pending_payment and activate when it is paid (you get order.paid). - 402 — the charge failed: nothing changed. charge.orderId is an order like any other — POST /orders/{orderId}/retry takes the payment again and then applies the whole change.
DELETE /services/{id}/options/{serviceOptionId} drops one add-on (active[].id from GET .../options). Free, immediate, orders scope.
An add-on is delivered to the server the moment it is active — the same path a customer's own purchase takes on this platform.
Game servers
A game server sold through you gets the whole game panel: files, backups, schedules, mods, SFTP, console, start-up variables. Every /services/{id}/game/* route above is the platform's own client route, served by the same handler, so the shapes are the game module's and a storefront that mirrors the client panel needs no translation layer:
- GET /services/{id}/game — { server, capabilities }, the document the panel renders from; capabilities says which panels the server offers. - files — GET .../files?path=, GET .../files/read?path=, GET .../files/download?path= (binary) and POST .../files/{op} for write, mkdir, rename, delete, compress, decompress, chmod, download-url and upload ({ path, name, content }, content base64, 4 MiB). - backups, schedules and mods — list / create / edit / delete / restore / run, ids in the path; DELETE and restore on a backup need destructive. - POST /services/{id}/game/console — a console session with origin and wsOrigin: open wsOrigin + ws_path + ?token= from your own viewer; the socket lives on this platform (or the cluster owner's), never on yours.
Plugin-provisioned services
A service built by one of the supplier's provider plugins renders its own page: GET /services/{id}/provider names the plugin (provider: null for native VPS, bare metal and the rest), GET /services/{id}/ui-schema is the schema the platform's own client page draws from (data.schema: null with useDefaultUI: true when the plugin ships none — fall back to the generic controls), and POST /services/{id}/ui-action/{action} { params } runs a panel action. The answers are the plugin handlers' own envelopes, unchanged.
Console from your own panel
GET /services/{id}/console gives you a session shaped for a client you write yourself. POST /services/{id}/console-session is for the other case: your storefront runs the same viewer the supplier's panel runs, and only needs to know where to connect. The answer carries sessionId, token, wsPath, viewerKind and — the part that matters — origin, the supplier's own address. The websocket lives on the supplier, never on your platform; open the viewer against origin and the session is authenticated by token alone. Sessions are one-shot and expire in minutes, so create one at click time.
Cast a console into a page you do not have to build
Both of the above assume you ship a VNC client. POST /services/{id}/console-cast assumes you do not: it returns a URL on the supplier that renders the console and nothing else.
POST /services/{serviceId}/console-cast
{ "label": "web-01 rescue", "viewOnly": false,
"openTtlSeconds": 900, "viewerTtlSeconds": 3600 }
200 { "success": true, "message": "Console link created",
"data": {
"id": "8e1c…", // revoke with this
"url": "https://supplier.example/console/cast/2Hs…",
"ticket": "2Hs…", // returned ONCE, it is in the url
"viewOnly": false,
"openExpiresAt": "2026-09-14T10:15:00Z",
"viewerTtlSeconds": 3600,
"viewerExpiresAt": null, // stamped on first open
"status": "pending" } }
Open it in a tab, put it behind a button in your panel, or <iframe> it. The person opening it needs no account with the supplier — the URL is the credential, so treat it like a password: send it over a channel you trust, and revoke it when the job is done.
The page is the browser VNC viewer: a native VPS shows the VM's screen, a dedicated server shows what the management controller is showing. It carries a status line, a Ctrl+Alt+Del button, full screen, and a reconnect — nothing else, no panel, no branding of yours. A cast is always opened in that mode, whatever the supplier's default console mode is.
Two clocks. openTtlSeconds (default 900, 60–604800) is how long the link may be opened for the first time. viewerTtlSeconds (default 3600, 300–43200) starts at that first open and is the whole life of the viewing window: inside it the same link may be re-opened, and each open mints a fresh console session. That is what makes a page refresh, a reconnect and a dropped Wi-Fi survivable — a console session itself lives only minutes.
viewOnly: true mints a read-only link: the screen streams, keyboard and mouse do not reach the machine. It is enforced by the supplier's relay, not by the page, so a holder who points their own VNC client at it still cannot type. A VPS hosted on a shared cluster (one the supplier resells from a third party) answers 400 for a read-only link — the stream is minted on the owner's panel, which cannot be told to make it read-only.
GET /services/{id}/console-cast lists the links on that service that are still live (never their URLs — those exist once, at creation), each with status (pending / open / expired / revoked) and both timestamps. DELETE /services/{id}/console-cast/{castId} kills one; it is idempotent, and it stops the link minting anything further. It does not cut a stream already open on it — close that with the console session itself, or let the window run out.
Refused with 400 for anything that is not a VPS or a dedicated server, 403 when the supplier has the console switched off for that product, and 404 for a service that is not yours. Every failure at open time — unknown, expired, revoked, or a service that has since been terminated — answers the person holding the link with the same 404 and the same sentence: a link is handed to third parties, and which of those it is would be free enumeration signal.
Sessions opened this way are audited to you: the supplier's console log shows the reseller who created the link, with the role cast.
Customers
Customer records are optional: an order carrying externalCustomerId creates or reuses one automatically (with orders scope alone). The customers scope is for managing them directly.
- POST /customers { "externalCustomerId" (required, ≤100), "externalCustomerEmail", "externalCustomerName" } — idempotent on externalCustomerId; always 200 with the record. When the record already exists it is returned unchanged: a repeat call does not update the email or the name. Use PUT for that. - PUT /customers/{id} — externalCustomerEmail, externalCustomerName, metadata (an object; replaces the stored metadata wholesale). Only the fields you send are touched. 404 when the record is not yours. - DELETE /customers/{id} — a hard delete of the customer record. It is refused with 400 while any service attributed to that customer is anything other than terminated or cancelled; the message counts them (*"Cannot delete a customer with N non-terminated service(s); terminate them first"*). Once it goes through, nothing attached is destroyed: past orders and terminated services survive, detached from the customer (their external_customer_* fields become empty), and any API key bound to that customer is revoked — a delegated key stops authenticating from that moment. There is no undo and no way to re-attach the history to a new record. - GET /customers — ?page= · ?limit= (max 100) · ?search= (matches external id, email or name). Records are snake_case: id, external_customer_id, external_customer_email, external_customer_name, metadata, created_at, service_count. A customer-bound key sees only its own record, and that projection carries no service_count.
Account
GET /payment-methods lists your saved methods on the supplier (id, type, label, cardBrand, cardLastFour, isDefault, lastUsedAt); PUT /payment-methods/{id}/default picks the one every auto-charge lands on. Methods are added in the supplier's client panel, not through the API.
Webhooks
Set your endpoint with PUT /account/webhook ({ "webhookUrl": "https://…" } — an explicit scheme and a public host; private and loopback addresses are refused, both at the URL you submit and again at delivery time, when the host is resolved and every address checked) or in the portal, then verify deliveries as shown in the signing guide. Set it before you place orders: events raised while no URL is on file are dropped, not queued.
POST /webhooks/test queues a webhook.test delivery; with no URL on file it answers 400 No webhook URL configured. It confirms only that the delivery was queued — the result of the attempt is not readable through the API.
| Event | Fires when |
|---|---|
order.paid | An order was charged successfully (also after a successful retry) |
order.invoiced | An invoice or proforma was raised instead of an immediate charge |
order.payment_failed | The charge did not go through |
service.provisioned | A service reached active |
service.provisioning_failed | Creation or provisioning failed after payment |
service.suspended | Suspended, by you or by the operator |
service.unsuspended | Suspension lifted |
service.terminated | Terminated, by you or by the operator |
webhook.test | You called POST /webhooks/test |
Order events carry orderId, orderNumber, externalOrderId, externalCustomerId, status, paymentMode and, where known, amount, vat, total, currency, invoiceId, paymentError, provisioningError; a service.provisioning_failed raised after the order was accepted also lists the affected services[], and one raised when a build fails later (on the node, after order.paid) carries serviceId, provisioningError and stage. order.paid is also sent when the document of an invoiced order is paid. Service events carry serviceId, orderId, externalOrderId, externalCustomerId, status, serverIp, hostname and, where known, reason. The delivery body is { "event", "timestamp", "data" }.
Delivery is at-least-once. The queue is swept once a minute; deliveries for one reseller are attempted in creation order and never overtake each other within that sweep, but a delivery that fails is retried later while newer ones go out, so treat ordering as best-effort and key your handling on the event and the service state, not on arrival order. Dedupe on X-Webhook-Id. Failed deliveries retry with exponential backoff (60 s doubling, capped at one hour) up to the configured attempt limit — by default 20 attempts, about fifteen hours — then stop; the supplier's operator can re-queue a delivery that gave up. Only the first 64 KB of your endpoint's response body is read. Delivery is paused while your profile is not active, and resumes — including the backlog — when it is reactivated.
Redirects are not followed — your endpoint must be a final URL, and must answer within 30 seconds.
Known gaps
Being straight about what is not there yet, so you do not design around it:
- No renewal or invoice events. There is no service.renewed, invoice.created or invoice.due. Today the first machine-readable signal of an unpaid balance is service.suspended — after your customers are offline. Watch the invoices in the portal until this lands. - No GET /invoices. Nor any other billing-document endpoint: an order's invoiceId / proformaId identify a document you can only open in the portal. - No delivery history. Neither the API nor your portal shows whether a webhook was delivered, retried or abandoned. Only the supplier can see the attempt log. - No upgrade-status endpoint. After change-package you poll the service. - No sandbox mode. Integration testing places real orders against real payment methods. Use a product priced at zero for the reseller. - No API version negotiation beyond the /v1 path segment. - No read-only cast link for a VPS on a shared cluster. The stream is minted on the panel that owns the hardware, and the peer protocol does not carry the flag, so viewOnly: true is refused there rather than silently ignored. - A cast link cannot close a stream it already opened. Revoking stops the link, not the session behind it.
Reseller API — request signing
Every mutating call to /api/v1/reseller must carry an HMAC signature. This document defines exactly what is signed, with worked vectors, so an integration in any language can reproduce it.
If you are writing a WHMCS module, read this before anything else — getting the signature wrong is the single most common reason a first integration fails, and it fails as a flat 401.
Headers
| Header | Required | Value |
|---|---|---|
Authorization | yes | Bearer rsk_<prefix>_<secret> |
X-Reseller-Signature | on mutations | hmac-sha256=<64 lowercase hex chars> |
X-Reseller-Timestamp | recommended | Unix time, seconds or milliseconds |
Content-Type | on requests with a body | application/json — without it the body is not parsed and the signature cannot match |
"Mutations" means POST, PUT, PATCH and DELETE. GET is never signed. Every one of them is signed — there is no unsigned mutation and no exemption for a "safe" one such as setting the default payment method or sending a test webhook.
The scope check runs before the signature check: a key without the route's scope gets 403 even when the signature is perfect.
Bodies are limited to 1 MB.
What gets signed
The exact bytes of the request body — not a re-encoding of them.
Serialize your payload once, keep that string, send it as the body, and sign that same string. Do not serialize twice; two encoders will not always agree.
signature = hex( HMAC_SHA256( key = signing_secret, message = signed_content ) )
where signed_content is:
With X-Reseller-Timestamp | Without it |
|---|---|
"<timestamp>." + <raw body> | <raw body> |
The timestamp is the header value verbatim — the same characters you send. If you send 1735689600, sign 1735689600.{"a":1}, not 1735689600000.{"a":1}.
For a request with no body (for example DELETE /services/{id}), the raw body is the empty string when you send none, or {} if you send an empty JSON object. Sign whichever you actually transmit — the server verifies a body-less request against both forms. Send a timestamp on body-less requests — without one the signed content is a constant, so a single captured signature would be replayable against that route forever.
The signing secret is the reseller profile's signing secret (also used to sign the webhooks you receive). It is a 64-character hex string, minted the moment you apply to the programme, shown in full in the reseller portal under API Settings and rotatable there. It is not the API key: one signing secret per account, however many keys you hold, and rotating it invalidates the old one immediately for every integration at once.
Why the raw bytes matter
Earlier releases signed a *re-serialization* of the parsed body. That round-tripped byte-identically between two JavaScript services, so FluxBilling-to-FluxBilling worked, but no other language could match it reliably. All three of these differ from V8's output and would have failed:
| Your encoder emits | V8 re-encodes as |
|---|---|
{"url":"https:\/\/x.test"} (PHP default) | {"url":"https://x.test"} |
{"name":"café"} (PHP default) | {"name":"café"} |
{"quantity":1.0} | {"quantity":1} |
Signing the raw bytes removes the whole class of problem: whatever you send is what is verified.
For compatibility during rollout, a signature over the re-serialized form is still accepted, and a deprecation warning is logged server-side. Do not build against that fallback — it will be removed.
Timestamp window
When X-Reseller-Timestamp is present it is enforced: more than 300 seconds of skew in either direction is rejected with 401. Seconds and milliseconds are both accepted and distinguished automatically (a value above 10¹¹ is read as milliseconds). A non-numeric value is a 400.
The header is currently optional so existing integrations keep working. Send it. Without it, a captured request stays replayable indefinitely.
Worked vectors
Secret: topsecret. These digests are real — verify your implementation against them before touching the live API.
1 — body only, no timestamp
raw body {"productId":"p1","quantity":2}
signed content {"productId":"p1","quantity":2}
signature d507d1886ee899eec5da5d6879119341287bfed619bc4620781d6e7511be9cd0
2 — body with a timestamp
raw body {"productId":"p1","quantity":2}
timestamp 1735689600
signed content 1735689600.{"productId":"p1","quantity":2}
signature c2dd5122cfe40dc33bb2c83ae0024db0f6661e0d4ea5cdae20ea2cad184c1dd1
3 — body-less DELETE with a timestamp
raw body (empty string)
timestamp 1735689600
signed content 1735689600.
signature 3dd148f773b8ec61c6a175856cf072395f5d51185b63ca5f4e218a211c87c32d
3b — the same DELETE sending {} instead of no body
raw body {}
timestamp 1735689600
signed content 1735689600.{}
signature 047fcd1bc819b9d1d20d6a57c7ff7b59662dd9171838e4e6abd50f400c575f71
To check on the command line:
printf '%s' '1735689600.{"productId":"p1","quantity":2}' | openssl dgst -sha256 -hmac topsecret
Once the vectors match, make a harmless signed call such as POST /webhooks/test against the live API.
PHP
<?php
function fluxbilling_signed_request(string $base, string $apiKey, string $secret,
string $method, string $path, ?array $payload = null): array
{
$body = $payload === null
? ''
: json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$timestamp = (string) time();
$signature = hash_hmac('sha256', $timestamp . '.' . $body, $secret);
$headers = [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json',
'X-Reseller-Timestamp: ' . $timestamp,
'X-Reseller-Signature: hmac-sha256=' . $signature,
];
$ch = curl_init($base . '/api/v1/reseller' . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 60,
// Never follow a redirect on an API call.
CURLOPT_FOLLOWLOCATION => false,
]);
// Send the SAME string that was signed. Do not re-encode $payload here.
if ($body !== '') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
return ['status' => $status, 'body' => json_decode($raw, true)];
}
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE is no longer *required* now that the raw bytes are signed — but keep it: it makes the body readable in logs and keeps the encoding stable if you ever diff two requests.
The one rule that still matters: $body is built once and used for both the signature and CURLOPT_POSTFIELDS. Re-encoding for the request is the mistake that produces a 401 nobody can explain.
Node.js
import crypto from 'node:crypto';
async function signedRequest(base, apiKey, secret, method, path, payload) {
const body = payload === undefined ? '' : JSON.stringify(payload);
const ts = String(Math.floor(Date.now() / 1000));
const signature = crypto.createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex');
const res = await fetch(`${base}/api/v1/reseller${path}`, {
method,
redirect: 'manual',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'X-Reseller-Timestamp': ts,
'X-Reseller-Signature': `hmac-sha256=${signature}`,
},
body: body === '' ? undefined : body, // send the exact string that was signed
});
return { status: res.status, body: await res.json() };
}
Python
import hashlib, hmac, json, time, requests
def signed_request(base, api_key, secret, method, path, payload=None):
body = "" if payload is None else json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
ts = str(int(time.time()))
sig = hmac.new(secret.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest()
r = requests.request(
method, f"{base}/api/v1/reseller{path}",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"X-Reseller-Timestamp": ts,
"X-Reseller-Signature": f"hmac-sha256={sig}",
},
data=body.encode() if body else None, # the exact bytes that were signed
allow_redirects=False, timeout=60,
)
return r.status_code, r.json()
Verifying inbound webhooks
Deliveries FROM FluxBilling are signed with the same secret, in the opposite direction:
| Header | Meaning |
|---|---|
X-Webhook-Signature | hmac-sha256=<hex> over the raw delivery body |
X-Webhook-Event | event type, e.g. service.provisioned |
X-Webhook-Id | delivery id — stable across retries; use it to dedupe |
X-Webhook-Timestamp | ISO-8601 time of *this attempt* — a retry carries a new timestamp, a new body and a new signature |
User-Agent | FluxBilling-Reseller-Webhook/1.0 |
The body is { "event": "...", "timestamp": "...", "data": { ... } } — the same timestamp as the header.
<?php
$raw = file_get_contents('php://input');
$expected = 'hmac-sha256=' . hash_hmac('sha256', $raw, $secret);
$provided = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if (!hash_equals($expected, $provided)) {
http_response_code(401);
exit;
}
// Delivery is at-least-once: the same X-Webhook-Id can arrive more than once
// after a network timeout. Record it and ignore repeats.
$payload = json_decode($raw, true);
Use hash_equals, not === — string comparison leaks timing.
Respond 2xx within 30 seconds to acknowledge. Any other status, a timeout, or a redirect (redirects are never followed) is a failed delivery. Failed deliveries retry with exponential backoff — by default after at least 60 s, 120 s, 240 s and 480 s (the queue is scanned once a minute), for 5 attempts in total; the supplier can change both numbers — after which the delivery is marked permanently failed and not retried.
Deliveries stop while your profile is not active and resume, backlog included, when it is. Neither the API nor your portal exposes the attempt history — if a delivery is missing, reconcile from GET /orders and GET /services, and ask the supplier to read the delivery log.
Failure modes
| Response | Cause |
|---|---|
401 Missing or invalid Authorization header / 401 Invalid API key format | No Bearer rsk_… header |
401 Invalid API key | Unknown, revoked or expired key — or the reseller profile is not active (pending, suspended or closed); the API does not distinguish these |
401 Request signature required for this operation. Include X-Reseller-Signature header. | No X-Reseller-Signature on a mutation |
400 Invalid signature format | Not hmac-sha256=<hex> |
401 Invalid signature | Digest mismatch, or not 64 hex characters |
400 Webhook secret not configured | No signing secret on the profile — generate one in the portal (Rotate signing secret) |
400 Invalid X-Reseller-Timestamp | The header is not a number |
401 Request timestamp outside the allowed window | More than 300 s of clock skew |
403 API key is missing the required scope: x | Key lacks the scope that route needs (checked before the signature) |
403 Feature not available (code: FEATURE_DISABLED) | The supplier has switched the reseller module off |
429 Rate limit exceeded | See the X-RateLimit-* response headers |
A 401 Invalid signature almost always means the body was re-encoded between signing and sending. Log the exact bytes you passed to both and compare them.
Endpoint index
Every operation, grouped as in the OpenAPI document. The badge on the right is the scope a key needs.
Docs
Documentation routes — no authentication
| GET | /docs | Human-readable documentation page (guide + endpoint index) — no authentication | none |
| GET | /guide.md | Usage guide in Markdown: signing, ordering, lifecycle, webhooks, WHMCS — no authentication | none |
| GET | /openapi.json | This document — no authentication | none |
Products
The wholesale catalogue and what an order form needs
| GET | /products | Wholesale catalogue: every product enabled for you, with your prices per cycle | read |
| GET | /products/{productId} | One product with your wholesale prices | read |
| GET | /products/{productId}/schema | What an order for this product needs: accepted cycles, required fields, OS images, priced locations, plugin form | read |
Orders
Placing orders, idempotency, reconciliation, payment retry
| GET | /orders | List your orders — reconcile with `?externalOrderId=` after a timed-out create | read |
| POST | /orders signed | Place an order. Send `externalOrderId` — it is your idempotency key | orders |
| GET | /orders/{orderId} | One order, with its services and any provisioning error | read |
| POST | /orders/{orderId}/retry signed | Retry payment on a `payment_failed` order; creates the services on success | orders |
Services
Lifecycle (suspend / unsuspend / terminate / change package) and device control
| GET | /services | List your services | read |
| GET | /services/{serviceId} | One service | read |
| DELETE | /services/{serviceId} signed | Terminate — irreversible, and the ONLY call that stops your wholesale billing. Idempotent | destructive |
| POST | /services/{serviceId}/power signed | Power action | services |
| POST | /services/{serviceId}/suspend signed | Suspend your customer's service. Does NOT pause what you owe | services |
| POST | /services/{serviceId}/unsuspend signed | Lift a suspension | services |
| POST | /services/{serviceId}/change-package signed | Move the service to another product (`_ChangePackage`). Asynchronous: 202 with a billing document | services |
| GET | /services/{serviceId}/capabilities | Which controls this service supports — read before offering any button | read |
| GET | /services/{serviceId}/os-templates | Valid `osTemplate` values for a reinstall | read |
| GET | /services/{serviceId}/stats | Resource usage: CPU, memory, disk and network in/out | read |
| GET | /services/{serviceId}/console | Open a one-shot console session (VNC/KVM) — short-lived URL, fetch at click time | services |
| POST | /services/{serviceId}/capture signed | Capture this VPS into a reusable image you can deploy new servers from. The server keeps running. Answers 202 — poll `GET /images` until the image leaves `capturing`. The image is stored on the node the server runs on, so servers you deploy from it are built there too, and its storage is charged monthly by size when the supplier prices it. | services |
| GET | /images | Images you hold, newest first | read |
| DELETE | /images/{imageId} signed | Delete an image and stop its storage charge. Servers already built from it are untouched. | services |
| POST | /services/{serviceId}/reinstall signed | Reinstall the operating system. Destroys data | destructive |
| POST | /services/{serviceId}/password signed | Reset the root password | destructive |
| GET | /services/{serviceId}/backups | List backups / snapshots | read |
| POST | /services/{serviceId}/backups signed | Create a backup / snapshot | services |
| POST | /services/{serviceId}/backups/{backupId}/restore signed | Restore a backup / snapshot. Destroys the current disk state | destructive |
| DELETE | /services/{serviceId}/backups/{backupId} signed | Delete a backup / snapshot | destructive |
| GET | /services/{serviceId}/document | The whole service page in one read: kind, service, capabilities, feature switches, kind section, add-ons and upgrades at wholesale, network | read |
| GET | /services/{serviceId}/traffic | Network in/out over time (VPS, dedicated, IP transit) | read |
| GET | /services/{serviceId}/health | Hardware health from the management controller (dedicated): temperatures, fans, power supplies | read |
| GET | /services/{serviceId}/network | Subnets, addresses, allocations and rDNS records | read |
| PUT | /services/{serviceId}/rdns signed | Set or clear reverse DNS on one of the server's addresses (dedicated) | services |
| GET | /services/{serviceId}/ssh-keys | SSH keys attached to the service — applied on its next reinstall (dedicated) | read |
| POST | /services/{serviceId}/ssh-keys signed | Attach an SSH key: an existing `sshKeyId`, or a `publicKey` (+ `name`) to store and attach in one call | services |
| DELETE | /services/{serviceId}/ssh-keys/{keyId} signed | Detach an SSH key from the service | services |
| POST | /services/{serviceId}/console-session signed | Create a browser console session your OWN viewer can open — the websocket stays on the supplier (`origin`) | services |
| GET | /services/{serviceId}/console-cast | Console links on this service that are still live | services |
| POST | /services/{serviceId}/console-cast signed | Cast the console into a page you do not host — returns a URL to open | services |
| DELETE | /services/{serviceId}/console-cast/{castId} signed | Revoke a console link | services |
| GET | /services/{serviceId}/options | Add-ons on the service and add-ons still available to it, priced at wholesale | read |
| PUT | /services/{serviceId}/options signed | Set the add-ons the service carries (absolute target). Additions and top-ups are charged at wholesale in your payment mode; removals are free | orders |
| DELETE | /services/{serviceId}/options/{serviceOptionId} signed | Remove one add-on from the service. Free and immediate; the next renewal bills less | orders |
| GET | /services/{serviceId}/upgrades | Upgrade paths from this service's product, priced at wholesale — feed `POST .../change-package` | read |
| POST | /services/{serviceId}/rescue signed | Boot a dedicated server into the rescue environment. Returns the one-time rescue password | services |
| POST | /services/{serviceId}/unrescue signed | Leave the rescue environment and reboot into the installed OS | services |
| PUT | /services/{serviceId}/transit-config signed | Self-service BGP edits on an IP transit service: ASN, IRR AS-SET, announced prefixes, route type | services |
Game servers
The game module's customer surface (files, backups, schedules, mods, console, SFTP), served for the reseller by the same handlers as the client panel
| GET | /services/{serviceId}/game | The game server document: `{ server, capabilities }` — the one document the whole game panel renders from | read |
| GET | /services/{serviceId}/game/addons | Slot / RAM / port add-ons the server holds and can take | read |
| PATCH | /services/{serviceId}/game/vars signed | Save start-up variables (`{ vars }`); applied on the next start | services |
| POST | /services/{serviceId}/game/power/{action} signed | Power verb: `start`, `stop`, `restart`, `kill` | services |
| POST | /services/{serviceId}/game/console signed | Open a console session; dial the websocket at `origin` + `ws_path` with `token` | services |
| GET | /services/{serviceId}/game/files | List a directory (`?path=`) | read |
| GET | /services/{serviceId}/game/files/read | Read a text file (`?path=`) | read |
| GET | /services/{serviceId}/game/files/contents | Alias of `files/read` | read |
| GET | /services/{serviceId}/game/files/download | Download a file (`?path=`) as `application/octet-stream` | read |
| POST | /services/{serviceId}/game/files/{op} signed | File operation: `write`, `mkdir`, `rename`, `delete`, `compress`, `decompress`, `chmod`, `download-url`, or `upload` with `{ path, name, content }` (base64) | services |
| GET | /services/{serviceId}/game/sftp | SFTP host, port and username | read |
| POST | /services/{serviceId}/game/sftp/rotate signed | Rotate the SFTP password; the new one is returned once | services |
| GET | /services/{serviceId}/game/backups | Backups and the quota | read |
| POST | /services/{serviceId}/game/backups signed | Take a backup (`{ name?, ignore? }`) | services |
| PATCH | /services/{serviceId}/game/backups/{gameBackupId} signed | Rename / lock a backup | services |
| DELETE | /services/{serviceId}/game/backups/{gameBackupId} signed | Delete a backup | destructive |
| POST | /services/{serviceId}/game/backups/{gameBackupId}/restore signed | Restore a backup over the live files | destructive |
| GET | /services/{serviceId}/game/schedules | Scheduled tasks | read |
| POST | /services/{serviceId}/game/schedules signed | Create a scheduled task | services |
| PATCH | /services/{serviceId}/game/schedules/{gameScheduleId} signed | Edit a scheduled task | services |
| DELETE | /services/{serviceId}/game/schedules/{gameScheduleId} signed | Delete a scheduled task | services |
| POST | /services/{serviceId}/game/schedules/{gameScheduleId}/run signed | Run a scheduled task now | services |
| GET | /services/{serviceId}/game/calendar | Upcoming runs and wipes | read |
| GET | /services/{serviceId}/game/mods | Installed mods / plugins | read |
| POST | /services/{serviceId}/game/mods signed | Install a mod version | services |
| GET | /services/{serviceId}/game/mods/sources | Mod sources this game supports | read |
| GET | /services/{serviceId}/game/mods/search | Search a mod source (`?source=&q=`) | read |
| GET | /services/{serviceId}/game/mods/versions | Versions of a mod (`?source=&projectId=`) | read |
| GET | /services/{serviceId}/game/mods/project | A mod's project page (`?source=&projectId=`) | read |
| POST | /services/{serviceId}/game/mods/upload signed | Adopt an uploaded mod file into the mod list | services |
| DELETE | /services/{serviceId}/game/mods/{gameModId} signed | Remove a mod | services |
Plugin UI
A visual plugin's client service page (provider, schema, panel actions) for plugin-provisioned services
| GET | /services/{serviceId}/provider | The visual-plugin provider behind the service (`provider: null` on native / bare-metal) | read |
| GET | /services/{serviceId}/ui-schema | The plugin’s client service-page schema, if it ships one | read |
| POST | /services/{serviceId}/ui-action/{action} signed | Run a plugin panel action (`{ params }`) — the same actions the client page runs | services |
Customers
Your end-customer records — optional, used to attribute orders and to bind delegated keys
| GET | /customers | List your customer records (a delegated key sees only its bound customer) | read |
| POST | /customers signed | Create a customer record, or return the existing one for this `externalCustomerId` | customers |
| PUT | /customers/{customerId} signed | Update a customer record | customers |
| DELETE | /customers/{customerId} signed | Delete a customer record. Refused (400) while the customer has a non-terminated service; history is detached and kept | customers |
Account
Saved payment methods and webhooks
| GET | /payment-methods | Your saved payment methods on the supplier | account |
| PUT | /payment-methods/{methodId}/default signed | Choose the saved method every auto-charge lands on | account |
| PUT | /account/webhook signed | Set the URL webhook deliveries are sent to | account |
| POST | /webhooks/test signed | Queue a `webhook.test` delivery to your webhook URL | account |