AI AdminPanel Documentation

Partner API

The Partner API lets a hosting company or reseller manage AI Admin Panel licensing programmatically — mint licenses for customers, move a license from one reseller to another, and self-manage the API tokens that authenticate those calls. It's the API behind Partner Center: everything a partner can do by hand in Partner Center, this API does over HTTP.

Base URL: https://api.partners.aiadminpanel.com/v1

Machine-readable contract: the full OpenAPI 3.0 spec — every request/response schema, field, and status code — lives in-repo at api/partnercenter/openapi.yaml. This page is a human-readable tour of the same surface; treat the YAML as the source of truth if the two ever disagree.

Until the self-service partner portal ships (a later phase), tokens are issued by us — contact your AI Admin Panel account contact to get one.

Authentication

Every request carries a bearer token in the Authorization header:

Authorization: Bearer pcp_...

Tokens are prefixed pcp_ and shown exactly once, at creation time (see POST /tokens) — only a SHA-256 hash of the token is stored server-side, so a lost token can't be recovered, only revoked and replaced. A missing, malformed, unknown, or revoked token returns 401.

Scopes

Every token carries a read and/or write scope:

  • read — sufficient for any GET request.
  • write — required for any mutating request (POST / PATCH / DELETE).

Calling a write endpoint with a read-only token returns 403.

Subtree Scoping

Every resource — licenses, customers, transfers, tokens — is scoped to the calling partner's subtree (itself plus any sub-resellers below it). A partner can see and act on only its own subtree.

Anything outside the caller's subtree — a resource that belongs to a different partner entirely, or one that simply doesn't exist — returns a uniform 404. The API never returns 403 or 500 for an out-of-subtree resource, because either of those would leak the fact that the resource exists at all. If you get a 404, treat it as "not yours to see," not necessarily "doesn't exist anywhere."

Rate Limiting

Two independent limiters protect the API:

LimiterDefaultApplies to
Per-token10 req/s, burst 30Every authenticated request, keyed by token
Per-IP failed-auth brake1 req/sRequests that fail authentication, keyed by source IP

The failed-auth brake exists to slow down token-guessing; it only counts failed auth attempts — a successful request never consumes it, so a busy legitimate integration is never throttled by it.

Operators can tune the per-token limits via PC_RATE_RPS / PC_RATE_BURST, and the failed-auth brake via PC_AUTHFAIL_RPS — all three must be set greater than 0 (a 0 or negative value is rejected at startup rather than silently disabling the limiter).

Exceeding a limit returns 429 with a Retry-After header (seconds to wait) and the standard error envelope:

{
  "error": {
    "code": "too-many-requests",
    "message": "rate limit exceeded, retry after 2 seconds"
  }
}

Error Envelope

Every non-2xx response — 4xx or 5xx — uses the same shape:

{
  "error": {
    "code": "not-found",
    "message": "license not found"
  }
}

code is a stable, dash-joined slug safe to branch on in code (not-found, forbidden, unprocessable-entity, internal-error, ...). message is a human-readable description, safe to display to a user.

Pagination

List endpoints (GET /licenses, GET /customers, GET /transfers, GET /tokens) accept two query parameters:

ParameterDefaultNotes
limit50Max 200. A value outside 1-200 falls back to the default.
offset0Number of records to skip, for paging forward.

Results are newest-first. There's no total count in the response — page until you get back fewer rows than limit.

Endpoints

GroupMethod + pathScope
IdentityGET /meread
TokensGET /tokens · POST /tokens · DELETE /tokens/{tokenId}read / write / write
LicensesGET /licenses · POST /licenses · GET /licenses/{id}read / write / read
Licenses (lifecycle)POST /licenses/{id}/suspend · .../resume · .../terminatewrite
Licenses (edition)POST /licenses/{id}/editionwrite
CustomersGET /customers · POST /customers · GET /customers/{id} · PATCH /customers/{id} · DELETE /customers/{id}read / write / read / write / write
TransfersGET /transfers · POST /transfersread / write
UsageGET /usageread
StatementsGET /statements · GET /statements/{id} · GET /statements/{id}/exportread

Identity

GET /me

Identifies the calling partner and the scope of the token used to authenticate. Useful as a first call when wiring up a new integration — confirm which account and scope a token actually grants before you build on top of it.

curl https://api.partners.aiadminpanel.com/v1/me \
  -H "Authorization: Bearer pcp_EXAMPLE1234567890abcdef"

Response: 200 OK

{
  "partnerId": "3b1e4b7a-9f2d-4e2a-8b3a-1a2b3c4d5e6f",
  "name": "Acme Hosting",
  "type": "reseller",
  "scope": "write"
}

Tokens

POST /tokens

Creates a new API token for the calling partner account. Requires a write-scoped token to call. The full secret is returned once, in token — after this response, only tokenPrefix (the first 8 characters after pcp_) is ever shown again.

curl -X POST https://api.partners.aiadminpanel.com/v1/tokens \
  -H "Authorization: Bearer pcp_EXAMPLE1234567890abcdef" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "CI provisioning",
    "scope": "write"
  }'

