Errors

Every error carries a stable machine-readable code. Branch on the code, and show the message and suggestion to your users.

Error shapes

There are two, and which one you get depends on where the request failed. Handle both in the same place.

Business failures

The request was valid and authenticated, but the action was refused — for example, removing the last workspace on a live plan. HTTP 200, success: false, code in error.code.

Business failure
HTTP/1.1 200 OK

{
  "success": false,
  "data": null,
  "error": {
    "code": "SLOT_COUNT_MISMATCH",
    "message": "This plan includes 2 IPs per account, but 1 slot countries were sent.",
    "field": null,
    "details": null,
    "suggestion": "Choose one country (or Any) for each IP in the plan."
  },
  "timestamp": "2026-02-18T09:31:04.512Z"
}

Transport failures

The request never reached a handler: bad key, rate limit, malformed body, or a resource in another organization. Real HTTP error status, code in detail.code.

Transport failure
HTTP/1.1 403 Forbidden

{
  "detail": {
    "code": "ORGANIZATION_PERMISSION_DENIED",
    "message": "Workspace 'abc' does not belong to your organisation.",
    "suggestion": "Call GET /workspaces to list valid workspace ids."
  }
}

Handling errors

A small wrapper that normalises both shapes keeps the rest of your code simple:

Python
class ProxyCharmError(RuntimeError):
    """Normalised error for both response shapes."""

    def __init__(self, code, message, suggestion=None, status=None):
        super().__init__(message)
        self.code = code
        self.suggestion = suggestion
        self.status = status


def unwrap(response):
    """Return data, or raise ProxyCharmError with a usable code."""
    payload = response.json()

    if response.status_code >= 400:
        detail = payload.get("detail") or {}
        if isinstance(detail, str):
            detail = {"code": "HTTP_ERROR", "message": detail}
        raise ProxyCharmError(
            detail.get("code", "HTTP_ERROR"),
            detail.get("message", response.reason),
            detail.get("suggestion"),
            response.status_code,
        )

    if not payload["success"]:
        error = payload["error"]
        raise ProxyCharmError(
            error["code"],
            error["message"],
            error.get("suggestion"),
            response.status_code,
        )

    return payload["data"]

What to retry

Retry with backoff 429, any 5xx, ADD_ACCOUNT_CONFLICT, INVOICE_LIST_FAILED. Transient by nature.
Retry after a condition clears ADD_ACCOUNT_PENDING — poll GET /subscription until pending_change is null.
Do not retry Every validation and subscription-state code. The same request will fail the same way; fix the input or the plan first.
Never retry blind POST /workspaces without an idempotency key. You may create and pay for a second workspace.

Error catalogue

Grouped by cause. Codes are stable; we may add new ones, so treat an unrecognised code as a generic failure.

Authentication

Raised before your request reaches a handler. Always HTTP 401 with the code under detail.code.

Code HTTP What it means How to fix it
API_KEY_INVALID 401 The Authorization header is missing the key prefix, the key does not exist, or its organization has been deleted. Check that you sent the full key value, including the prefix. Create a new key if you no longer have the original.
API_KEY_REVOKED 401 The key was revoked in the dashboard. Create a new key and roll it out to your services.
API_KEY_EXPIRED 401 The key passed the expiry date it was created with. Create a new key. Expiry is set once at creation and cannot be extended.
NOT_AUTHENTICATED 403 No Authorization header was sent at all. Send Authorization: Bearer <key> on every request.

Rate limits

Raised by the per-key counter before the handler runs.

Code HTTP What it means How to fix it
RATE_LIMIT_EXCEEDED 429 The key exceeded its request budget for the current window. Wait the number of seconds in the Retry-After header, then retry.

Ownership and access

Raised when a path parameter points at a resource outside the key's organization.

