# Reseller API & programme — documentation

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

```json
{ "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**:

```json
{ "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:

```json
{ "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`

```json
{
  "productId": "uuid",
  "billingCycle": "monthly",
  "quantity": 1,
  "externalOrderId": "your-own-id",
  "externalCustomerId": "your-own-customer-id",
  "externalCustomerEmail": "customer@example.com",
  "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. |

```json
{
  "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:

```json
{ "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:

```json
{ "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:

```json
{ "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:

```json
{ "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.

```json
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
<?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

```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

```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
<?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.
