All documentation

Reseller API

Drive your reseller account from your own software with a scoped management key — create managed customers, issue their inference keys and allocate quota out of your inventory.

Three credentials, none interchangeable

A browser session is what signing in to this website gives you. It can do everything your account is permitted to do, including creating and revoking management keys.

An sk- inference key belongs to one of your customers and calls the inference gateway. It cannot reach this API.

An sk-spm- management key is your own automation credential for this API. It cannot make a model request and it cannot manage other management keys — creating and revoking those stays with a signed-in session on the management keys page .

Scopes are fixed when a key is created. There is no rotate endpoint and no way to widen a key later: to change what a key can do, create a replacement and revoke the old one.

Base URL

Every endpoint below lives on the control plane, under one prefix:

https://sp-cambo.store/api/v1/reseller-management

This is not the inference host. Model requests go to the gateway, which is documented under base URLs .

Authentication

Send the management secret as a bearer token. This surface accepts no other scheme — an x-api-key header is ignored and the request is refused as unauthenticated.

bash
curl https://sp-cambo.store/api/v1/reseller-management/customers \
  -H "Authorization: Bearer sk-spm-your-management-key"

A 401 invalid_management_key means the key itself is not usable. Several unrelated causes produce it, and they are deliberately not distinguished in the response:

  • the token is absent, or does not begin with sk-spm-;
  • no key matches it, it has been revoked, or it is past its expiry;
  • your reseller account is no longer active;
  • your account no longer holds the reseller permission — in which case every management key you hold stops working at once, not just this one.

Each accepted call stamps the key's last used time, which is visible on the management keys page. If a key you expect to be busy shows nothing, it is not reaching the API.

Scopes

Every endpoint requires one scope, and a key holding the wrong one is refused with 403 insufficient_scope before the request is processed.

Scope What it authorises
customers:readGET /customers
customers:writePOST /customers
keys:readGET /customers/{id}/api-keys
keys:writePOST /customers/{id}/api-keysPOST /customers/{id}/api-keys/{keyId}/revoke
allocations:writePOST /customers/{id}/allocations
allocations:read Nothing. The scope can be granted, but no endpoint on this surface reads it yet.
usage:read Nothing. The scope can be granted, but no endpoint on this surface reads it yet.

Grant the narrowest set that does the job. A worker that only reads customers has no reason to hold allocations:write, which can move units out of your inventory.

Managed customers

GET /customers

Requires customers:read. Returns every customer you manage, oldest first.

bash
curl https://sp-cambo.store/api/v1/reseller-management/customers \
  -H "Authorization: Bearer sk-spm-your-management-key"
200 application/json
{
  "data": [
    {
      "id": "41",
      "name": "Sokha Dev Team",
      "email": "team@example.com",
      "label": "Sokha — retainer",
      "status": "ACTIVE",
      "created_at": "2026-08-14T02:31:08+00:00"
    }
  ]
}

POST /customers

Requires customers:write. Creates a real SP Cambo account that the customer can sign in to, and links it to you.

bash
curl https://sp-cambo.store/api/v1/reseller-management/customers \
  -H "Authorization: Bearer sk-spm-your-management-key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Sokha Dev Team",
    "email": "team@example.com",
    "label": "Sokha — retainer",
    "password": "<a-strong-generated-password>",
    "password_confirmation": "<a-strong-generated-password>"
  }'
  • name — required, up to 255 characters.
  • email — required, and unique across all of SP Cambo. Someone who already has an account cannot be created as your customer; the response is 422 validation_failed.
  • password and password_confirmation — required and identical. At least 12 characters with upper and lower case, a number and a symbol.
  • label — required, up to 150 characters. Your own reference, not shown to the customer.

Responds 201 with the same shape as the list. The password is never returned and never recoverable. Generate one per customer, deliver it over a channel you trust, and tell them to change it — anything you keep is a credential you are now responsible for.

A new customer has no entitlement and no keys. Until you allocate quota, their requests are refused for lack of balance.

Customer inference keys

GET /customers/{id}/api-keys

Requires keys:read. Newest first. Never includes a secret — only the prefix and last four characters, which is enough to match a key against your own records.

bash
curl https://sp-cambo.store/api/v1/reseller-management/customers/41/api-keys \
  -H "Authorization: Bearer sk-spm-your-management-key"

status is one of ACTIVE, DISABLED, REVOKED or EXPIRED. EXPIRED is derived from the expiry date at read time, so a key can report it without anything having been written.

POST /customers/{id}/api-keys

Requires keys:write.

bash
curl https://sp-cambo.store/api/v1/reseller-management/customers/41/api-keys \
  -H "Authorization: Bearer sk-spm-your-management-key" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "Sokha production",
    "allowed_model_aliases": ["<your-model-alias>"],
    "expires_at": "2027-01-31T23:59:59Z"
  }'
  • label — required, up to 100 characters.
  • allowed_model_aliases — optional. Each entry must be a currently published alias; one that is not gives 422 validation_failed. Omitting the field grants every published alias, which is the widest scope available, so send the list explicitly unless you mean that.
  • expires_at — optional, and must be in the future.
201 application/json
{
  "data": {
    "key": {
      "id": "918",
      "label": "Sokha production",
      "prefix": "sk-",
      "last_four": "4f2b",
      "status": "ACTIVE",
      "created_at": "2026-08-22T04:10:55+00:00",
      "last_used_at": null,
      "expires_at": "2027-01-31T23:59:59+00:00",
      "allowed_model_aliases": ["<your-model-alias>"]
    },
    "secret": "sk-…shown-exactly-once…"
  }
}