Code HTTP What it means How to fix it
ORGANIZATION_PERMISSION_DENIED 403 The workspace id in the path belongs to another organization, or the profile that created the key is not an owner. Invoices require owner-level access. Call GET /workspaces for valid ids. For invoices, use a key created by an organization owner.
ASSIGNMENT_NOT_FOUND 404 No proxy assignment with that id exists in your organization. Call GET /proxies to list the assignment ids you can read.
ACCOUNT_NOT_FOUND 200 The workspace was deleted between your read and your write. Re-list workspaces and retry with a current id.
PROFILE_NOT_FOUND 200 The profile that created the key no longer exists. Create a new key from an active dashboard session.
PROFILE_SUSPENDED 200 The profile that created the key is suspended. Contact support, then create a new key.
ORGANIZATION_NOT_FOUND 200 The organization record could not be loaded. Retry once. If it persists, contact support.

Request validation

Returned by POST /workspaces and GET /invoices when a field value is not usable. Body-level, so the HTTP status is still 200.

Code HTTP What it means How to fix it
ACCOUNT_NAME_INVALID 200 The workspace name is empty or longer than the allowed length. Send a non-empty name.
ACCOUNT_ADDRESS_INVALID 200 The address exceeds the allowed length. Shorten the address, or omit the field.
ACCOUNT_COUNTRY_INVALID 200 The workspace country is not a two-letter ISO code. Send an ISO 3166-1 alpha-2 code such as US, or omit the field.
SLOT_INVALID_COUNTRY 200 A slot entry was not an object, or its country was not a two-letter ISO code. Send slots as [{"country": "DE"}], or [{"country": null}] for any country.
SLOT_COUNT_MISMATCH 200 The number of slots you sent does not match the IPs per workspace on your plan. Read subscription.plan.ip_count and send exactly that many slot entries.
INVOICE_STATUS_INVALID 200 The status filter is not a customer-visible invoice status. Filter by open, paid, uncollectible, or void.
VALIDATION_ERROR 422 The request body or query string failed schema validation — a missing required field, or a query parameter outside its allowed range. Read detail in the response, which names the offending field and the constraint it broke.

Subscription state

Returned by POST /workspaces and DELETE /workspaces/{id} when the plan cannot absorb the change.

Code HTTP What it means How to fix it
SUBSCRIPTION_NOT_ACTIVE 200 There is no live subscription, or it has not finished checkout. Start or complete a plan in the dashboard. Checkout is not available over the API.
SUBSCRIPTION_PAST_DUE 200 A payment failed, so seat changes are blocked. Update the card in the billing portal, then retry.
SUBSCRIPTION_CANCELING 200 The plan is set to end at the period boundary. Keep the plan in the billing portal, then retry.
LAST_ACCOUNT_CANCEL_ONLY 200 You tried to remove the only live workspace on the plan. Cancel the plan in the billing portal instead. A live subscription must keep at least one workspace.
ACCOUNT_NOT_REMOVABLE 200 The workspace is already removed, or is still confirming payment, so it is not in a removable state. Wait for its status to settle, then re-read it before deciding.
PLAN_NOT_FOUND 200 The plan behind the live subscription could not be loaded. Contact support. This is a data problem on our side.

Conflicts and transient failures

Safe to retry, in most cases with the same idempotency key. See the idempotency section on the conventions page.

Code HTTP What it means How to fix it
ADD_ACCOUNT_CONFLICT 200 Another seat change for the same organization was in flight, so this one lost the race. Retry with the same idempotency key after a short backoff.
ADD_ACCOUNT_PENDING 200 A plan change is still confirming payment. Poll GET /subscription until pending_change clears, then retry.
ADD_ACCOUNT_FAILED 200 The workspace could not be created — commonly because the idempotency key you sent already belongs to another organization's workspace. Retry with a fresh idempotency key.
STRIPE_CUSTOMER_MISSING 200 The organization has no billing customer, so a paid seat cannot be charged. Open Billing in the dashboard once to attach a payment method.
INVOICE_LIST_FAILED 200 The billing provider rejected the invoice list call. Retry after a short backoff. The message carries the upstream reason.