Skip to content

API conventions

These rules hold for every operation of /api/v1. The API reference documents each operation’s parameters, bodies and error codes.

  • 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-ID header (you may send your own, up to 64 characters of A-Z a-z 0-9 . _ -); quote it when you contact support.

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"
}
}
  • code is stable and machine-readable: branch on it, not on title or detail (which are for people and may be reworded).
  • request_id correlates with our logs.
  • error is 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).

Collections that can grow without bound are paginated with an opaque cursor:

  • limit: page size, 1–200 (default 50);
  • cursor: the next_cursor of the previous page;
  • the answer is {"items": [...], "next_cursor": "…"}; next_cursor is null on 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:

Terminal window
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" ] || break
done

The CLI does the same with --all (pluspanel accounts list --all -o json).

Filters are query parameters, documented per operation in the reference. For example, the audit log:

Terminal window
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"

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, 408 and 429 answers are not stored, so you can retry them with the same key. An answer larger than 1 MiB is not stored either (its replay is 409 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.
Terminal window
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))
done
  • Every GET that answers a JSON body carries a strong ETag. Send it back in If-None-Match and an unchanged resource answers 304 Not Modified with no body.
  • A PUT or PATCH honours If-Match: if the resource changed since you read it (its current GET, read with your own credential and permissions, has another ETag), the answer is 412 precondition_failed and nothing is changed. Read again, re-apply your change, retry. A PUT or PATCH whose path has no GET answers 412 when If-Match is sent. The CLI sends it with --if-match.
  • Operations that support this are marked x-pluspanel-etag: true in the reference.
Terminal window
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.json

This 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.

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: 600
RateLimit-Remaining: 598
RateLimit-Reset: 1
RateLimit-Policy: 600;w=60

When 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.