Response: 201 Created

{
  "id": "b6b0b9b1-2c3d-4e5f-8a9b-0c1d2e3f4a5b",
  "partnerId": "3b1e4b7a-9f2d-4e2a-8b3a-1a2b3c4d5e6f",
  "label": "CI provisioning",
  "tokenPrefix": "aB3dE5fG",
  "scope": "write",
  "createdBy": "root",
  "createdAt": "2026-07-20T12:00:00Z",
  "lastUsedAt": null,
  "revokedAt": null,
  "token": "pcp_aB3dE5fGhIjKlMnOpQrStUvWxYz0123456789"
}

GET /tokens lists every token belonging to the caller (active and revoked, secrets never included); DELETE /tokens/{tokenId} revokes one immediately and permanently.

Licenses

POST /licenses

Issues a new license against a contract, for a customer, both inside your partner subtree. The commercial edition you request (e.g. power-user) is resolved server-side to the underlying keygen policy — this API never asks for, or returns, a keygen policy id directly. Requires a write-scoped token to call.

curl -X POST https://api.partners.aiadminpanel.com/v1/licenses \
  -H "Authorization: Bearer pcp_EXAMPLE1234567890abcdef" \
  -H "Content-Type: application/json" \
  -d '{
    "customerId": "9d8c7b6a-5e4f-3d2c-1b0a-9f8e7d6c5b4a",
    "contractId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
    "edition": "power-user",
    "externalBillingRef": "INV-2026-0042"
  }'

Response: 201 Created

{
  "license": {
    "id": "c1d2e3f4-a5b6-c7d8-e9f0-a1b2c3d4e5f6",
    "partnerId": "3b1e4b7a-9f2d-4e2a-8b3a-1a2b3c4d5e6f",
    "customerId": "9d8c7b6a-5e4f-3d2c-1b0a-9f8e7d6c5b4a",
    "contractId": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
    "keygenLicenseId": "f1e2d3c4-b5a6-9788-1234-567890abcdef",
    "keyMasked": "…7F3A2C",
    "edition": "power-user",
    "state": "active",
    "externalBillingRef": "INV-2026-0042",
    "metadata": "",
    "issuedAt": "2026-07-20T12:00:00Z",
    "terminatedAt": null
  },
  "key": "AAAA-BBBB-CCCC-DDDD"
}

The full license key is returned once, here — afterward, only keyMasked (the last 6 characters) is ever shown again. GET /licenses lists (paginated) every license in your subtree; GET /licenses/{id} fetches one.

Licenses (lifecycle)

POST /licenses/{id}/suspend

Suspends a license immediately (both in keygen and in Partner Center). Requires a write-scoped token to call. .../resume reinstates a suspended license; .../terminate permanently and irreversibly revokes one — all three share this shape.

curl -X POST https://api.partners.aiadminpanel.com/v1/licenses/c1d2e3f4-a5b6-c7d8-e9f0-a1b2c3d4e5f6/suspend \
  -H "Authorization: Bearer pcp_EXAMPLE1234567890abcdef"

Response: 200 OK — the license object, with "state": "suspended".

Licenses (edition)

POST /licenses/{id}/edition

Upgrades or downgrades a license to a different commercial edition by swapping the underlying keygen policy to match. Only an active license can change edition; a suspended or terminated one returns 409. Requesting the license's current edition is a no-op (200, no ledger event). Requires a write-scoped token to call.

curl -X POST https://api.partners.aiadminpanel.com/v1/licenses/c1d2e3f4-a5b6-c7d8-e9f0-a1b2c3d4e5f6/edition \
  -H "Authorization: Bearer pcp_EXAMPLE1234567890abcdef" \
  -H "Content-Type: application/json" \
  -d '{"edition": "power-user-plus"}'

Response: 200 OK — the license object, with "edition": "power-user-plus".

Customers

POST /customers

Creates a customer under your partner account, or under a sub-reseller inside your subtree when partnerId is supplied. Requires a write-scoped token to call.

curl -X POST https://api.partners.aiadminpanel.com/v1/customers \
  -H "Authorization: Bearer pcp_EXAMPLE1234567890abcdef" \
  -H "Content-Type: application/json" \
  -d '{
    "externalRef": "cust-4471",
    "name": "Example Customer GmbH",
    "notes": "Migrated from legacy billing"
  }'

Response: 201 Created

{
  "id": "9d8c7b6a-5e4f-3d2c-1b0a-9f8e7d6c5b4a",
  "partnerId": "3b1e4b7a-9f2d-4e2a-8b3a-1a2b3c4d5e6f",
  "externalRef": "cust-4471",
  "name": "Example Customer GmbH",
  "notes": "Migrated from legacy billing",
  "createdAt": "2026-07-20T12:00:00Z"
}

GET /customers lists (paginated) every customer in your subtree; GET /customers/{id} fetches one; PATCH /customers/{id} updates only the fields you send (omitted fields keep their current value); DELETE /customers/{id} removes one — and returns 409 instead of orphaning a license if the customer still has an active, trial, or suspended license attached.

