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 anyGETrequest.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:
| Limiter | Default | Applies to |
|---|---|---|
| Per-token | 10 req/s, burst 30 | Every authenticated request, keyed by token |
| Per-IP failed-auth brake | 1 req/s | Requests 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:
| Parameter | Default | Notes |
|---|---|---|
limit | 50 | Max 200. A value outside 1-200 falls back to the default. |
offset | 0 | Number 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
| Group | Method + path | Scope |
|---|---|---|
| Identity | GET /me | read |
| Tokens | GET /tokens · POST /tokens · DELETE /tokens/{tokenId} | read / write / write |
| Licenses | GET /licenses · POST /licenses · GET /licenses/{id} | read / write / read |
| Licenses (lifecycle) | POST /licenses/{id}/suspend · .../resume · .../terminate | write |
| Licenses (edition) | POST /licenses/{id}/edition | write |
| Customers | GET /customers · POST /customers · GET /customers/{id} · PATCH /customers/{id} · DELETE /customers/{id} | read / write / read / write / write |
| Transfers | GET /transfers · POST /transfers | read / write |
| Usage | GET /usage | read |
| Statements | GET /statements · GET /statements/{id} · GET /statements/{id}/export | read |
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.