Verified against CloudPress platform release 2026.08.01.
When to Use This Skill
Activate this skill when:
- User asks how to call a specific CloudPress API endpoint
- User asks what parameters an endpoint accepts or what a response looks like
- User asks about authentication, API keys, OAuth, scopes, or the
X-Auth-Account header
- User asks about permissions, roles, or what their token can do
- User is debugging a 400, 401, 402, 403, 404, 409, 422, 429, 502, or 503 from the API
- User asks about async operations, task polling, registrar processes, or completion callbacks
- User asks about billing webhooks (outbound site-subscription lifecycle webhooks configured on a billing plan) — what events fire, the payload shape, or why a domain/registrar event didn't produce one
- User asks about cPanel hosting accounts through the API — ordering, resizing, domains, passwords, control-panel sessions
- User asks about Mailspace mail hosting through the API — ordering or resizing a mailspace, or managing what is inside one: mailboxes, app passwords, mail rules, vacation responses, aliases, groups, mailing lists, masked emails, mail domains and their ownership verification, deleted-mail recovery, or delivery logs
- User asks about a WordPress site's transactional email through the API — sender settings, send logs, mail metrics, or the sender-DNS check
- User asks how to set up, configure, or connect the CloudPress MCP server (e.g. in Claude Desktop/Cowork), what MCP tools exist, or why an MCP token is rejected
- User is building an integration with CloudPress
CloudPress REST API
Authentication
All /api/* requests authenticate via an HTTP Bearer token:
Authorization: Bearer <token>
There are three credential types, all presented as a Bearer token. The server resolves which kind it is (session/API key first, then OAuth).
| Credential |
Identity |
Admin-capable? |
OAuth scopes apply? |
Notes |
| User API key |
A user (accesses accounts that user belongs to) |
If the key's is_admin is set |
No (bypasses scope checks) |
Optional IP access list (allowed_ips). |
| System API key |
An account (system-managed only) |
If is_admin |
No |
system_managed keys only; IP must be on the system access list. The account is resolved from the key's bearer — X-Auth-Account is not required (and is ignored). |
| OAuth 2.1 access token |
A user, bound to one (account, brand) |
Never (OAuth tokens are never admin) |
Yes — fail-closed |
Issued by the OAuth 2.1 authorization server. See OAuth 2.1. |
Account scoping — X-Auth-Account:
X-Auth-Account: <account_guid>
Scopes a user API key to a single account: index endpoints then return only that
account's resources, instead of resources across every account the token's user can
access.
The endpoints that require the header answer 400
{"errors":["Missing X-Auth-Account"],"code":"missing_account"} when it is absent. That
set is exactly:
- every route under
/api/orders (including POST /api/orders/domain),
/api/subscriptions, /api/carts, /api/users, /api/domain_contacts,
/api/domain_registrations (including its nested hosts and processes), and
/api/cpanel_accounts (including its nested password, purge, session and
domains)
POST /api/sso
POST /api/dns_zones — zone create only; its body carries no code key,
just {"errors":["Missing X-Auth-Account"]}
POST /api/mailspace — mailspace create only; it answers 400 with
code: "account_required" and a longer message. The two codes mean the same thing on
different routes. The nested /api/mailspace/:mailspace_id/… routes do not inherit
this: sent without the header, they resolve the mailspace from the ones the token's user
can reach, and an inaccessible guid is a 404 with an empty body — deliberately
indistinguishable from one that does not exist.
Nothing else requires it. In particular the webhook endpoints do not:
GET /api/webhooks/task needs no account at all, and POST /api/webhooks/task/:id has
no header check — called with no account context it fails inside the action and returns
a 500, not a 400.
OAuth tokens always carry an account, so the header is irrelevant for them.
Authentication failures return 401 with header WWW-Authenticate: Token realm="Application" and an empty body. Causes: no/invalid token, IP not on the access list, the resolved account is on trial, or the X-Auth-Account GUID doesn't match an account the token can access. Trial accounts cannot use the API at all (both session and OAuth auth reject them; a user-API-key call with no X-Auth-Account resolves no account and so isn't trial-blocked at auth time).
There are three 401 body shapes:
- the empty body above — also what
DELETE /api/accounts/:id returns when the caller
is refusing to delete their only remaining account;
{"error":"invalid_token","error_description":"..."} for an audience-bound OAuth
token (next paragraph);
{"errors":["This endpoint requires a user-scoped API key."],"code":"user_required"}
from the endpoints that need a user behind the credential (see Scopes).
Audience-bound OAuth tokens: an OAuth access token minted with a resource (RFC 8707 audience, e.g. for /mcp) is rejected at /api/* with 401 {"error":"invalid_token","error_description":"token audience is not valid for /api"}. Only un-audienced (or /api-audienced) tokens work here.
Rate limiting: 600 requests / 10 minutes. Two details that are easy to get wrong:
- The budget is keyed on the client IP, not on the credential — several tokens
behind one egress IP share one bucket.
- It is counted per resource group, not once across all of
/api — sites, domains
and DNS each get their own 600, so exhausting one leaves the others untouched.
Exceeding it returns 429 with an empty body.
The /mcp endpoint is the opposite on both counts: 6000 requests / 10 minutes, keyed on
the OAuth access token (falling back to the client IP when there is no token), so LLM
clients sharing egress IPs don't collide.
OAuth 2.1
CloudPress runs a standard OAuth 2.1 authorization server. Use it for third-party apps acting on a user's behalf; use API keys for first-party/server integrations.
Authorization server metadata (per-brand, derived from request host):
GET /.well-known/oauth-authorization-server
{
"issuer": "https://<host>",
"authorization_endpoint": "https://<host>/oauth/authorize",
"token_endpoint": "https://<host>/oauth/token",
"revocation_endpoint": "https://<host>/oauth/revoke",
"introspection_endpoint": "https://<host>/oauth/introspect",
"registration_endpoint": "https://<host>/oauth/registration",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"scopes_supported": ["sites:read","sites:write","domains:read","domains:write","dns:read","dns:write","mailspace:read","mailspace:write","billing:read","cpanel:read"],
"service_documentation": "https://<host>/docs/oauth"
}
- Grant types:
authorization_code and refresh_token only. PKCE is mandatory (S256 only). Refresh tokens rotate (previous token revoked on use).
- No OpenID Connect — there is no
/.well-known/openid-configuration, no userinfo endpoint, and no ID tokens. The only OIDC-adjacent surface CloudPress exposes is Dynamic Client Registration (below).
- Dynamic Client Registration (DCR, RFC 7591):
POST /oauth/registration. This
server only ever issues public PKCE clients, but it does not reject a
registration that asks for a confidential token_endpoint_auth_method — the
requested value is coerced to "none" and the registration succeeds as a public
client, echoing "none" back with no client_secret. So a client that sends
client_secret_post (as some connector implementations do regardless of the
advertised metadata) registers successfully and then uses PKCE. Per-IP limit: 50
registrations/hour → 429
{"error":"too_many_requests","error_description":"registration rate limit exceeded"}.
- Brand isolation — OAuth tokens only: an OAuth access token is bound to the brand
(hostname) it was issued under and is rejected on any other brand's hostname. The
authorize screen only lists the user's non-trial accounts on the current brand.
API keys and session tokens are not brand-checked — they authenticate on any
brand host.
Scopes
There are no default scopes — every resource scope is opt-in. The complete vocabulary:
| Scope |
Grants |
sites:read |
Site read + nested site reads (show/index, backups list, cache status, CDN status, variants list, tasks, edge-rules list, Shield reads, logs, all four metrics endpoints — resources, CDN, Shield, transactional mail — and the transactional-email overview and send logs). Note POST /api/sites/:id/metrics/cdn is a read despite the POST verb: the body carries the query window, and the scope it requires is sites:read. |
sites:write |
Site + nested site writes (update/destroy, backups, backup exports, cache, restart, restore, edge rules, Shield writes, certificates, site-domain CRUD, variant change, transactional-email sender settings, lifting a transactional-email suspension — the endpoint is a DELETE that clears a hold, it does not apply one — and the sender-DNS re-check). Not site creation — POST /api/sites is a no-op stub returning 400; sites are created by ordering one via POST /api/orders. |
domains:read |
Domains list/show/query/available; domain-registration index/show/check/suggestions; contacts/hosts/processes reads |
domains:write |
Domain-registration mutations; domain-contact create/update/destroy/resend; host/process writes |
dns:read |
DNS zones index/show/dns_stats; DNS records index/show |
dns:write |
DNS zone & record create/update/destroy |
mailspace:read |
Mailspace list/show, and every read inside a mailspace — mailboxes (plus the address-availability check), mailbox app-password list, mailbox mail rules, vacation responses, aliases, groups, mailing lists, masked emails, mail domains with their DNS records and verification status, the purged-mailbox recovery list, archived (deleted) mail including the raw-message download, and delivery logs. This is not a metadata-only scope — read the warning below before granting it. |
mailspace:write |
Mailspace purchase/resize/delete, and every mutation inside a mailspace — mailbox create/update/delete/restore and irreversible force-delete, app-password mint/update/revoke, mail-rule create/update/delete/toggle/move/adopt, vacation responses, aliases, groups, mailing lists, masked emails, mail-domain CRUD with DNS re-check and ownership verification, purged-mailbox restore, archived-mail restore/delete, and the immediate permanent purge of a soft-deleted mailspace. |
billing:read |
Orders index/show; subscriptions index/show; carts show |
cpanel:read |
cPanel account list/show; cPanel account domain list — reads only |
GET /api/about accepts any valid token regardless of scope.
There is no cpanel:write scope and no billing:write scope. Do not read that
as "OAuth cannot spend the account's money" — it can.
What the missing scopes really cover. Direct order placement (POST /api/orders,
POST /api/orders/domain, POST /api/domain_registrations/:id/registrant_change) and
the cPanel writes (account order/resize/cancel, password, purge, control-panel
session, domain add/remove) declare no scope at all, so they fail closed for every
OAuth token. That much is true.
What is NOT covered — read this before granting either scope. Two scopes that do
exist reach a purchase:
mailspace:write → POST /api/mailspace buys a new mailspace, and
PATCH /api/mailspace/:id resizes one.
sites:write → PATCH /api/sites/:id with a plan parameter changes the
site's plan (an up- or downgrade of the recurring subscription).
Each of those builds a cart and charges the account's saved default payment method
off-session — no interactive confirmation step, no separate billing scope, and the
202 comes back whether the charge succeeded or was declined.
Consequence: an OAuth token holding mailspace:write or sites:write can spend the
account's money and change its recurring bill. Treat those two as billing-capable when
deciding what to request or grant. Only the order-placing and cPanel endpoints listed
above are genuinely out of OAuth's reach.
A second consequence — data access, not only money. The two mailspace scopes are
often assumed to cover subscription metadata. They do not:
mailspace:read reaches mail itself. It lists a mailspace's mailboxes, reads its
delivery logs, and — where the mail platform's archive feature is available — lists a
mailbox's deleted mail and downloads a raw message.
mailspace:write reaches irreversible destruction. Mailbox force-delete erases the
mail the ordinary restore path exists to bring back, and the mailspace purge destroys a
soft-deleted mailspace immediately rather than at the end of its retention window.
There is no narrower mail scope to request instead, and no separate scope for either
capability. Grant mailspace:read only where reading a customer's mail is intended, and
mailspace:write only to a client that genuinely administers mail.
Endpoints unavailable via OAuth (no scope declared → fail-closed): account CRUD & roles, API keys, users, user_roles, global tasks, SSO (site and top-level), webhooks, the domain-order endpoints (POST /api/orders/domain, POST /api/domain_registrations/:id/registrant_change), every cPanel write (account order/resize/cancel, password, purge, control-panel session, domain add/remove), and POST/PATCH/DELETE on /api/orders. These require a session or API-key credential.
Several of those blocked endpoints additionally reject a system account-bearer key
(an account with no user) with 401
{"errors":["This endpoint requires a user-scoped API key."],"code":"user_required"}.
The endpoints that do this are: POST /api/orders/domain,
POST /api/domain_registrations/:id/registrant_change,
POST /api/cpanel_accounts, PATCH/DELETE /api/cpanel_accounts/:username, and the nested cPanel
password, purge, session, and domain create/destroy endpoints.
Scope enforcement
Session and API-key credentials bypass scope checks entirely. For OAuth tokens:
- Missing the required scope →
403, header WWW-Authenticate: Bearer error="insufficient_scope", scope="<required>", body {"error":"insufficient_scope","error_description":"requires scope: <required>"}.
- Endpoint declares no scope (OAuth-blocked) →
403 {"error":"insufficient_scope","error_description":"endpoint not available via OAuth"}.
Removing a user's role on an account revokes their OAuth tokens and sessions for that account.
Base URL & Conventions
- Host:
https://my.cloudpress.com is the default CloudPress host, and every example in this document uses it. White-label brands are served on their own hostname (e.g. brand.example.com) with identical paths — substitute your brand's host throughout, including the /mcp and /oauth/* URLs.
Brand-locking applies to OAuth access tokens only — one issued on a given brand's
hostname is rejected on any other. API keys and session tokens are not brand-checked
and authenticate on any brand host.
- All API routes are under
/api/. All responses are JSON.
- Resource IDs are GUIDs (UUID format). The complete list of exceptions:
- task IDs, volume IDs, DNS record-type codes and
domain_contact IDs are integers;
- a cPanel account is addressed by its cPanel username
(
/api/cpanel_accounts/:username);
- a cPanel domain is addressed by the domain name
(
/api/cpanel_accounts/:username/domains/:id);
- a registrar host is addressed by its host name
(
/api/domain_registrations/:id/hosts/:host_name);
- a cart is addressed by an opaque token, not a GUID
(
/api/carts/:token).
- Timestamps are ISO 8601, UTC.
- Async operations return
202 Accepted, but the body varies by endpoint — see
Async Patterns for the enumerated shapes and what to poll.
- Pagination: exactly five index endpoints paginate —
GET /api/domains,
GET /api/subscriptions, GET /api/dns_zones, GET /api/orders, and
GET /api/cpanel_accounts. They accept page and per_page (default 50, max 100;
values outside 1..100 are clamped). Every other index endpoint ignores page and
per_page and returns the full set — don't build a pager against them.
Error Responses
| Status |
When it occurs |
Body shape |
400 |
Missing required header; invalid/no-op params; order & site-resize validation errors (unknown variant/location/term/product); no_default_payment_method; a non-numeric days on mail metrics (invalid_days); OAuth picker/DCR errors |
{"errors":["..."]} often with a "code"; OAuth: {"error":"...","error_description":"..."} |
401 |
Authentication failed (no/invalid token, IP blocked, trial account, account mismatch); admin-only endpoint with non-admin credential; DELETE /api/accounts/:id refusing to delete the caller's only remaining account; OAuth token bound to a non-/api audience; a domain-order or cPanel-write endpoint called with a system account-bearer key (user_required) |
Empty + WWW-Authenticate: Token realm="Application"; {"error":"invalid_token",...} for the audience case; {"errors":[...],"code":"user_required"} for the account-bearer case |
402 |
Service suspended for unpaid invoice (dunning); registrar fee gate (privacy/registrant change) |
{"errors":["..."],"code":"service_suspended","invoice":{...}} or {...,"code":"payment_required","price":{...},"portal_url":"..."} |
403 |
Insufficient role ({"errors":["Not Authorized"]}); OAuth scope failure; Shield not in plan / premium required; inside a mailspace — no edit permission (not_authorized), staff/dunning hold (mailspace_suspended), or soft-deleted (pending_delete) |
{"errors":["Not Authorized"]} or {"error":"insufficient_scope"/"shield_not_in_plan"/"shield_premium_required"} |
404 |
Resource not found or not accessible to this token |
Usually empty (head); some render {"errors":[...]} |
409 |
Conflict — Bunny resource not active (cdn_not_active, shield_not_active); registrar process already in flight (registration_busy); mailspace not yet in the mail platform (not_provisioned); a verification retry on a mailspace that is already set up (already_provisioned) |
{"error":"cdn_not_active"} / {"errors":[...],"code":"registration_busy",...} |
422 |
Validation failure or permission restriction (e.g. inherited role, reseller-only, resize constraints); billing still settling (billing_settling); payment could not be initiated for an order/plan-change/domain order (cart_pay_failed); a site's transactional email not provisioned (mail_not_provisioned) or not active (mail_not_active) |
{"errors":["..."]}, often with a "code" |
429 |
The global rate limit (600/10min) — empty body. Also two per-endpoint limiters that do carry a body, both one run per 5 minutes: POST /api/mailspace/:id/domains/dns_check (per domain) and POST /api/sites/:id/mail/dns_check (per site, shared with the control panel) |
Global: empty. The two DNS checks: {"errors":["..."],"code":"rate_limited"} |
502 |
Upstream failure — Bunny (CDN/Shield), the domain registrar, or the transactional-email provider. The three use different body shapes, so don't parse them with one branch. |
Bunny: {"error":"<message>"} (a bare error string, no code). Registrar: {"errors":["..."],"code":"..."} — registrar_unavailable, authcode_unavailable (EPP-code fetch) or process_unavailable (process resend/cancel). Transactional email: {"errors":["..."],"code":"log_search_failed"} on GET /api/sites/:id/mail/logs. |
503 |
Feature disabled (domain-registration flag); no registrar configured for a TLD; mail hosting not configured (stalwart_unavailable, on every /api/mailspace/:id/… endpoint, reads included); transactional email not configured (mailchannels_unavailable); any mail-server read or write that could not be performed — as distinct from one the mail server refused — under a per-resource code, see the mailspace read-failure convention below |
{"errors":["..."],"code":"feature_disabled"/"registrar_unavailable"/"missing_api_key"/"stalwart_unavailable"/"mailchannels_unavailable"/"mailboxes_unavailable"/"domains_unavailable"/"aliases_unavailable"/"groups_unavailable"/"mailing_lists_unavailable"/"archived_items_unavailable"/"…"} |
Note: there is no longer a generic domains/backups "feature flag → 503" model. DNS, domains, backups, and restores are no longer feature-flag gated. The only FeatureFlag 503 in the API is the domain-registration flag. CDN/Shield availability is signaled by 409 (cdn_not_active/shield_not_active), and Shield plan access by 403 (shield_not_in_plan/shield_premium_required).
Common error messages:
| Error body |
Cause |
{"errors":["Missing X-Auth-Account"],"code":"missing_account"} |
Header required but absent (400) |
{"errors":["Missing X-Auth-Account"]} — no code key |
Same condition on POST /api/dns_zones only. Zone create renders its own body and does not carry a code, so a client keying on code == "missing_account" will miss it (400). |
{"errors":["Account unable to create orders."],"code":"account_cannot_order"} |
Account state prevents orders (e.g. trial with 2+ sites) (400). Both the site-order and the domain-order path send this same message and this code — branch on the code. |
{"errors":["Must have reseller permissions"]} |
Reseller-only endpoint (422) |
{"errors":["Unable to remove an inherited role."]} |
Deleting an inherited account role (422) |
{"errors":["..."],"code":"no_default_payment_method"} |
Order/resize with no saved payment method (400) |
Authorization & Account Model
Many 401/403 errors come from token scope or role gaps.
Account Types
| Type |
How to identify |
What it can do |
| Regular |
Default |
Manage own resources per role flags |
| Reseller |
reseller: true on account |
Create sub-accounts, create users, manage sub-account billing |
| Admin |
is_admin on the user/API key (not the account) |
A broad, but not universal, bypass: it satisfies the view / edit / create / destroy / billing-view / WP-login checks on any account, and it can create top-level accounts. It does not satisfy the site-lifecycle check — see the is_admin role-flag row below. OAuth tokens are never admin. |
| Trial |
is_trial: true |
Blocked from the API entirely (auth returns 401) |
Brands: resellers can white-label via an AccountBrand (hostname, name, logo, theme, SMTP, support contacts). Each account belongs to a brand; the brand is resolved from the request hostname and isolates OAuth tokens.
Role Permission Flags
Every user has a role on each account they belong to. Flags on the role:
| Flag |
What it controls in the API |
is_admin |
Full access to account operations — overrides the other flags on this table. One deliberate exception: the site-lifecycle gate (below) ignores platform-staff is_admin entirely and requires actual membership on the account. |
can_edit |
PATCH/update on accounts, sites, domains, zones, registrations |
can_create |
POST orders / create resources |
can_destroy |
The DELETE gate on: /api/accounts/:id, /api/dns_zones/:id, and the per-site sub-resources (backups, cache, site domains, edge rules, Shield rules and access lists). A non-admin user cannot delete their only remaining account even with this flag — the refusal is 401 with an empty body, not a 403. |
| (site lifecycle) |
DELETE /api/sites/:id is not gated on can_destroy. It requires the billing role on the account: either is_admin, or billing and can_edit together (walked up to the billing account for children that inherit billing). Platform-staff is_admin on the user grants nothing here — the caller must be a member of the account. DELETE /api/cpanel_accounts/:username uses the same gate. |
billing |
View billing/pricing data; with can_edit, manage billing |
wp_login |
Generate SSO URLs for any WP user — without it, SSO is limited to WP users the token's user is explicitly linked to |
A suspended user fails every permission check. List available roles: GET /api/user_roles.
Role Inheritance
A user granted a role on a parent account automatically receives the same role on all child accounts (cascaded asynchronously). Inherited roles carry inherited_from in the response and cannot be removed at the child level — change the parent role to update all descendants (deleting an inherited role returns 422).
Permission Check Flow
- Is the token valid, IP allowed, account not on trial, not rate-limited?
- For OAuth tokens: does the token's scope cover this action? (Session/API keys skip this.)
- Is
X-Auth-Account required by this endpoint?
- Does the token's user have the required role flag on the target account?
- For reseller-only operations: is the account a reseller?
- For per-site tool endpoints: is the site dunning-suspended? (→ 402)
Reseller Capabilities
With a reseller account and X-Auth-Account set to it:
POST /api/accounts — create sub-accounts
POST /api/users — create managed users (returned with an auto-generated password and API token)
Sub-accounts inherit the reseller's billing plan.
Dunning Suspension
When a site's invoice is unpaid, per-site show/update/destroy and tool endpoints return 402:
{
"errors": ["This service is suspended because an invoice is unpaid."],
"code": "service_suspended",
"invoice": { "number": "INV-123", "hosted_url": "https://..." }
}
List endpoints are not blocked — they surface dunning_suspended: true on the site instead.
Async Patterns
Tasks
Most provisioning operations are async and answer 202 Accepted — but there is no
single envelope. Do not assume a task reference; several 202 bodies carry no task id
at all, and some carry nothing. The shapes actually in use:
202 body |
Endpoints |
| Empty — no body |
site rename (PATCH /api/sites/:id with only name); POST /api/sites/:id/restart; PATCH /api/sites/:id/restores/:id; PATCH/DELETE /api/sites/:id/backups/:id; PATCH/DELETE /api/sites/:id/domains/:id; DELETE /api/dns_zones/:id; PATCH/DELETE /api/dns_zones/:zone_id/records/:id; POST /api/accounts/:id/roles and DELETE /api/accounts/:id/roles/:id; DELETE /api/accounts/:id; DELETE /api/api_keys/:id; DELETE /api/orders/:id (order cancel) |
{"task_id": <int>} |
PATCH /api/sites/:id/variants/:id (PHP version change) |
{"task_id": <int>, "status": "PENDING", "cache_type": "<id>"} |
the cache layer — PATCH/DELETE /api/sites/:id/cache/:id and DELETE /api/sites/:id/cache/:id/purge |
The cart envelope ({status:"accepted", cart:{…}, payment:{…}, orders:[…]}) |
POST /api/orders and POST /api/orders/domain; PATCH /api/sites/:id with plan; POST/PATCH /api/cpanel_accounts; POST/PATCH /api/mailspace; POST /api/domain_registrations/:id/registrant_change |
{"status":"preparing","backup_id":"…"} |
POST /api/sites/:id/backups/:id/export |
{"status":"purging","username":"…"} |
POST /api/cpanel_accounts/:username/purge |
{"status":"pending_deletion","username":"…","delete_scheduled_at":"…"} |
DELETE /api/cpanel_accounts/:username |
{"status":"accepted","domain":"…","domain_type":"…"} |
POST /api/cpanel_accounts/:username/domains |
{"id":"<guid>","ns1":"…","ns2":"…"} or {"id":"<guid>","cname":"…"} |
POST /api/sites/:id/domains — which of the two you get depends on how that domain has to be pointed (nameservers vs CNAME) |
| The resource's own body |
PATCH /api/accounts/:id (the account); a domain-registration or domain-contact mutation that opened a registrar process (its show body, carrying a pending_process block) |
The verification block ({"verification":{"domain":…,"state":…,…}}) |
POST /api/mailspace/:id/domain_verification — provisioning is enqueued, not finished. Poll GET on the same path until state reads "provisioned". |
{"status":"queued"} |
POST /api/sites/:id/mail/dns_check — no id to poll; re-read the transactional-email overview for the result. |
{"status":"accepted","task_id":<int>} or {"status":"accepted","noop":true} |
POST /api/webhooks/cdn_cache (infra-facing, not a customer endpoint) |
That table is the complete set — no other /api endpoint answers 202.
Async operations include: PHP version change, backup creation, restore, restart, domain add/remove, account-role removal, cache enable/disable/purge, account delete. (Site creation, site resize/plan-change, domain orders, cPanel account orders and resizes, and Mailspace purchases and resizes are also async but are polled via the cart, not a task — see Carts below.)
Polling task status:
GET /api/tasks/:id
GET /api/sites/:site_id/tasks/:id
Task statuses:
| Status |
Meaning |
PENDING |
Queued, not yet started |
RUNNING |
Currently executing |
OK |
Completed successfully |
FAILED |
Failed |
CANCELLED |
Cancelled |
PAUSED |
Paused |
Completion callback — attach a callback to an order (POST /api/orders, POST /api/orders/domain, or POST /api/cpanel_accounts) and CloudPress POSTs to your URL when that order's task finishes, instead of you polling:
{ "callback": { "url": "https://your-app.com/webhook", "authorization": "Bearer your-secret" } }
- Permitted keys:
url (required) and authorization (optional, sent verbatim as the Authorization header). Any other key in the callback object is discarded. Only the three order-placing endpoints above consume a callback, so callbacks attach to order tasks only — it is ignored if sent anywhere else (a Mailspace purchase, for instance, does not accept one).
- What CloudPress sends:
POST to your url with body { "timestamp": <epoch int>, "success": <bool>, "data": <task.data string> } and headers Authorization (verbatim) + Accept: application/json. Same outbound delivery layer as billing webhooks — 30s timeout, any 2xx counts as
success. Retry backoff: the wait is chosen from the age of the delivery's
timestamp (5 / 10 / 15 minutes), which in practice means the first retry lands
~5 minutes after the failed attempt and every retry after that at 15-minute intervals.
Retries stop once that timestamp is more than 4 hours old, so delivery is
at-least-once — de-dup on timestamp, which stays fixed across all retries of one
delivery.
- When it fires: only on a terminal task transition — the task reaching
OK,
FAILED, or CANCELLED (including when an infrastructure component reports the
result in via POST /api/webhooks/task/:id, which sets OK or FAILED). Intermediate
transitions such as PENDING → RUNNING, or a pause, queue no delivery. So expect
one callback per order task, not a status stream.
- Mechanism: the
callback is stashed on the cart and stamped onto the order task,
and the delivery is queued at that terminal transition if the task carries one.
- Utility endpoints (distinct from your receiver — these are inbound to CloudPress):
GET /api/webhooks/task returns {ip_address} for a reachability test; POST /api/webhooks/task/:id is how a task result is reported into CloudPress (used by infra; API-key credential, OAuth-blocked; idempotent via a data+success digest), and posting that result is what triggers the outbound callback. It expects an account context and, unlike the endpoints listed under X-Auth-Account, does not check for one — sending it without one produces a 500, not a 400.
Carts (order/billing async window)
Five endpoints return the same cart envelope (status accepted): POST /api/orders; site plan-change (PATCH /api/sites/:id with plan); cPanel account order/resize (POST/PATCH /api/cpanel_accounts); Mailspace purchase/resize (POST/PATCH /api/mailspace); and the paid registrant change (POST /api/domain_registrations/:id/registrant_change). Poll the cart for materialized orders. orders is empty during the async window on Stripe-billed plans, and a declined off-session charge is still a 202 — never branch on the HTTP status. What to branch on depends on the operation: a purchase reports a declined or challenged charge as payment.status: "awaiting_authentication", but a resize (site plan-change, cPanel resize, Mailspace resize) builds a proration cart and reports processing with a populated payment.hosted_invoice_url instead. On a resize, branch on that URL being non-null. See Orders and Carts.
Registrar processes
Domain-registration mutations may complete synchronously (200) or open a registrar process (202 with pending_process). Poll via the processes endpoints. A registration with an in-flight process is "busy" and rejects further mutations with 409 registration_busy.
Feature & Plan Gating
domain-registration feature flag — when off, all domain-registration and domain-contact endpoints return 503 {"errors":[...],"code":"feature_disabled"}.
- Shield plan entitlement — the site's product must include Shield, else
403
{"error":"shield_not_in_plan"}. The gate is declared on this enumerated set:
/api/sites/:id/shield (show/create/update/destroy) plus the nested
shield/events, shield/bot_detection, shield/waf, shield/waf_custom_rules,
shield/rate_limits and shield/access_lists — reads as well as writes. Two of
those actions additionally require a premium plan —
PATCH /api/sites/:id/shield/bot_detection and
POST /api/sites/:id/shield/waf_custom_rules — else 403
{"error":"shield_premium_required"}.
- Bunny activation — CDN/Shield endpoints return
409 cdn_not_active / shield_not_active when the underlying Bunny pull zone / shield zone hasn't been provisioned yet.
- Registrar availability — registrar operations on a TLD with no configured registrar return
503 registrar_unavailable.
- cPanel availability — cPanel hosting is limited to workspaces that are already cPanel customers; every
/api/cpanel_accounts route returns 403 {"errors":[...],"code":"cpanel_not_enabled"} for any other workspace. A 503 cpanel_unavailable means the hosting platform could not be reached and is retryable; cpanel_not_enabled is not.
- Mail platform availability — when mail hosting is not configured, every
/api/mailspace/:id/… endpoint returns 503
{"errors":["The mail server is not configured."],"code":"stalwart_unavailable"}.
Reads included — this gate runs before the mailspace is even looked up, so it is not a
write-only refusal.
- Mailspace state gates — the endpoints nested under
/api/mailspace/:id apply four
further gates in this order, and a client should map each code to exactly one cause:
403 not_authorized (no edit permission on the mailspace's own workspace),
403 mailspace_suspended (staff block or dunning hold), 403 pending_delete
(mailspace soft-deleted), 409 not_provisioned (mailspace not yet in the mail
platform — complete domain verification first). Two of those four are decided on the
HTTP verb, not the action name — not_authorized and pending_delete: GET and
HEAD always pass, so reads stay available on a soft-deleted mailspace for its whole
retention window. mailspace_suspended is not verb-gated: a staff-blocked or
dunning-held mailspace refuses reads as well as writes, even though its message reads
"cannot be modified". Do not infer the verb rule from one code to the next.
Two deliberate exceptions: the mailspace purge endpoint does not apply
pending_delete (being soft-deleted is its precondition), and the primary-domain
verification endpoint does not apply not_provisioned (it runs before provisioning is
possible).
- Mailspace read failures are
503 with a per-resource code, and an empty collection
is never one of them. Every index inside a mailspace reads the mail server live, and
each answers its own code when that read could not be performed —
mailboxes_unavailable, domains_unavailable, aliases_unavailable,
groups_unavailable, mailing_lists_unavailable, archived_items_unavailable,
purged_mailboxes_unavailable. The code names which read failed, which is the point:
GET .../groups has two, and group_members_unavailable now means only that the member
tally failed while the group list itself came back. So an empty array in a 200 means
the mail server was asked — a 200 no longer hides a failed read, and a client may
reconcile its own state against it. That buys honesty about the read, not
completeness: every one of these lists is capped upstream (500 rows) and truncates
silently, with no total and no flag, and purged_mailboxes has the worst of it — its cap
is applied server-wide before tenant filtering, so an empty 200 there is still not
proof the mail is gone. Three further exceptions, none of which generalises: mailbox mail
rules answer 422 mail_rules_unavailable rather than a 503; the app-password list,
the masked-email list, the delivery logs and the by-id group and mailing-list lookups are
still tolerant and degrade to an empty result or a 404.
- On a mailspace write,
503 answers one of two opposite questions — check which.
The distinction is deliberate and it is per code, not per status: some 503s mean
nothing was attempted, others mean something was attempted and the outcome is
unknown. restore_unavailable (purged-mailbox restore) and
archived_item_lookup_unavailable mean nothing happened and the resource is untouched.
`restore_un
…(truncated)
1---2name: cloudpress-api3description: CloudPress REST API reference for building integrations. Covers authentication (session tokens, API keys, OAuth 2.1 tokens and scopes), all endpoints (sites, cPanel hosting, Mailspace mail hosting — mailboxes, aliases, groups, mail logs — domains, DNS, orders/carts, backups, SSO, CDN, cache, edge rules, logs, Shield/WAF, metrics, tasks, accounts, users, domain registration), parameters, response shapes, error handling, async task/registrar-process patterns, outbound billing webhooks (site, domain, cPanel events), rate limiting, pagination, and the authorization/account/role/brand model. Also covers the CloudPress MCP (Model Context Protocol) server — connecting an MCP client (Claude Desktop/Cowork), the OAuth audience binding, JSON-RPC protocol, and tool catalog. Activate when the user asks about the CloudPress API, how to call an endpoint, what parameters to pass, what a response looks like, OAuth scopes, how to set up the MCP server, or how to integrate with CloudPress programmatically.4---5<!-- NOTE: the `description` above is 1003 of the 1024-character limit. Do not6 append to it — a longer value silently breaks skill activation. Any7 addition must be balanced by a cut elsewhere in the same field. -->89<!-- API SNAPSHOT: the line below is the only place this file records which10 CloudPress release its contents were verified against. The skill's own11 release version is a publish date and does not encode it. Bump this in the12 same pass that bumps package.json, .claude-plugin/plugin.json, and13 CHANGELOG.md in the cloud-press/skills repo. -->1415*Verified against CloudPress platform release **2026.08.01**.*1617# When to Use This Skill1819Activate this skill when:20- User asks how to call a specific CloudPress API endpoint21- User asks what parameters an endpoint accepts or what a response looks like22- User asks about authentication, API keys, OAuth, scopes, or the `X-Auth-Account` header23- User asks about permissions, roles, or what their token can do24- User is debugging a 400, 401, 402, 403, 404, 409, 422, 429, 502, or 503 from the API25- User asks about async operations, task polling, registrar processes, or completion callbacks26- User asks about **billing webhooks** (outbound site-subscription lifecycle webhooks configured on a billing plan) — what events fire, the payload shape, or why a domain/registrar event didn't produce one27- User asks about **cPanel hosting accounts** through the API — ordering, resizing, domains, passwords, control-panel sessions28- User asks about **Mailspace mail hosting** through the API — ordering or resizing a mailspace, or managing what is inside one: mailboxes, app passwords, mail rules, vacation responses, aliases, groups, mailing lists, masked emails, mail domains and their ownership verification, deleted-mail recovery, or delivery logs29- User asks about a WordPress site's **transactional email** through the API — sender settings, send logs, mail metrics, or the sender-DNS check30- User asks how to set up, configure, or connect the CloudPress **MCP server** (e.g. in Claude Desktop/Cowork), what MCP tools exist, or why an MCP token is rejected31- User is building an integration with CloudPress3233---3435# CloudPress REST API3637---3839## Authentication4041All `/api/*` requests authenticate via an HTTP Bearer token:4243```bash44Authorization: Bearer <token>45```4647There are **three credential types**, all presented as a Bearer token. The server resolves which kind it is (session/API key first, then OAuth).4849| Credential | Identity | Admin-capable? | OAuth scopes apply? | Notes |50|---|---|---|---|---|51| **User API key** | A user (accesses accounts that user belongs to) | If the key's `is_admin` is set | No (bypasses scope checks) | Optional IP access list (`allowed_ips`). |52| **System API key** | An account (system-managed only) | If `is_admin` | No | `system_managed` keys only; IP must be on the system access list. The account is resolved from the key's `bearer` — `X-Auth-Account` is **not** required (and is ignored). |53| **OAuth 2.1 access token** | A user, bound to one `(account, brand)` | **Never** (OAuth tokens are never admin) | **Yes — fail-closed** | Issued by the OAuth 2.1 authorization server. See [OAuth 2.1](#oauth-21). |5455**Account scoping — `X-Auth-Account`:**5657```bash58X-Auth-Account: <account_guid>59```6061Scopes a user API key to a single account: index endpoints then return only that62account's resources, instead of resources across every account the token's user can63access.6465The endpoints that **require** the header answer **`400`**66`{"errors":["Missing X-Auth-Account"],"code":"missing_account"}` when it is absent. That67set is exactly:6869- every route under `/api/orders` (including `POST /api/orders/domain`),70 `/api/subscriptions`, `/api/carts`, `/api/users`, `/api/domain_contacts`,71 `/api/domain_registrations` (including its nested `hosts` and `processes`), and72 `/api/cpanel_accounts` (including its nested `password`, `purge`, `session` and73 `domains`)74- `POST /api/sso`75- `POST /api/dns_zones` — zone **create** only; its body carries **no** `code` key,76 just `{"errors":["Missing X-Auth-Account"]}`77- `POST /api/mailspace` — mailspace **create** only; it answers `400` with78 `code: "account_required"` and a longer message. The two codes mean the same thing on79 different routes. The nested `/api/mailspace/:mailspace_id/…` routes do **not** inherit80 this: sent without the header, they resolve the mailspace from the ones the token's user81 can reach, and an inaccessible guid is a **`404`** with an empty body — deliberately82 indistinguishable from one that does not exist.8384Nothing else requires it. In particular the **webhook** endpoints do not:85`GET /api/webhooks/task` needs no account at all, and `POST /api/webhooks/task/:id` has86no header check — called with no account context it fails inside the action and returns87a **`500`**, not a `400`.8889OAuth tokens always carry an account, so the header is irrelevant for them.9091**Authentication failures** return **`401`** with header `WWW-Authenticate: Token realm="Application"` and an empty body. Causes: no/invalid token, IP not on the access list, the resolved account is on trial, or the `X-Auth-Account` GUID doesn't match an account the token can access. **Trial accounts cannot use the API at all** (both session and OAuth auth reject them; a user-API-key call with no `X-Auth-Account` resolves no account and so isn't trial-blocked at auth time).9293There are **three** `401` body shapes:94951. the empty body above — also what `DELETE /api/accounts/:id` returns when the caller96 is refusing to delete their only remaining account;972. `{"error":"invalid_token","error_description":"..."}` for an audience-bound OAuth98 token (next paragraph);993. `{"errors":["This endpoint requires a user-scoped API key."],"code":"user_required"}`100 from the endpoints that need a user behind the credential (see [Scopes](#scopes)).101102**Audience-bound OAuth tokens:** an OAuth access token minted with a `resource` (RFC 8707 audience, e.g. for `/mcp`) is rejected at `/api/*` with **`401`** `{"error":"invalid_token","error_description":"token audience is not valid for /api"}`. Only un-audienced (or `/api`-audienced) tokens work here.103104**Rate limiting:** 600 requests / 10 minutes. Two details that are easy to get wrong:105106- The budget is keyed on the **client IP**, not on the credential — several tokens107 behind one egress IP share one bucket.108- It is counted **per resource group**, not once across all of `/api` — sites, domains109 and DNS each get their own 600, so exhausting one leaves the others untouched.110111Exceeding it returns **`429`** with an empty body.112113The `/mcp` endpoint is the opposite on both counts: 6000 requests / 10 minutes, keyed on114the **OAuth access token** (falling back to the client IP when there is no token), so LLM115clients sharing egress IPs don't collide.116117---118119## OAuth 2.1120121CloudPress runs a standard OAuth 2.1 authorization server. Use it for third-party apps acting on a user's behalf; use API keys for first-party/server integrations.122123**Authorization server metadata** (per-brand, derived from request host):124125```bash126GET /.well-known/oauth-authorization-server127```128129```json130{131 "issuer": "https://<host>",132 "authorization_endpoint": "https://<host>/oauth/authorize",133 "token_endpoint": "https://<host>/oauth/token",134 "revocation_endpoint": "https://<host>/oauth/revoke",135 "introspection_endpoint": "https://<host>/oauth/introspect",136 "registration_endpoint": "https://<host>/oauth/registration",137 "response_types_supported": ["code"],138 "grant_types_supported": ["authorization_code", "refresh_token"],139 "code_challenge_methods_supported": ["S256"],140 "token_endpoint_auth_methods_supported": ["none"],141 "scopes_supported": ["sites:read","sites:write","domains:read","domains:write","dns:read","dns:write","mailspace:read","mailspace:write","billing:read","cpanel:read"],142 "service_documentation": "https://<host>/docs/oauth"143}144```145146- **Grant types:** `authorization_code` and `refresh_token` only. **PKCE is mandatory** (`S256` only). Refresh tokens rotate (previous token revoked on use).147- **No OpenID Connect** — there is no `/.well-known/openid-configuration`, no userinfo endpoint, and no ID tokens. The only OIDC-adjacent surface CloudPress exposes is Dynamic Client Registration (below).148- **Dynamic Client Registration (DCR, RFC 7591):** `POST /oauth/registration`. This149 server only ever issues **public PKCE clients**, but it does **not reject** a150 registration that asks for a confidential `token_endpoint_auth_method` — the151 requested value is **coerced to `"none"`** and the registration succeeds as a public152 client, echoing `"none"` back with no `client_secret`. So a client that sends153 `client_secret_post` (as some connector implementations do regardless of the154 advertised metadata) registers successfully and then uses PKCE. Per-IP limit: 50155 registrations/hour → **`429`**156 `{"error":"too_many_requests","error_description":"registration rate limit exceeded"}`.157- **Brand isolation — OAuth tokens only:** an OAuth access token is bound to the brand158 (hostname) it was issued under and is rejected on any other brand's hostname. The159 authorize screen only lists the user's non-trial accounts on the current brand.160 **API keys and session tokens are not brand-checked** — they authenticate on any161 brand host.162163### Scopes164165There are **no default scopes** — every resource scope is opt-in. The complete vocabulary:166167| Scope | Grants |168|---|---|169| `sites:read` | Site read + nested site reads (show/index, backups list, cache status, CDN status, variants list, tasks, edge-rules list, Shield reads, logs, **all four metrics endpoints** — resources, CDN, Shield, transactional mail — and the transactional-email overview and send logs). Note `POST /api/sites/:id/metrics/cdn` is a **read** despite the POST verb: the body carries the query window, and the scope it requires is `sites:read`. |170| `sites:write` | Site + nested site writes (update/destroy, backups, backup exports, cache, restart, restore, edge rules, Shield writes, certificates, site-domain CRUD, variant change, transactional-email sender settings, **lifting** a transactional-email suspension — the endpoint is a `DELETE` that clears a hold, it does not apply one — and the sender-DNS re-check). **Not site creation** — `POST /api/sites` is a no-op stub returning `400`; sites are created by ordering one via `POST /api/orders`. |171| `domains:read` | Domains list/show/query/available; domain-registration index/show/check/suggestions; contacts/hosts/processes reads |172| `domains:write` | Domain-registration mutations; domain-contact create/update/destroy/resend; host/process writes |173| `dns:read` | DNS zones index/show/dns_stats; DNS records index/show |174| `dns:write` | DNS zone & record create/update/destroy |175| `mailspace:read` | Mailspace list/show, **and every read inside a mailspace** — mailboxes (plus the address-availability check), mailbox app-password list, mailbox mail rules, vacation responses, aliases, groups, mailing lists, masked emails, mail domains with their DNS records and verification status, the purged-mailbox recovery list, archived (deleted) mail **including the raw-message download**, and delivery logs. This is not a metadata-only scope — read the warning below before granting it. |176| `mailspace:write` | Mailspace purchase/resize/delete, **and every mutation inside a mailspace** — mailbox create/update/delete/restore and irreversible force-delete, app-password mint/update/revoke, mail-rule create/update/delete/toggle/move/adopt, vacation responses, aliases, groups, mailing lists, masked emails, mail-domain CRUD with DNS re-check and ownership verification, purged-mailbox restore, archived-mail restore/delete, and the immediate permanent purge of a soft-deleted mailspace. |177| `billing:read` | Orders index/show; subscriptions index/show; carts show |178| `cpanel:read` | cPanel account list/show; cPanel account domain **list** — reads only |179180**`GET /api/about`** accepts any valid token regardless of scope.181182**There is no `cpanel:write` scope and no `billing:write` scope.** Do **not** read that183as "OAuth cannot spend the account's money" — it can.184185- **What the missing scopes really cover.** Direct order placement (`POST /api/orders`,186 `POST /api/orders/domain`, `POST /api/domain_registrations/:id/registrant_change`) and187 the cPanel writes (account order/resize/cancel, password, purge, control-panel188 session, domain add/remove) declare no scope at all, so they fail closed for **every**189 OAuth token. That much is true.190- **What is NOT covered — read this before granting either scope.** Two scopes that do191 exist reach a purchase:192 - **`mailspace:write`** → `POST /api/mailspace` buys a new mailspace, and193 `PATCH /api/mailspace/:id` resizes one.194 - **`sites:write`** → `PATCH /api/sites/:id` with a `plan` parameter changes the195 site's plan (an up- or downgrade of the recurring subscription).196197 Each of those builds a cart and charges the account's saved default payment method198 **off-session** — no interactive confirmation step, no separate billing scope, and the199 `202` comes back whether the charge succeeded or was declined.200201**Consequence:** an OAuth token holding `mailspace:write` or `sites:write` can spend the202account's money and change its recurring bill. Treat those two as billing-capable when203deciding what to request or grant. Only the *order-placing* and *cPanel* endpoints listed204above are genuinely out of OAuth's reach.205206**A second consequence — data access, not only money.** The two `mailspace` scopes are207often assumed to cover subscription metadata. They do not:208209- **`mailspace:read` reaches mail itself.** It lists a mailspace's mailboxes, reads its210 delivery logs, and — where the mail platform's archive feature is available — lists a211 mailbox's deleted mail and **downloads a raw message**.212- **`mailspace:write` reaches irreversible destruction.** Mailbox force-delete erases the213 mail the ordinary restore path exists to bring back, and the mailspace purge destroys a214 soft-deleted mailspace immediately rather than at the end of its retention window.215216There is no narrower mail scope to request instead, and no separate scope for either217capability. Grant `mailspace:read` only where reading a customer's mail is intended, and218`mailspace:write` only to a client that genuinely administers mail.219220**Endpoints unavailable via OAuth** (no scope declared → fail-closed): account CRUD & roles, API keys, users, `user_roles`, global `tasks`, SSO (site and top-level), webhooks, the domain-order endpoints (`POST /api/orders/domain`, `POST /api/domain_registrations/:id/registrant_change`), **every cPanel write** (account order/resize/cancel, password, purge, control-panel session, domain add/remove), and `POST`/`PATCH`/`DELETE` on `/api/orders`. These require a session or API-key credential.221222Several of those blocked endpoints additionally reject a **system account-bearer** key223(an account with no user) with **`401`**224`{"errors":["This endpoint requires a user-scoped API key."],"code":"user_required"}`.225The endpoints that do this are: `POST /api/orders/domain`,226`POST /api/domain_registrations/:id/registrant_change`,227`POST /api/cpanel_accounts`, `PATCH`/`DELETE /api/cpanel_accounts/:username`, and the nested cPanel228`password`, `purge`, `session`, and domain `create`/`destroy` endpoints.229230### Scope enforcement231232Session and API-key credentials **bypass** scope checks entirely. For OAuth tokens:233- Missing the required scope → **`403`**, header `WWW-Authenticate: Bearer error="insufficient_scope", scope="<required>"`, body `{"error":"insufficient_scope","error_description":"requires scope: <required>"}`.234- Endpoint declares no scope (OAuth-blocked) → **`403`** `{"error":"insufficient_scope","error_description":"endpoint not available via OAuth"}`.235236Removing a user's role on an account revokes their OAuth tokens and sessions for that account.237238---239240## Base URL & Conventions241242- **Host:** `https://my.cloudpress.com` is the default CloudPress host, and every example in this document uses it. White-label brands are served on their own hostname (e.g. `brand.example.com`) with **identical paths** — substitute your brand's host throughout, including the `/mcp` and `/oauth/*` URLs.243 Brand-locking applies to **OAuth access tokens only** — one issued on a given brand's244 hostname is rejected on any other. API keys and session tokens are not brand-checked245 and authenticate on any brand host.246- All API routes are under `/api/`. All responses are JSON.247- Resource IDs are GUIDs (UUID format). The complete list of exceptions:248 - task IDs, volume IDs, DNS record-type codes and `domain_contact` IDs are **integers**;249 - a **cPanel account** is addressed by its cPanel **username**250 (`/api/cpanel_accounts/:username`);251 - a **cPanel domain** is addressed by the **domain name**252 (`/api/cpanel_accounts/:username/domains/:id`);253 - a **registrar host** is addressed by its **host name**254 (`/api/domain_registrations/:id/hosts/:host_name`);255 - a **cart** is addressed by an **opaque token**, not a GUID256 (`/api/carts/:token`).257- Timestamps are ISO 8601, UTC.258- Async operations return **`202 Accepted`**, but the *body* varies by endpoint — see259 [Async Patterns](#async-patterns) for the enumerated shapes and what to poll.260- **Pagination:** exactly five index endpoints paginate — `GET /api/domains`,261 `GET /api/subscriptions`, `GET /api/dns_zones`, `GET /api/orders`, and262 `GET /api/cpanel_accounts`. They accept `page` and `per_page` (default `50`, max `100`;263 values outside `1..100` are clamped). **Every other index endpoint ignores `page` and264 `per_page` and returns the full set** — don't build a pager against them.265266---267268## Error Responses269270| Status | When it occurs | Body shape |271|--------|---------------|------------|272| `400` | Missing required header; invalid/no-op params; **order & site-resize validation errors** (unknown variant/location/term/product); `no_default_payment_method`; a non-numeric `days` on mail metrics (`invalid_days`); OAuth picker/DCR errors | `{"errors":["..."]}` often with a `"code"`; OAuth: `{"error":"...","error_description":"..."}` |273| `401` | Authentication failed (no/invalid token, IP blocked, trial account, account mismatch); admin-only endpoint with non-admin credential; `DELETE /api/accounts/:id` refusing to delete the caller's only remaining account; OAuth token bound to a non-`/api` audience; a domain-order or cPanel-write endpoint called with a system account-bearer key (`user_required`) | Empty + `WWW-Authenticate: Token realm="Application"`; `{"error":"invalid_token",...}` for the audience case; `{"errors":[...],"code":"user_required"}` for the account-bearer case |274| `402` | Service suspended for unpaid invoice (dunning); registrar fee gate (privacy/registrant change) | `{"errors":["..."],"code":"service_suspended","invoice":{...}}` or `{...,"code":"payment_required","price":{...},"portal_url":"..."}` |275| `403` | Insufficient role (`{"errors":["Not Authorized"]}`); OAuth scope failure; Shield not in plan / premium required; inside a mailspace — no edit permission (`not_authorized`), staff/dunning hold (`mailspace_suspended`), or soft-deleted (`pending_delete`) | `{"errors":["Not Authorized"]}` or `{"error":"insufficient_scope"/"shield_not_in_plan"/"shield_premium_required"}` |276| `404` | Resource not found or not accessible to this token | Usually empty (`head`); some render `{"errors":[...]}` |277| `409` | Conflict — Bunny resource not active (`cdn_not_active`, `shield_not_active`); registrar process already in flight (`registration_busy`); mailspace not yet in the mail platform (`not_provisioned`); a verification retry on a mailspace that is already set up (`already_provisioned`) | `{"error":"cdn_not_active"}` / `{"errors":[...],"code":"registration_busy",...}` |278| `422` | Validation failure or permission restriction (e.g. inherited role, reseller-only, resize constraints); billing still settling (`billing_settling`); payment could not be initiated for an order/plan-change/domain order (`cart_pay_failed`); a site's transactional email not provisioned (`mail_not_provisioned`) or not active (`mail_not_active`) | `{"errors":["..."]}`, often with a `"code"` |279| `429` | The global rate limit (600/10min) — **empty body**. Also two per-endpoint limiters that *do* carry a body, both one run per 5 minutes: `POST /api/mailspace/:id/domains/dns_check` (per domain) and `POST /api/sites/:id/mail/dns_check` (per site, shared with the control panel) | Global: empty. The two DNS checks: `{"errors":["..."],"code":"rate_limited"}` |280| `502` | Upstream failure — Bunny (CDN/Shield), the domain registrar, or the transactional-email provider. **The three use different body shapes**, so don't parse them with one branch. | Bunny: `{"error":"<message>"}` (a bare `error` string, no `code`). Registrar: `{"errors":["..."],"code":"..."}` — `registrar_unavailable`, `authcode_unavailable` (EPP-code fetch) or `process_unavailable` (process resend/cancel). Transactional email: `{"errors":["..."],"code":"log_search_failed"}` on `GET /api/sites/:id/mail/logs`. |281| `503` | Feature disabled (`domain-registration` flag); no registrar configured for a TLD; mail hosting not configured (`stalwart_unavailable`, on **every** `/api/mailspace/:id/…` endpoint, reads included); transactional email not configured (`mailchannels_unavailable`); **any mail-server read or write that could not be performed** — as distinct from one the mail server refused — under a per-resource code, see the mailspace read-failure convention below | `{"errors":["..."],"code":"feature_disabled"/"registrar_unavailable"/"missing_api_key"/"stalwart_unavailable"/"mailchannels_unavailable"/"mailboxes_unavailable"/"domains_unavailable"/"aliases_unavailable"/"groups_unavailable"/"mailing_lists_unavailable"/"archived_items_unavailable"/"…"}` |282283> **Note:** there is **no longer** a generic `domains`/`backups` "feature flag → 503" model. DNS, domains, backups, and restores are no longer feature-flag gated. The only `FeatureFlag` 503 in the API is the `domain-registration` flag. CDN/Shield availability is signaled by **`409`** (`cdn_not_active`/`shield_not_active`), and Shield plan access by **`403`** (`shield_not_in_plan`/`shield_premium_required`).284285**Common error messages:**286287| Error body | Cause |288|-----------|-------|289| `{"errors":["Missing X-Auth-Account"],"code":"missing_account"}` | Header required but absent (400) |290| `{"errors":["Missing X-Auth-Account"]}` — **no `code` key** | Same condition on `POST /api/dns_zones` only. Zone create renders its own body and does not carry a `code`, so a client keying on `code == "missing_account"` will miss it (400). |291| `{"errors":["Account unable to create orders."],"code":"account_cannot_order"}` | Account state prevents orders (e.g. trial with 2+ sites) (400). Both the site-order and the domain-order path send this same message **and** this `code` — branch on the code. |292| `{"errors":["Must have reseller permissions"]}` | Reseller-only endpoint (422) |293| `{"errors":["Unable to remove an inherited role."]}` | Deleting an inherited account role (422) |294| `{"errors":["..."],"code":"no_default_payment_method"}` | Order/resize with no saved payment method (400) |295296---297298## Authorization & Account Model299300Many 401/403 errors come from token scope or role gaps.301302### Account Types303304| Type | How to identify | What it can do |305|------|----------------|----------------|306| Regular | Default | Manage own resources per role flags |307| Reseller | `reseller: true` on account | Create sub-accounts, create users, manage sub-account billing |308| Admin | `is_admin` on the **user/API key** (not the account) | A broad, but **not universal**, bypass: it satisfies the view / edit / create / destroy / billing-view / WP-login checks on any account, and it can create top-level accounts. It does **not** satisfy the site-lifecycle check — see the `is_admin` role-flag row below. **OAuth tokens are never admin.** |309| Trial | `is_trial: true` | **Blocked from the API entirely** (auth returns 401) |310311**Brands:** resellers can white-label via an `AccountBrand` (hostname, name, logo, theme, SMTP, support contacts). Each account belongs to a brand; the brand is resolved from the request hostname and isolates OAuth tokens.312313### Role Permission Flags314315Every user has a role on each account they belong to. Flags on the role:316317| Flag | What it controls in the API |318|------|-----------------------------|319| `is_admin` | Full access to account operations — overrides the other flags on this table. **One deliberate exception:** the site-lifecycle gate (below) ignores platform-staff `is_admin` entirely and requires actual membership on the account. |320| `can_edit` | PATCH/update on accounts, sites, domains, zones, registrations |321| `can_create` | POST orders / create resources |322| `can_destroy` | The `DELETE` gate on: `/api/accounts/:id`, `/api/dns_zones/:id`, and the per-site sub-resources (backups, cache, site domains, edge rules, Shield rules and access lists). A non-admin user cannot delete their **only** remaining account even with this flag — the refusal is **`401` with an empty body**, not a `403`. |323| *(site lifecycle)* | **`DELETE /api/sites/:id` is not gated on `can_destroy`.** It requires the **billing** role on the account: either `is_admin`, or `billing` **and** `can_edit` together (walked up to the billing account for children that inherit billing). Platform-staff `is_admin` on the *user* grants nothing here — the caller must be a member of the account. `DELETE /api/cpanel_accounts/:username` uses the same gate. |324| `billing` | View billing/pricing data; with `can_edit`, manage billing |325| `wp_login` | Generate SSO URLs for **any** WP user — without it, SSO is limited to WP users the token's user is explicitly linked to |326327A suspended user fails every permission check. List available roles: `GET /api/user_roles`.328329### Role Inheritance330331A user granted a role on a parent account automatically receives the same role on all child accounts (cascaded asynchronously). Inherited roles carry `inherited_from` in the response and cannot be removed at the child level — change the parent role to update all descendants (deleting an inherited role returns **`422`**).332333### Permission Check Flow3343351. Is the token valid, IP allowed, account not on trial, not rate-limited?3362. For OAuth tokens: does the token's scope cover this action? (Session/API keys skip this.)3373. Is `X-Auth-Account` required by this endpoint?3384. Does the token's user have the required role flag on the target account?3395. For reseller-only operations: is the account a reseller?3406. For per-site tool endpoints: is the site dunning-suspended? (→ 402)341342### Reseller Capabilities343344With a reseller account and `X-Auth-Account` set to it:345- `POST /api/accounts` — create sub-accounts346- `POST /api/users` — create managed users (returned with an auto-generated password and API token)347348Sub-accounts inherit the reseller's billing plan.349350### Dunning Suspension351352When a site's invoice is unpaid, per-site **show/update/destroy and tool endpoints** return **`402`**:353354```json355{356 "errors": ["This service is suspended because an invoice is unpaid."],357 "code": "service_suspended",358 "invoice": { "number": "INV-123", "hosted_url": "https://..." }359}360```361362List endpoints are not blocked — they surface `dunning_suspended: true` on the site instead.363364---365366## Async Patterns367368### Tasks369370Most provisioning operations are async and answer **`202 Accepted`** — but there is **no371single envelope**. Do not assume a task reference; several `202` bodies carry no task id372at all, and some carry nothing. The shapes actually in use:373374| `202` body | Endpoints |375|---|---|376| **Empty** — no body | site rename (`PATCH /api/sites/:id` with only `name`); `POST /api/sites/:id/restart`; `PATCH /api/sites/:id/restores/:id`; `PATCH`/`DELETE /api/sites/:id/backups/:id`; `PATCH`/`DELETE /api/sites/:id/domains/:id`; `DELETE /api/dns_zones/:id`; `PATCH`/`DELETE /api/dns_zones/:zone_id/records/:id`; `POST /api/accounts/:id/roles` and `DELETE /api/accounts/:id/roles/:id`; `DELETE /api/accounts/:id`; `DELETE /api/api_keys/:id`; `DELETE /api/orders/:id` (order cancel) |377| `{"task_id": <int>}` | `PATCH /api/sites/:id/variants/:id` (PHP version change) |378| `{"task_id": <int>, "status": "PENDING", "cache_type": "<id>"}` | the cache layer — `PATCH`/`DELETE /api/sites/:id/cache/:id` and `DELETE /api/sites/:id/cache/:id/purge` |379| **The cart envelope** (`{status:"accepted", cart:{…}, payment:{…}, orders:[…]}`) | `POST /api/orders` and `POST /api/orders/domain`; `PATCH /api/sites/:id` **with** `plan`; `POST`/`PATCH /api/cpanel_accounts`; `POST`/`PATCH /api/mailspace`; `POST /api/domain_registrations/:id/registrant_change` |380| `{"status":"preparing","backup_id":"…"}` | `POST /api/sites/:id/backups/:id/export` |381| `{"status":"purging","username":"…"}` | `POST /api/cpanel_accounts/:username/purge` |382| `{"status":"pending_deletion","username":"…","delete_scheduled_at":"…"}` | `DELETE /api/cpanel_accounts/:username` |383| `{"status":"accepted","domain":"…","domain_type":"…"}` | `POST /api/cpanel_accounts/:username/domains` |384| `{"id":"<guid>","ns1":"…","ns2":"…"}` **or** `{"id":"<guid>","cname":"…"}` | `POST /api/sites/:id/domains` — which of the two you get depends on how that domain has to be pointed (nameservers vs CNAME) |385| **The resource's own body** | `PATCH /api/accounts/:id` (the account); a domain-registration or domain-contact mutation that opened a registrar process (its `show` body, carrying a `pending_process` block) |386| **The verification block** (`{"verification":{"domain":…,"state":…,…}}`) | `POST /api/mailspace/:id/domain_verification` — provisioning is enqueued, not finished. Poll `GET` on the same path until `state` reads `"provisioned"`. |387| `{"status":"queued"}` | `POST /api/sites/:id/mail/dns_check` — no id to poll; re-read the transactional-email overview for the result. |388| `{"status":"accepted","task_id":<int>}` or `{"status":"accepted","noop":true}` | `POST /api/webhooks/cdn_cache` (infra-facing, not a customer endpoint) |389390That table is the complete set — no other `/api` endpoint answers `202`.391392**Async operations include:** PHP version change, backup creation, restore, restart, domain add/remove, account-role removal, cache enable/disable/purge, account delete. (Site creation, site resize/plan-change, domain orders, cPanel account orders and resizes, and Mailspace purchases and resizes are also async but are polled via the **cart**, not a task — see [Carts](reference/endpoints-orders.md#carts) below.)393394**Polling task status:**395396```bash397GET /api/tasks/:id398GET /api/sites/:site_id/tasks/:id399```400401**Task statuses:**402403| Status | Meaning |404|--------|---------|405| `PENDING` | Queued, not yet started |406| `RUNNING` | Currently executing |407| `OK` | Completed successfully |408| `FAILED` | Failed |409| `CANCELLED` | Cancelled |410| `PAUSED` | Paused |411412**Completion callback** — attach a `callback` to an order (`POST /api/orders`, `POST /api/orders/domain`, or `POST /api/cpanel_accounts`) and CloudPress POSTs to your URL when that order's task finishes, instead of you polling:413414```json415{ "callback": { "url": "https://your-app.com/webhook", "authorization": "Bearer your-secret" } }416```417418- **Permitted keys:** `url` (required) and `authorization` (optional, sent **verbatim** as the `Authorization` header). Any other key in the `callback` object is discarded. Only the three order-placing endpoints above consume a `callback`, so callbacks attach to **order** tasks only — it is ignored if sent anywhere else (a Mailspace purchase, for instance, does not accept one).419- **What CloudPress sends:** `POST` to your `url` with body `{ "timestamp": <epoch int>, "success": <bool>, "data": <task.data string> }` and headers `Authorization` (verbatim) + `Accept: application/json`. Same outbound delivery layer as billing webhooks — 30s timeout, any 2xx counts as420 success. **Retry backoff:** the wait is chosen from the age of the delivery's421 `timestamp` (5 / 10 / 15 minutes), which in practice means the first retry lands422 ~5 minutes after the failed attempt and every retry after that at 15-minute intervals.423 Retries stop once that `timestamp` is more than 4 hours old, so delivery is424 at-least-once — de-dup on `timestamp`, which stays fixed across all retries of one425 delivery.426- **When it fires:** only on a **terminal** task transition — the task reaching `OK`,427 `FAILED`, or `CANCELLED` (including when an infrastructure component reports the428 result in via `POST /api/webhooks/task/:id`, which sets `OK` or `FAILED`). Intermediate429 transitions such as `PENDING → RUNNING`, or a pause, queue **no** delivery. So expect430 one callback per order task, not a status stream.431- **Mechanism:** the `callback` is stashed on the cart and stamped onto the order task,432 and the delivery is queued at that terminal transition if the task carries one.433- **Utility endpoints** (distinct from your receiver — these are *inbound to CloudPress*): `GET /api/webhooks/task` returns `{ip_address}` for a reachability test; `POST /api/webhooks/task/:id` is how a task result is reported **into** CloudPress (used by infra; API-key credential, **OAuth-blocked**; idempotent via a `data`+`success` digest), and posting that result is what triggers the outbound callback. It expects an account context and, unlike the endpoints listed under [`X-Auth-Account`](#authentication), does not check for one — sending it without one produces a `500`, not a `400`.434435### Carts (order/billing async window)436437Five endpoints return the same **cart envelope** (status `accepted`): `POST /api/orders`; site plan-change (`PATCH /api/sites/:id` with `plan`); cPanel account order/resize (`POST`/`PATCH /api/cpanel_accounts`); Mailspace purchase/resize (`POST`/`PATCH /api/mailspace`); and the paid registrant change (`POST /api/domain_registrations/:id/registrant_change`). Poll the cart for materialized orders. `orders` is empty during the async window on Stripe-billed plans, and a **declined off-session charge is still a `202`** — never branch on the HTTP status. What to branch on depends on the operation: a **purchase** reports a declined or challenged charge as `payment.status: "awaiting_authentication"`, but a **resize** (site plan-change, cPanel resize, Mailspace resize) builds a proration cart and reports `processing` with a populated `payment.hosted_invoice_url` instead. On a resize, branch on that URL being non-null. See [Orders](reference/endpoints-orders.md#orders) and [Carts](reference/endpoints-orders.md#carts).438439### Registrar processes440441Domain-registration mutations may complete synchronously (200) or open a registrar **process** (202 with `pending_process`). Poll via the [processes](reference/endpoints-domains.md#domain-registration-processes) endpoints. A registration with an in-flight process is "busy" and rejects further mutations with **`409`** `registration_busy`.442443---444445## Feature & Plan Gating446447- **`domain-registration` feature flag** — when off, all domain-registration and domain-contact endpoints return **`503`** `{"errors":[...],"code":"feature_disabled"}`.448- **Shield plan entitlement** — the site's product must include Shield, else **`403`**449 `{"error":"shield_not_in_plan"}`. The gate is declared on this enumerated set:450 `/api/sites/:id/shield` (show/create/update/destroy) plus the nested451 `shield/events`, `shield/bot_detection`, `shield/waf`, `shield/waf_custom_rules`,452 `shield/rate_limits` and `shield/access_lists` — **reads as well as writes**. Two of453 those actions additionally require a **premium** plan —454 `PATCH /api/sites/:id/shield/bot_detection` and455 `POST /api/sites/:id/shield/waf_custom_rules` — else **`403`**456 `{"error":"shield_premium_required"}`.457- **Bunny activation** — CDN/Shield endpoints return **`409`** `cdn_not_active` / `shield_not_active` when the underlying Bunny pull zone / shield zone hasn't been provisioned yet.458- **Registrar availability** — registrar operations on a TLD with no configured registrar return **`503`** `registrar_unavailable`.459- **cPanel availability** — cPanel hosting is limited to workspaces that are already cPanel customers; every `/api/cpanel_accounts` route returns **`403`** `{"errors":[...],"code":"cpanel_not_enabled"}` for any other workspace. A **`503`** `cpanel_unavailable` means the hosting platform could not be reached and is retryable; `cpanel_not_enabled` is not.460- **Mail platform availability** — when mail hosting is not configured, **every**461 `/api/mailspace/:id/…` endpoint returns **`503`**462 `{"errors":["The mail server is not configured."],"code":"stalwart_unavailable"}`.463 Reads included — this gate runs before the mailspace is even looked up, so it is not a464 write-only refusal.465- **Mailspace state gates** — the endpoints nested under `/api/mailspace/:id` apply four466 further gates in this order, and a client should map each code to exactly one cause:467 **`403`** `not_authorized` (no edit permission on the mailspace's own workspace),468 **`403`** `mailspace_suspended` (staff block or dunning hold), **`403`** `pending_delete`469 (mailspace soft-deleted), **`409`** `not_provisioned` (mailspace not yet in the mail470 platform — complete domain verification first). Two of those four are decided on the471 **HTTP verb**, not the action name — `not_authorized` and `pending_delete`: `GET` and472 `HEAD` always pass, so **reads stay available on a soft-deleted mailspace for its whole473 retention window**. `mailspace_suspended` is **not** verb-gated: a staff-blocked or474 dunning-held mailspace refuses **reads as well as writes**, even though its message reads475 "cannot be modified". Do not infer the verb rule from one code to the next.476 **Two deliberate exceptions:** the mailspace **purge** endpoint does not apply477 `pending_delete` (being soft-deleted is its precondition), and the **primary-domain478 verification** endpoint does not apply `not_provisioned` (it runs before provisioning is479 possible).480- **Mailspace read failures are `503` with a per-resource code, and an empty collection481 is never one of them.** Every index inside a mailspace reads the mail server live, and482 each answers its own code when that read could not be **performed** —483 `mailboxes_unavailable`, `domains_unavailable`, `aliases_unavailable`,484 `groups_unavailable`, `mailing_lists_unavailable`, `archived_items_unavailable`,485 `purged_mailboxes_unavailable`. The code names *which* read failed, which is the point:486 `GET .../groups` has two, and `group_members_unavailable` now means only that the member487 tally failed while the group list itself came back. **So an empty array in a `200` means488 the mail server was asked** — a `200` no longer hides a failed read, and a client may489 reconcile its own state against it. That buys honesty about the read, **not490 completeness**: every one of these lists is capped upstream (500 rows) and truncates491 silently, with no total and no flag, and `purged_mailboxes` has the worst of it — its cap492 is applied server-wide *before* tenant filtering, so an empty `200` there is still not493 proof the mail is gone. Three further exceptions, none of which generalises: mailbox mail494 rules answer **`422`** `mail_rules_unavailable` rather than a 503; the app-password list,495 the masked-email list, the delivery logs and the by-id group and mailing-list lookups are496 still **tolerant** and degrade to an empty result or a `404`.497- **On a mailspace write, `503` answers one of two opposite questions — check which.**498 The distinction is deliberate and it is per code, not per status: some `503`s mean499 *nothing was attempted*, others mean *something was attempted and the outcome is500 unknown*. `restore_unavailable` (purged-mailbox restore) and501 `archived_item_lookup_unavailable` mean nothing happened and the resource is untouched.502 `restore_un503504…(truncated)