zoxaAI
Homepage
API Reference

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 on POST /call (each documented below),
  • an array of field errors for 422 validation 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

StatusCategoryRetry?
400Bad request — business-logic violation. Don't retry without fixing the body.✗
401Authentication missing or invalid.✗ until credentials fixed
402Quota / billing.After plan upgrade or period reset
403Forbidden — auth succeeded but lacks scope.✗
404Resource not found in your organization.✗
409Conflict — uniqueness violation.✗ unless deduping
422Schema validation failed.✗ until body fixed
429Rate limit exceeded.✓ with exponential backoff
500Server error. Rare — please report.After short delay
501Feature not implemented for this provider yet.✗
502Upstream provider error (Twilio, ElevenLabs, OpenAI, etc.).✓ with backoff
503Required 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:

detailContext
"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

detailContext
"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.

detailContext
"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

detailContext
"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

detailContext
"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 400 with the { error, details } object shown under 400.
  • A model not in the catalog (only when a request persists a new config — a transient agent, or an inbound binding) returns 422 with a plain string detail, e.g. "llm model 'gpt-x' is not available for provider 'openai'. Pick a model from the catalog (GET /api/v1/user/configurations/providers)."
FieldTypeDescription
typestringMachine-readable error type (missing, string_too_short, value_error, ...).
locarrayPath to the bad field. Always begins with "body". Nested fields add segments: ["body", "model", "temperature"].
msgstringHuman-readable message.
inputanyThe value submitted.
ctxobjectConstraint metadata (min/max values, valid options). May be absent.

Common type values

TypeMeaning
missingRequired field not provided.
extra_forbiddenUnknown 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_longLength constraint.
string_pattern_mismatchRegex constraint.
greater_than_equal / less_than_equalNumeric bound.
value_errorA custom @model_validator rejected the body — msg carries the reason.
literal_errorValue 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

PracticeWhy
Check HTTP status before parsingStatus determines whether detail is a string or array.
Don't pattern-match exact messagesMessages may change across releases — match on type / loc for 422, status code for everything else.
Backoff 429, 502, 503, 5xxTransient. Don't retry 4xx other than 429.
Refresh JWT on 401 for dashboard sessionsAPI keys don't expire unless revoked.
Treat 404 as "not yours"Could be missing or in another org — your code handles them the same.

On this page