Transfers

POST /transfers

Re-parents a license's commercial ownership to another partner, customer, and contract — the license's keygen key never changes, only which partner it bills under. Both the license's current owning partner and toPartnerId must be inside your partner subtree. Requires a write-scoped token to call.

curl -X POST https://api.partners.aiadminpanel.com/v1/transfers \
  -H "Authorization: Bearer pcp_EXAMPLE1234567890abcdef" \
  -H "Content-Type: application/json" \
  -d '{
    "licenseId": "c1d2e3f4-a5b6-c7d8-e9f0-a1b2c3d4e5f6",
    "toPartnerId": "5f6e7d8c-9b0a-1c2d-3e4f-5a6b7c8d9e0f",
    "toCustomerId": "0f1e2d3c-4b5a-6978-8012-34567890abcd",
    "toContractId": "7c8d9e0f-1a2b-3c4d-5e6f-7a8b9c0d1e2f",
    "reason": "Reseller acquired by another partner"
  }'

Response: 201 Created

{
  "status": "completed"
}

GET /transfers lists (paginated) every transfer with either end inside your subtree — your own outgoing transfers and any incoming ones from a partner above you.

Usage

GET /usage

Returns your own account's current billing period as a live, in-progress rolling summary — never a subtree rollup; every contract-holding partner is billed directly by us, regardless of tree depth. This is the one place a partner is shown in-progress numbers rather than a finalized document: it's your own current period, recomputed daily (and on request by our staff) as new license activity comes in. Before the first recompute of a new period, this returns empty totals/lines rather than an error.

curl https://api.partners.aiadminpanel.com/v1/usage \
  -H "Authorization: Bearer pcp_EXAMPLE1234567890abcdef"

Response: 200 OK

{
  "periodStart": "2026-07-01",
  "periodEnd": "2026-07-31",
  "computedAt": "2026-07-23T00:05:00Z",
  "totals": [
    { "currency": "EUR", "amountCents": 4645 }
  ],
  "lines": [
    {
      "contractId": "b0000000-0000-0000-0000-000000000001",
      "model": "per_instance_metered",
      "currency": "EUR",
      "quantity": 84,
      "unitPriceCents": 1000,
      "amountCents": 2710,
      "detail": [
        { "licenseId": "c0000000-0000-0000-0000-000000000001", "keyMasked": "****-0001", "days": 31, "amountCents": 1000 }
      ]
    }
  ]
}

See Statements for how the numbers in lines are computed — the same rolling compute backs this endpoint, your dashboard's month-to-date tile, and (once finalized) the reviewable statement itself.

Statements

GET /statements

Lists your account's own statement history — finalized and superseded only. A draft (our working state, recomputed daily) is never included here, even your own current period — use GET /usage for that.

curl https://api.partners.aiadminpanel.com/v1/statements \
  -H "Authorization: Bearer pcp_EXAMPLE1234567890abcdef"

Response: 200 OK

[
  {
    "id": "d0000000-0000-0000-0000-000000000001",
    "periodStart": "2026-07-01",
    "periodEnd": "2026-07-31",
    "revision": 1,
    "status": "finalized",
    "totals": [
      { "currency": "EUR", "amountCents": 4645 }
    ],
    "computedAt": "2026-08-01T00:05:00Z",
    "finalizedBy": "root",
    "finalizedAt": "2026-08-02T09:14:00Z",
    "supersedesStatementId": null
  }
]

Each row omits partnerId (every row already belongs to you) and lines — fetch GET /statements/{id} for line detail. supersedesStatementId points back at the prior revision a correction replaced, when this statement is itself a correction.

GET /statements/{id}

Returns one of your own finalized or superseded statements, including its lines and — for metered contracts — the per-license breakdown behind each line. A draft, or a statement belonging to another partner, is indistinguishable from nonexistent: both return 404, never 403, so existence itself is never revealed.

curl https://api.partners.aiadminpanel.com/v1/statements/d0000000-0000-0000-0000-000000000001 \
  -H "Authorization: Bearer pcp_EXAMPLE1234567890abcdef"

Response: 200 OK — a Statement object shaped like the GET /usage response above, plus id, partnerId, revision, status, finalizedBy, finalizedAt, and supersedesStatementId.

GET /statements/{id}/export

Downloads the full statement as a file, in the format given by the required format query parameter (csv or json). Same visibility rule as GET /statements/{id} — a draft or another partner's statement 404s, never 403. Exporting the same finalized statement twice always produces byte-identical output.

curl "https://api.partners.aiadminpanel.com/v1/statements/d0000000-0000-0000-0000-000000000001/export?format=csv" \
  -H "Authorization: Bearer pcp_EXAMPLE1234567890abcdef" \
  -o statement-2026-07.csv

Response: 200 OK, Content-Disposition: attachment; filename="statement-<partner>-<period>-r<revision>.<format>", body is text/csv or the same JSON document as GET /statements/{id} depending on format.