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 anyGETrequest.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:
| 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 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:
| 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. 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
| 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 |
| Contacts | GET /customers/{id}/contacts · POST /customers/{id}/contacts · PATCH /customers/{id}/contacts/{contactId} · DELETE /customers/{id}/contacts/{contactId} | read / write / write / write |
| Notes | GET /customers/{id}/notes · POST /customers/{id}/notes | read / write |
| Activity | GET /customers/{id}/activity · .../summary · .../export | read |
| 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 ${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:
statusmust be one ofprospect,active,suspended,churned.billingCountry/postalCountrymust be a two-letter upper-case ISO 3166-1 code (DE, notdeorDEU), 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
notesfield. Notes are now dated and attributed, and live atPOST /customers/{id}/notes— see Notes below. Anoteskey sent toPOSTorPATCH /customersis 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.