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. The public /v1 token API is separate from the browser's /portal/v1 API. It does not expose team administration, sub-reseller creation, contract management, whole-customer-book moves, or root operations. Use Partner Center for those workflows.

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

Machine-readable contract: the bundled OpenAPI 3.0 specification describes the public interface. Use its schemas alongside the behavior documented here; prose and examples are not proof that a route is registered. Download it straight from the API — no token required, so you can read the contract before you have one:

curl -O https://api.partners.aiadminpanel.com/v1/openapi.yaml

Feed it to a client generator (openapi-generator, oapi-codegen, openapi-typescript) or import it into Postman or Insomnia. This page is a human-readable tour of the same surface.

Owners and members create tokens on Partner Center → API Tokens. Billing and read-only portal users cannot list, mint, or revoke tokens. See API tokens.

Authentication

Every protected 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 has one scope, read or write (write also permits reads):

  • 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

Licenses, customers, and transfer history are checked against the calling partner's subtree (itself plus descendants). Transfer creation checks both ends. Tokens are self-managed for the caller's own account, and usage and statements are also own-account rather than subtree rollups.

For an authorized operation, out-of-scope resource lookups use 404 so they do not distinguish another partner's resource from a missing one. Authentication, write-scope checks, validation, and infrastructure failures can return other statuses before a lookup. A 404 does not prove the resource is absent globally.

Identifiers

Every customer, licence and transfer carries two identifiers, and they are for different jobs.

id is a UUID and is the one the API takes: it is what goes in a path, a request body, or a filter. It never changes and it is what you should store as a foreign key.

humanId is the short, readable one — C-10428 for a customer, L-90233 for a licence, T-3391 for a transfer. It exists to be read down a phone line, printed on an invoice, and quoted in a support ticket. It is unique across the whole system (not just your account), assigned once when the record is created, and never changes afterwards — including when a customer or licence is transferred to another partner. The API does not accept it in paths or request bodies.

Customers additionally have externalRef, which is yours: whatever id you use in your own systems, stored so you can reconcile against them. We never generate or interpret it.

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 authentication never consumes that brake. The independent per-token limiter still applies to legitimate integrations.

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"
  }
}

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

The lists GET /licenses, GET /customers, and GET /transfers 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. GET /tokens and GET /statements return arrays without these pagination parameters. Customer activity uses limit and offset; contacts and recent notes have their own behavior below.

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
ContactsGET /customers/{id}/contacts · POST /customers/{id}/contacts · PATCH /customers/{id}/contacts/{contactId} · DELETE /customers/{id}/contacts/{contactId}read / write / write / write
NotesGET /customers/{id}/notes · POST /customers/{id}/notesread / write
ActivityGET /customers/{id}/activity · .../summary · .../exportread
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 ${PARTNER_API_TOKEN}"

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 ${PARTNER_API_TOKEN}" \
  -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": "40000000-0000-4000-8000-000000000004",
  "createdAt": "2026-07-20T12:00:00Z",
  "lastUsedAt": null,
  "revokedAt": null,
  "token": "REDACTED_PARTNER_API_TOKEN"
}

createdBy identifies the token that called POST /tokens; the returned secret above is redacted. PARTNER_API_TOKEN in shell examples is a securely supplied environment variable, not a literal token.

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 and belonging to the same partner. 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 ${PARTNER_API_TOKEN}" \
  -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",
    "humanId": "L-90233",
    "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": "REDACTED_LICENSE_KEY"
}

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

Requests suspension at the license service and records it in Partner Center. A running panel observes license-state changes through its validation cycle; this HTTP response is not evidence that containers have stopped. 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 ${PARTNER_API_TOKEN}"

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 ${PARTNER_API_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"edition": "web-host"}'

Response: 200 OK — the license object, with the selected edition.

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 ${PARTNER_API_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "externalRef": "cust-4471",
    "name": "Example Customer GmbH",
    "legalName": "Example Customer Gesellschaft mbH",
    "vatId": "DE123456789",
    "email": "accounts@example.test",
    "phone": "+49 30 1234567",
    "billingStreet": "Beispielstraße 1",
    "billingPostalCode": "10115",
    "billingCity": "Berlin",
    "billingCountry": "DE",
    "status": "active"
  }'

Only name is required. Everything else is optional and defaults to empty; status defaults to active.

Two fields are validated, and a bad value returns 422 with an explanation rather than a generic error:

  • status must be one of prospect, active, suspended, churned.
  • billingCountry / postalCountry must be a two-letter upper-case ISO 3166-1 code (DE, not de or DEU), or empty.

Response: 201 Created

{
  "id": "9d8c7b6a-5e4f-3d2c-1b0a-9f8e7d6c5b4a",
  "humanId": "C-10428",
  "partnerId": "3b1e4b7a-9f2d-4e2a-8b3a-1a2b3c4d5e6f",
  "externalRef": "cust-4471",
  "name": "Example Customer GmbH",
  "legalName": "Example Customer Gesellschaft mbH",
  "vatId": "DE123456789",
  "email": "accounts@example.test",
  "phone": "+49 30 1234567",
  "billingStreet": "Beispielstraße 1",
  "billingPostalCode": "10115",
  "billingCity": "Berlin",
  "billingCountry": "DE",
  "status": "active",
  "createdAt": "2026-07-20T12:00:00Z",
  "website": "",
  "billingRegion": "",
  "postalStreet": "",
  "postalPostalCode": "",
  "postalCity": "",
  "postalRegion": "",
  "postalCountry": "",
  "ownerEmail": ""
}

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 if any attached license has a state other than terminated or expired (including active, trial, or suspended).