secret appears only in this reseller API response; the reseller API cannot fetch it again. The customer-owned inference key keeps its normal lookup hash plus encrypted owner-recovery copy, so the customer can later use Copy key from their own authenticated dashboard. If your reseller automation needs the secret for delivery, capture the creation response — it cannot use the customer's owner-reveal endpoint.

POST /customers/{id}/api-keys/{keyId}/revoke

Requires keys:write. Takes no body, and is safe to retry: revoking an already-revoked key changes nothing and still answers 200 with the key.

bash
curl -X POST https://sp-cambo.store/api/v1/reseller-management/customers/41/api-keys/918/revoke \
  -H "Authorization: Bearer sk-spm-your-management-key"

Revocation is immediate and permanent. It does not refund or reclaim anything — units already allocated to that customer stay with the customer.

Allocating quota

POST /customers/{id}/allocations

Requires allocations:write. This is a transfer, not a purchase: the units come out of entitlement you already own and are not billed again.

bash
curl https://sp-cambo.store/api/v1/reseller-management/customers/41/allocations \
  -H "Authorization: Bearer sk-spm-your-management-key" \
  -H "Content-Type: application/json" \
  -d '{
    "billing_mode": "TOKEN_QUOTA",
    "public_model_alias": "<your-model-alias>",
    "units": 500000,
    "idempotency_key": "onboarding-41-2026-08-22",
    "reason": "Initial allocation for the August retainer."
  }'
  • billing_modeTOKEN_QUOTA or CREDIT_BALANCE. It must match the inventory you are spending from.
  • public_model_alias — required. You can only allocate an alias your own lots already cover; if none of your inventory permits it, the call fails for insufficient balance even when your totals look sufficient.
  • units — a whole number of at least 1.
  • idempotency_key — required, up to 191 characters. See below.
  • reason — required, 10 to 2,000 characters. It is written to the audit trail on both sides of the transfer.
201 application/json
{
  "data": {
    "id": "77",
    "customer_id": "41",
    "billing_mode": "TOKEN_QUOTA",
    "public_model_alias": "<your-model-alias>",
    "units": "500000",
    "created_at": "2026-08-22T04:12:03+00:00"
  }
}

units comes back as a string. Unit counts can exceed what a JavaScript number holds exactly, so the API never rounds one for you.

What the transfer actually does

  • Soonest-expiring inventory is spent first. Lots with an expiry date go before lots without one, earliest first. A single allocation can draw on several of your lots.
  • The new lot inherits its source's expiry. An allocation cannot outlive the entitlement it came from, so allocating from a lot that expires next week hands the customer units that expire next week.
  • The customer's lot is scoped to the one alias you named, even if your source lot allowed several.
  • It is all or nothing. If your available units — remaining minus anything reserved for in-flight requests — do not cover the full amount, nothing moves and you get 402.
  • There is no un-allocate. Units that have moved belong to the customer. Revoking their keys does not bring them back.

Idempotency

idempotency_key is required because a retried allocation must not transfer twice. Choose a value your automation can reproduce for the same intent — an onboarding step id, or a customer id and period.

  • Reusing a key with identical inputs returns the original transfer and moves nothing further. This is what makes a timeout safe to retry.
  • Reusing a key with any different input — a different customer, mode, alias or unit count — is refused with 409 idempotency_conflict. It is not treated as a new transfer, because one of the two calls is a bug and guessing which would be worse.

Keys are checked across your whole account, so generate them per intent rather than per attempt.

Errors

Every failure is JSON with a message and a stable machine code. Branch on the code, never on the prose.

Status Code When
401invalid_management_keyThe bearer token is missing, does not start with sk-spm-, is unknown, has been revoked, has expired, or your reseller account has been suspended or has lost the reseller permission.
403insufficient_scopeThe key is valid but does not hold the scope this endpoint requires. Scopes cannot be added to an existing key — create a replacement.
404not_foundThe customer or key id is not one you manage, or the managed customer is not ACTIVE. Another reseller's ids are indistinguishable from ids that do not exist.
422validation_failedA field is missing or malformed. The errors object names each field.
402insufficient_tokens / insufficient_creditsYour own inventory does not cover the allocation. Nothing is transferred.
409idempotency_conflictThe idempotency_key was already used with different inputs.
429rate_limit_exceededMore than 60 requests in a minute. Retry-After tells you how long to wait.

404 is also the answer for an id belonging to another reseller. That is deliberate: a distinguishable response would let anyone enumerate other resellers' customers.

The inference gateway has its own, separate set of codes, listed under errors .

Rate limit

This surface allows 60 requests per minute, counted per reseller account rather than per key — running two workers on two keys does not double it. Exceeding it gives 429 rate_limit_exceeded with a Retry-After header; honour it instead of retrying immediately.

Creating a customer or a key from a signed-in browser session is limited far more tightly than this. If you are batching, use a management key. Rate limits covers the gateway's separate per-key limits.

Not on this surface yet

So that you do not design an integration around something that does not exist:

  • No usage endpoint. usage:read can be granted but no endpoint reads it. Per-customer usage is visible to a signed-in session on the managed customers pages.
  • No way to read allocations back. allocations:read is likewise grantable and unused. Record the transfer id from the 201 response; it is the only handle you will get.
  • No suspend or close for a managed customer, and no way to change a customer's label or email. Revoking their keys is the available lever.
  • No enable, disable or rotate for a customer's key — only issue and revoke.
  • No management-key administration. Listing, creating and revoking sk-spm- keys requires a signed-in session by design, so a leaked management key cannot mint more of itself.

These are gaps in the API, not in this page. If your integration needs one of them, say so — a documented gap is easier to prioritise than a guessed one.