Errors
Standard error response shape, HTTP status code reference, validation error structure, and recommended retry behavior.
All zoxaAI errors return a JSON body under a detail key. The shape of detail depends on the endpoint and the failure:
- a string for most business-logic and authentication errors,
- a structured object for
402,429, provider-dial failures, and schema-validation failures onPOST /call(each documented below), - an array of field errors for
422validation on the endpoints that bind the request body automatically.
Always check the HTTP status — and whether detail is a string, object, or array — before parsing.
Standard shape
{ "detail": "Human-readable error message" }Status code reference
| Status | Category | Retry? |
|---|---|---|
400 | Bad request — business-logic violation. Don't retry without fixing the body. | ✗ |
401 | Authentication missing or invalid. | ✗ until credentials fixed |
402 | Quota / billing. | After plan upgrade or period reset |
403 | Forbidden — auth succeeded but lacks scope. | ✗ |
404 | Resource not found in your organization. | ✗ |
409 | Conflict — uniqueness violation. | ✗ unless deduping |
422 | Schema validation failed. | ✗ until body fixed |
429 | Rate limit exceeded. | ✓ with exponential backoff |
500 | Server error. Rare — please report. | After short delay |
501 | Feature not implemented for this provider yet. | ✗ |
502 | Upstream provider error (Twilio, ElevenLabs, OpenAI, etc.). | ✓ with backoff |
503 | Required infrastructure not configured. | After ops fix |
400 — business-logic rejections
The request validates as JSON and matches the schema, but a business rule fails. On these, detail is a string. Common messages:
detail | Context |
|---|---|
"No organization selected" | API key has no active org (rare — only happens for keys created without an org context). |
"agent_not_found" | agentId doesn't resolve in your org. |
"invalid_credentials: <reason>" | Provider rejected the credential probe during inbound register or outbound dial. |
"unknown_provider: <name>" | callConfig.provider value isn't recognized. |
"'toNumber' is required when type='outbound'" | Missing toNumber on outbound. |
"'toNumber' is not allowed when type='inbound'" | Wrong direction. |
"Must provide either 'agentId' or 'agent'." | Agent selection rule failed. |
"Phone number +1... is not owned by this Twilio account" | Number isn't in the account. |
"No applicationId supplied and no default_app found..." | Vobiz inbound needs applicationId or a default app. |
"Cannot update a completed campaign" | Campaign in terminal state. |
Schema & cross-field validation on POST /call
POST /call validates the body itself, so a schema or cross-field failure (missing fields, wrong types, mode constraints, or a @model_validator rule) comes back as 400 — not 422 — with a structured detail object:
{
"detail": {
"error": "Agent configuration validation failed",
"details": [
{ "path": "callConfig.provider", "message": "Field required", "type": "missing" }
]
}
}Each details[] entry has path (the dot-joined field location), message, and type. The same shape is returned when resolving the agent-config snapshot for a transient or persistent agent fails validation.
401 — authentication
detail | Context |
|---|---|
"Authorization header required" | No X-API-Key header. |
"Invalid or expired API key" | Key not found or revoked. |
"Invalid or expired token" | JWT expired. |
402 — quota exhausted
{
"detail": {
"error_code": "insufficient_balance",
"error_message": "Wallet balance $0.00 is below the minimum $0.50 required to start a call.",
"balance_usd": "0.00",
"threshold_usd": "0.50"
}
}Retry after topping up the wallet. On POST /call the attempt is also persisted as a rejected call in call history (connectionStatus: insufficient_balance, endedReason: null) with the raw request and the balance frozen at rejection time — throttled to one row per reason per minute under retry storms.
403 — forbidden
Authenticated user lacks scope for the resource or action.
detail | Context |
|---|---|
"Access denied" | Cross-org access attempted (rare; we typically return 404). |
"Access denied. Superuser privileges required." | Non-superuser on admin endpoint. |
404 — not found in your org
detail | Context |
|---|---|
"Agent not found" | No agent with this UUID in your org. |
"Call not found" | No call with this id in your org. |
"Campaign not found" | Campaign not in your org. |
"API key not found" | Key id doesn't belong to your org. |
"binding_not_found" | Inbound binding id doesn't exist or belongs to another org. |
"Phone number not found" | Phone number id not found under the given config. |
Why `404` and not `403` for cross-org access
Returning 403 for a resource that exists in another org would leak the fact that the id is valid. We return 404 for both "doesn't exist" and "exists but not yours" — your code can treat them the same.
409 — conflict
detail | Context |
|---|---|
"Email already registered" | Signup with a taken email. |
"A telephony configuration named '...' already exists in this organization." | Duplicate config name. |
"This phone number is already added in this organization." | The number is already on one of this organization's telephony configs. |
Duplicates are only checked inside one organization. The same provider account and number can be added to any number of organizations.
422 — schema validation
Endpoints that bind the request body to a typed model automatically — agents CRUD, telephony, knowledge base, campaigns, API keys, and the rest — return 422 with an array detail when validation fails. Each item describes one field error.
{
"detail": [
{
"type": "string_too_short",
"loc": ["body", "name"],
"msg": "String should have at least 1 character",
"input": "",
"ctx": { "min_length": 1 }
}
]
}POST /call is the exception
POST /call validates its body inline rather than through automatic binding, so its outcomes differ from the array shape above:
- Schema / cross-field failures return
400with the{ error, details }object shown under400. - A model not in the catalog (only when a request persists a new config — a transient agent, or an inbound binding) returns
422with a plain stringdetail, e.g."llm model 'gpt-x' is not available for provider 'openai'. Pick a model from the catalog (GET /api/v1/user/configurations/providers)."
| Field | Type | Description |
|---|---|---|
type | string | Machine-readable error type (missing, string_too_short, value_error, ...). |
loc | array | Path to the bad field. Always begins with "body". Nested fields add segments: ["body", "model", "temperature"]. |
msg | string | Human-readable message. |
input | any | The value submitted. |
ctx | object | Constraint metadata (min/max values, valid options). May be absent. |
Common type values
| Type | Meaning |
|---|---|
missing | Required field not provided. |
extra_forbidden | Unknown field on a strict schema. Only the agent-config-carrying bodies reject unknown fields — POST /call, agent create/update, and inbound-binding bodies. Telephony, knowledge-base, and campaign bodies ignore unknown fields instead. |
string_type / int_type / etc. | Wrong primitive type. |
string_too_short / string_too_long | Length constraint. |
string_pattern_mismatch | Regex constraint. |
greater_than_equal / less_than_equal | Numeric bound. |
value_error | A custom @model_validator rejected the body — msg carries the reason. |
literal_error | Value not in Literal[...]. |
Multiple errors in one response
All validation failures are returned together:
{
"detail": [
{ "type": "string_too_short", "loc": ["body", "name"], "msg": "String should have at least 1 character", "input": "" },
{ "type": "literal_error", "loc": ["body", "model", "provider"], "msg": "Input should be 'openai', 'anthropic', ...", "input": "invalid" },
{ "type": "less_than_equal", "loc": ["body", "model", "temperature"], "msg": "Input should be less than or equal to 2.0", "input": 5.0, "ctx": { "le": 2.0 } }
]
}429 — concurrent-call limit
{
"detail": {
"code": "concurrency_limit_reached",
"current_limit": 5,
"current_active": 5,
"message": "Concurrent-call limit reached (5/5 active). Upgrade concurrency in Billing → Wallet to raise it."
}
}Your org's concurrent-call slots are full on zoxaAI's side — wait for an active call to end, or buy more slots. Log detail.current_active/current_limit, not just the status code. On POST /call the attempt is persisted as a rejected call in history (connectionStatus: concurrency_limit_reached, endedReason: null, throttled to one row per minute under retry storms).
A 429 from zoxaAI is never your telephony provider's limit
If your own Twilio/Vobiz account hits its rate or concurrency limit, the dial fails with our 400/502 provider_rejected shape (with providerStatus carrying the provider's status) — see 502 below. Only concurrency_limit_reached means zoxaAI's gate.
Back off with exponential delay + jitter. Don't retry tight in a loop.
500 — server error
Stack traces are never returned. The detail carries a high-level message. Please report 500s — they indicate bugs.
501 — not implemented
{ "detail": "vobiz.initiate_call_stateless not implemented" }The feature exists in the schema but the chosen provider hasn't been wired yet. Today this affects Plivo/Vonage/Telnyx for outbound API-first dial. Use the Provider matrix on outbound to check support.
502 — upstream provider error
{ "detail": "xAI voice API returned 503" }The provider zoxaAI proxied to (ElevenLabs, OpenAI, Twilio, etc.) returned an error. Retry with backoff — these are usually transient.
On POST /call outbound, dial-leg provider failures use a structured detail instead:
{
"detail": {
"code": "provider_rejected",
"provider": "vobiz",
"providerStatus": 503,
"callId": "call_4e4e571f8c9b3cf8e99d",
"message": "Failed to initiate Vobiz call: ..."
}
}providerStatus is the HTTP status your telephony provider's API returned. Important: only provider 5xx surfaces as our 502 (retryable upstream failure). Provider 4xx — your own account's rate/concurrency limit, bad credentials, bad destination — surfaces as our 400 with the same provider_rejected shape: retrying won't help until you fix the account-side issue. callId links to the row in call history (connectionStatus: dial_failed), whose logs.dial_error carries the full provider message.
Handling errors in code
async function call(path, init = {}) {
const res = await fetch(`https://dashboard.zoxa.ai/api/v1${path}`, {
...init,
headers: { "X-API-Key": "zsk_...", ...init.headers },
});
if (res.ok) return res.json();
const body = await res.json().catch(() => ({}));
if (res.status === 422) {
for (const err of body.detail ?? []) {
console.error(`${err.loc.join(".")}: ${err.msg}`);
}
} else {
console.error(`HTTP ${res.status}:`, body.detail);
}
if (res.status === 429 || res.status >= 500) {
// backoff + retry
}
throw new Error(`API ${res.status}`);
}import httpx, time, random
client = httpx.Client(
base_url="https://dashboard.zoxa.ai/api/v1",
headers={"X-API-Key": "zsk_..."},
)
def call(method, path, **kw):
for attempt in range(4):
r = client.request(method, path, **kw)
if r.status_code < 400:
return r.json()
body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
if r.status_code == 422:
for err in body.get("detail", []):
print(".".join(map(str, err["loc"])), err["msg"])
raise RuntimeError("validation")
if r.status_code in (429, 502, 503) or 500 <= r.status_code < 600:
time.sleep(min(2 ** attempt + random.random(), 10))
continue
raise RuntimeError(f"{r.status_code}: {body.get('detail')}")
raise RuntimeError("exhausted retries")Best practices
| Practice | Why |
|---|---|
| Check HTTP status before parsing | Status determines whether detail is a string or array. |
| Don't pattern-match exact messages | Messages may change across releases — match on type / loc for 422, status code for everything else. |
Backoff 429, 502, 503, 5xx | Transient. Don't retry 4xx other than 429. |
Refresh JWT on 401 for dashboard sessions | API keys don't expire unless revoked. |
Treat 404 as "not yours" | Could be missing or in another org — your code handles them the same. |