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.
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.
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:
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"] export class ProxyCharmError extends Error {
constructor({ code, message, suggestion, status }) {
super(message);
this.name = "ProxyCharmError";
this.code = code;
this.suggestion = suggestion;
this.status = status;
}
}
/** Return data, or throw ProxyCharmError with a usable code. */
export async function unwrap(response) {
const payload = await response.json();
if (!response.ok) {
const detail =
typeof payload.detail === "string"
? { code: "HTTP_ERROR", message: payload.detail }
: (payload.detail ?? {});
throw new ProxyCharmError({
code: detail.code ?? "HTTP_ERROR",
message: detail.message ?? response.statusText,
suggestion: detail.suggestion,
status: response.status,
});
}
if (!payload.success) {
throw new ProxyCharmError({ ...payload.error, status: response.status });
}
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. |