Changed: the customer object no longer has a notes field. Notes are now dated and attributed, and live at POST /customers/{id}/notes — see Notes below. A notes key sent to POST or PATCH /customers is ignored rather than rejected, so an existing integration keeps working; it just stops recording anything. Move those writes to the notes endpoint.

Contacts

A customer is a company. The people you actually deal with there are contacts — many per company, each with their own identifier (P-5042), and at most one flagged isPrimary.

POST /customers/{id}/contacts

curl -X POST https://api.partners.aiadminpanel.com/v1/customers/9d8c7b6a-5e4f-3d2c-1b0a-9f8e7d6c5b4a/contacts \
  -H "Authorization: Bearer ${PARTNER_API_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "firstName": "Ada",
    "lastName": "Lovelace",
    "title": "Head of IT",
    "email": "ada@example.test",
    "phone": "+49 30 7654321",
    "locale": "de",
    "isPrimary": true
  }'

A contact needs a first name or a last name — either alone is enough, but neither returns 422.

Setting isPrimary demotes whichever contact currently holds the flag, in a single transaction. You never have to demote the old one yourself, and a customer can never end up with two.

Response: 201 Created

{
  "id": "1f2e3d4c-5b6a-7908-1a2b-3c4d5e6f7a8b",
  "humanId": "P-5042",
  "customerId": "9d8c7b6a-5e4f-3d2c-1b0a-9f8e7d6c5b4a",
  "firstName": "Ada",
  "lastName": "Lovelace",
  "title": "Head of IT",
  "email": "ada@example.test",
  "phone": "+49 30 7654321",
  "locale": "de",
  "notes": "",
  "isPrimary": true,
  "createdAt": "2026-07-20T12:00:00Z",
  "updatedAt": "2026-07-20T12:00:00Z"
}

GET /customers/{id}/contacts lists them, primary first; PATCH /customers/{id}/contacts/{contactId} updates only the fields you send; DELETE /customers/{id}/contacts/{contactId} removes one.

A contactId that belongs to a different customer returns 404 — the same answer as one that does not exist. The contact id alone is never enough; the customer in the path has to be the right one.

Notes

Notes are append-only. There is no update and no delete, by design: a note records what someone knew at the time, and a note that can be rewritten is worth no more than a field that can be overwritten — which is exactly what this replaced.

POST /customers/{id}/notes

curl -X POST https://api.partners.aiadminpanel.com/v1/customers/9d8c7b6a-5e4f-3d2c-1b0a-9f8e7d6c5b4a/notes \
  -H "Authorization: Bearer ${PARTNER_API_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"body": "Called about the renewal — wants to move to annual billing."}'

Response: 201 Created

{
  "id": 4471,
  "customerId": "9d8c7b6a-5e4f-3d2c-1b0a-9f8e7d6c5b4a",
  "author": "3b1e4b7a-9f2d-4e2a-8b3a-1a2b3c4d5e6f",
  "body": "Called about the renewal — wants to move to annual billing.",
  "at": "2026-07-20T12:00:00Z"
}

The author is set from your credentials and cannot be supplied in the body. Through this API it is your partner id; a note written by a person signed into Partner Center carries their email address instead. An author key in the request body is ignored — an attribution the caller chooses is not an attribution, and this one can never be corrected afterwards.

An empty or whitespace-only body returns 422.

GET /customers/{id}/notes returns the 50 most recent, newest first.

Activity

GET /customers/{id}/activity

The ledger events belonging to this customer, newest first, paginated with ?limit= and ?offset= — licenses issued, suspended, resumed, terminated, transferred in or out.

Events recorded before customer records existed carry no direct customer reference: the ledger is append-only, so they cannot be rewritten. They are reached through the customer's licenses instead, so the history you get back is reachable through that fallback — older entries can have "customerId": null. Activity also accepts event, actor, from, and to filters. An empty actor= selects unattributed events. GET /customers/{id}/activity/summary returns counts; GET /customers/{id}/activity/export exports filtered CSV, capped at 5000 rows with truncation headers. New events can change later exports.

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 ${PARTNER_API_TOKEN}" \
  -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"
  }'

Optional effectiveDate is an RFC3339 timestamp, normalized to its UTC billing date; omit it for today in UTC. The database move precedes best-effort license metadata synchronization, so completion is not a synchronization guarantee.

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 ${PARTNER_API_TOKEN}"

Response: 200 OK

{
  "periodStart": "2026-07-01",
  "periodEnd": "2026-07-31",
  "computedAt": "2026-07-23T00:05:00Z",
  "totals": [
    { "currency": "EUR", "amountCents": 1000 }
  ],
  "lines": [
    {
      "contractId": "b0000000-0000-0000-0000-000000000001",
      "model": "per_instance_metered",
      "currency": "EUR",
      "quantity": 31,
      "unitPriceCents": 1000,
      "amountCents": 1000,
      "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 ${PARTNER_API_TOKEN}"

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": 1000 }
    ],
    "computedAt": "2026-08-01T00:05:00Z",
    "finalizedBy": "root",
    "finalizedAt": "2026-08-02T09:14:00Z"
  }
]

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; it is omitted when there is no predecessor.

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 ${PARTNER_API_TOKEN}"

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. Repeated exports of an unchanged stored statement are deterministic. Its status metadata changes if it is later superseded; retain the original download when you need the earlier document exactly.

curl "https://api.partners.aiadminpanel.com/v1/statements/d0000000-0000-0000-0000-000000000001/export?format=csv" \
  -H "Authorization: Bearer ${PARTNER_API_TOKEN}" \
  -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.