API conventions
These rules hold for every operation of /api/v1. The API reference documents
each operation’s parameters, bodies and error codes.
Basics
Section titled “Basics”- Base URL:
https://<your panel>/api/v1. The API lives on your panel’s address (including an organization’s own panel host); there is no separate API host. - JSON in and out (
Content-Type: application/json), UTF-8. Times are RFC 3339 in UTC (2026-10-19T09:12:44Z), ids are UUIDs. - HTTPS only. Send
Authorization: Bearer <key>(authentication). - Unknown members in a request body are refused (
422), so typos do not pass silently. - Every answer carries an
X-Request-IDheader (you may send your own, up to 64 characters ofA-Z a-z 0-9 . _ -); quote it when you contact support.
Errors
Section titled “Errors”Errors are RFC 9457 problem details, with the media type
application/problem+json:
{ "type": "https://pluspanel.net/problems/domain_taken", "title": "Conflict", "status": 409, "detail": "shop.example.com is already used by another site on this server", "instance": "/api/v1/accounts/5a5a5a5a-1111-4222-8333-444455556666/sites", "code": "domain_taken", "request_id": "4b7c1e2a-9d3f-4e8a-b1c2-0f9e8d7c6b5a", "error": { "code": "domain_taken", "message": "shop.example.com is already used by another site on this server", "request_id": "4b7c1e2a-9d3f-4e8a-b1c2-0f9e8d7c6b5a" }}codeis stable and machine-readable: branch on it, not ontitleordetail(which are for people and may be reworded).request_idcorrelates with our logs.erroris the original error member of the API, kept so existing clients keep working. It is deprecated: new code should read the top-level members. It stays for all of v1.
Common statuses: 400 malformed request, 401 missing or invalid credentials, 403 not allowed (role
or scope), 404 not found or not visible to you, 409 conflict with the current state, 412
precondition failed, 422 validation failed, 429 rate limited, 5xx server-side (safe to retry with
the same Idempotency-Key).
Pagination
Section titled “Pagination”Collections that can grow without bound are paginated with an opaque cursor:
limit: page size, 1–200 (default 50);cursor: thenext_cursorof the previous page;- the answer is
{"items": [...], "next_cursor": "…"};next_cursorisnullon the last page.
Do not build or edit cursors yourself. Small, bounded collections (plans, PHP versions) answer in full.
Fetching everything with curl and jq:
cursor=""while :; do page=$(curl -fsS -G "$PLUSPANEL_URL/api/v1/accounts" \ -H "Authorization: Bearer $PLUSPANEL_API_KEY" \ --data-urlencode "limit=200" ${cursor:+--data-urlencode "cursor=$cursor"}) jq -c '.items[]' <<<"$page" cursor=$(jq -r '.next_cursor // empty' <<<"$page") [ -n "$cursor" ] || breakdoneThe CLI does the same with --all (pluspanel accounts list --all -o json).
Filtering
Section titled “Filtering”Filters are query parameters, documented per operation in the reference. For example, the audit log:
curl -fsS -G "$PLUSPANEL_URL/api/v1/audit" -H "Authorization: Bearer $PLUSPANEL_API_KEY" \ --data-urlencode "object_type=site" \ --data-urlencode "from=2026-10-01T00:00:00Z" --data-urlencode "to=2026-10-19T00:00:00Z"Idempotency
Section titled “Idempotency”Every mutating POST accepts an Idempotency-Key header (1–255 printable ASCII characters; a UUID
is a good choice). Use a new key for each new operation, and the same key when you retry it.
- The outcome of the first request is stored for 24 hours per key and credential (the API key, else the user).
- A retry with the same key, method, path, query and body replays the stored answer, with the header
Idempotent-Replayed: true. Nothing is done twice. - The same key with a different request answers
422 idempotency_key_reused. - A retry while the first request is still running answers
409 idempotency_in_progress: wait and retry. - Answers that contain a secret shown only once (a new API key, a webhook signing secret, an invitation
link, recovery codes…) are never stored. Their replay answers
409 idempotency_replay_unavailable: the first request succeeded, but the secret is not shown a second time. 5xx,408and429answers are not stored, so you can retry them with the same key. An answer larger than 1 MiB is not stored either (its replay is409 idempotency_replay_unavailable).- A malformed key answers
400 idempotency_key_invalid. The sign-in steps and the device flow ignore the header. The CLI sends it with--idempotency-key.
key=$(uuidgen)for attempt in 1 2 3; do curl -fsS -X POST "$PLUSPANEL_URL/api/v1/accounts/$ACCOUNT_ID/sites" \ -H "Authorization: Bearer $PLUSPANEL_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: $key" \ -d '{"primary_domain": "shop.example.com", "title": "Shop", "admin_email": "owner@example.com"}' \ && break sleep $((attempt * 5))doneETags and preconditions
Section titled “ETags and preconditions”- Every
GETthat answers a JSON body carries a strongETag. Send it back inIf-None-Matchand an unchanged resource answers304 Not Modifiedwith no body. - A
PUTorPATCHhonoursIf-Match: if the resource changed since you read it (its currentGET, read with your own credential and permissions, has another ETag), the answer is412 precondition_failedand nothing is changed. Read again, re-apply your change, retry. APUTorPATCHwhose path has noGETanswers412whenIf-Matchis sent. The CLI sends it with--if-match. - Operations that support this are marked
x-pluspanel-etag: truein the reference.
etag=$(curl -fsS -D - -o site-security.json "$PLUSPANEL_URL/api/v1/sites/$SITE_ID/security" \ -H "Authorization: Bearer $PLUSPANEL_API_KEY" | awk 'tolower($1)=="etag:" {print $2}' | tr -d '\r')# … edit site-security.json …curl -fsS -X PUT "$PLUSPANEL_URL/api/v1/sites/$SITE_ID/security" \ -H "Authorization: Bearer $PLUSPANEL_API_KEY" -H "Content-Type: application/json" \ -H "If-Match: $etag" --data @site-security.jsonThis is optimistic concurrency, not a lock: it protects well-behaved clients from overwriting each other’s changes, but two writers racing within the same instant can both pass.
Rate limits
Section titled “Rate limits”Each API key has a budget of requests per minute: 600 by default, in every edition; a key can be
given a lower limit. The budget refills continuously (a token bucket). Every answer made with a key
says where you stand (RateLimit-Reset is the number of seconds until the budget is full again):
RateLimit-Limit: 600RateLimit-Remaining: 598RateLimit-Reset: 1RateLimit-Policy: 600;w=60When the budget is spent the answer is 429 rate_limited with Retry-After (seconds). Wait that long
before retrying, and spread bulk work instead of sending it in bursts.