Skip to content

Errors ​

There are two error vocabularies in CallAgent, and confusing them is the most common integration mistake. Read this section before the tables.

error.typeerror.code
WhereEvery non-2xx HTTP response from /api/v1Provider failures, call events, tool rejections
Answers"what kind of failure was this request?""what specifically went wrong in the voice pipeline?"
Size10 values from the central renderer, plus a few local ones55 values, shared across the platform
Always presentYes, on a rendered errorNo — only on the errors that have one

If you are writing an HTTP client, branch on error.type and the status code.error.code is extra detail when it is there, and it is frequently absent. If you are handling a call event or a webhook payload, error.code is the one you want.

The HTTP error shape ​

json
{
  "error": {
    "type": "validation_error",
    "message": "The request payload failed validation.",
    "errors": { "name": ["The name field is required."] },
    "documentation_url": "https://docs.callagent.dev/api/errors#validation",
    "request_id": "01JQ8Z9K3T4V5W6X7Y8Z9A0B1C"
  }
}

type and message are always present. errors, code, retryable, provider, provider_request_id, limit, used and request_id appear where they apply.

request_id is not on every response

It is on errors rendered by the central handler. It is not on any 2xx, and it is not on the errors raised by authentication middleware and a handful of controllers that build their own body — including a bad API key, the most common error you will see. No response carries an X-Request-Id header either.

You can supply your own: send X-Request-Id on the request and the rendered error will echo it back, truncated to 64 characters. That is the reliable way to correlate a failure with your own logs.

The types ​

typeStatusWhat it means
authentication_error401 403Missing, invalid, revoked or expired key; or a key not permitted from this origin or address.
organization_error403The organization is suspended or otherwise not active.
permission_error403Authenticated, but the key does not carry the required scope. The message names the scope.
not_found404No such resource — or it belongs to another organization. The two are deliberately indistinguishable.
validation_error422The body failed validation. errors is a field-keyed map.
plan_limit_exceeded402A plan ceiling. code names the entitlement, with limit and used.
rate_limit_error429Too many requests. retryable: true, plus Retry-After.
provider_error502 504 429An upstream provider failed. Carries provider, code, retryable and often provider_request_id.
ssrf_blocked422A URL you supplied resolved somewhere it may not go. code names the reason.
http_errorvariesAn HTTP error with no more specific mapping.
server_error500Our fault. Already reported on our side. request_id is what support needs.

A 500 never echoes the exception message. message is always Something went wrong on our side. The incident has been recorded.

Types raised locally by some handlers ​

These bypass the central renderer, so they carry no request_id and sometimes no documentation_url. Handle them as you would the equivalent above.

typeStatusWhere
retrieval_error422Knowledge-base query
invalid_integration422Integration connect
invalid_state422Webhook delivery replay — note the name suggests 409, but it is 422
forbidden403Contact-centre permission checks
billing_unavailable503Stripe is not configured on this deployment
telephony_error409 422 502Carrier and provisioning failures. A different envelope: {type, message, provider, code, retryable} with no documentation_url and no request_id.
not_implemented501A telephony provider with no adapter for this capability

Handling HTTP errors ​

Branch on the status first, then the type. Do not infer retryability from the status code — trust retryable when it is present.

js
async function call(url, init) {
  const requestId = crypto.randomUUID();
  const response = await fetch(url, {
    ...init,
    headers: { ...init.headers, 'X-Request-Id': requestId },
  });

  if (response.ok) return response.json();

  const { error } = await response.json();

  switch (response.status) {
    case 401:
    case 403:
      // Not retryable. Fix the key, its scopes, or its origin restriction.
      throw new Error(`${error.type}: ${error.message}`);

    case 422:
      // error.errors is field -> [messages] when type is validation_error.
      throw new Error(`${error.message} ${JSON.stringify(error.errors ?? {})}`);

    case 402:
      // Plan ceiling: error.code names the entitlement, with limit and used.
      throw new Error(`${error.code}: ${error.used}/${error.limit}`);

    case 429:
      return retryAfter(Number(response.headers.get('Retry-After') ?? 1));

    default:
      if (error.retryable) return retryAfter(5);
      // Quote request_id to support; fall back to the one we sent.
      throw new Error(`${error.type}: ${error.message} (${error.request_id ?? requestId})`);
  }
}
php
<?php

function handle(int $status, array $body, array $headers, string $requestId): never
{
    $error = $body['error'] ?? ['type' => 'unknown', 'message' => 'No error body.'];

    // Not retryable: fix the key, its scopes, or its origin restriction.
    if ($status === 401 || $status === 403) {
        throw new RuntimeException("{$error['type']}: {$error['message']}");
    }

    if ($status === 422) {
        throw new InvalidArgumentException(
            $error['message'] . ' ' . json_encode($error['errors'] ?? []),
        );
    }

    // Plan ceiling: `code` names the entitlement, with `limit` and `used`.
    if ($status === 402) {
        throw new RuntimeException("{$error['code']}: {$error['used']}/{$error['limit']}");
    }

    if ($status === 429) {
        throw new RetryLater((int) ($headers['retry-after'] ?? 1));
    }

    if (($error['retryable'] ?? false) === true) {
        throw new RetryLater(5);
    }

    // Quote request_id to support; fall back to the one we sent.
    throw new RuntimeException(
        "{$error['type']}: {$error['message']} (" . ($error['request_id'] ?? $requestId) . ')',
    );
}
bash
# -i so you can see the status and the rate-limit headers alongside the body.
curl -i -X POST "$CALLAGENT_API_URL/assistants" \
  -H "Authorization: Bearer $CALLAGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Request-Id: $(uuidgen)" \
  -d '{"name": "Support Line"}'

# The two fields worth reading on any failure:
#   .error.type       what kind of failure it was
#   .error.request_id what support will ask for

The shared error.code vocabulary ​

55 codes, defined once as CallAgentErrorCode in @callagent/contracts and used identically in HTTP responses, call events and webhook payloads — so provider_timeout means the same thing wherever you meet it.

Retryable ​

Ten codes are retryable, and retryable on a response is derived from this list rather than set by hand:

provider_timeout · provider_rate_limit · provider_unavailable · provider_bad_response · mcp_connection_failed · mcp_timeout · tool_timeout · storage_failure · media_failure · rate_limited

Everything else fails identically on a second attempt. Retry the ten; fix the rest.

Providers ​

CodeHTTPMeaning
provider_timeout504The provider did not answer in time.
provider_auth502The provider rejected the credential.
provider_rate_limit429The provider is throttling us.
provider_unavailable503The provider is down or unreachable.
provider_bad_response500The provider answered with something unusable.
provider_quota_exceeded402The account's quota with the provider is spent.
provider_unsupported500The provider does not support what was asked.
all_providers_failed503The primary and every fallback failed.
circuit_open503The breaker is open; the provider is being rested.

Outbound safety ​

CodeHTTPMeaning
ssrf_blocked403A tenant-supplied URL resolved somewhere it may not go.
sandbox_denied403A sandboxed tool asked for something the sandbox refuses.
sandbox_timeout504A sandboxed tool ran past its limit.
response_too_large500A tool response exceeded the size cap.

ssrf_blocked is 403 here and 422 over HTTP

The shared vocabulary maps ssrf_blocked to 403; the control plane's HTTP renderer returns 422 for the same failure, on the grounds that the URL you sent is a bad request rather than a forbidden one. Over HTTP, expect 422 with error.type: "ssrf_blocked".

Tools and MCP ​

CodeHTTPMeaning
tool_rejected500The call was refused — see the rejection reason.
tool_timeout504The tool exceeded timeoutSeconds.
tool_failed500The tool ran and failed.
tool_not_found404No such tool on this assistant.
invalid_tool_arguments422The model's arguments did not match the parameter schema.
mcp_unauthorized502The MCP server rejected the credential.
mcp_connection_failed500The MCP server could not be reached.
mcp_tool_not_found404The named tool is not on that server.
mcp_timeout504The MCP call exceeded its timeout.
mcp_permission_denied403Policy or risk tier blocked the call.

Knowledge ​

CodeHTTPMeaning
knowledge_unavailable500Retrieval failed.
knowledge_insufficient500Nothing relevant came back and the assistant is in knowledge-only mode.

Admission ​

Checked before a call is created, so these appear on a call request rather than mid-conversation.

CodeHTTPMeaning
concurrency_exceeded429No concurrency headroom.
insufficient_credits402The wallet cannot fund the call.
organization_suspended403The organization is not allowed to place calls.
destination_blocked403Destination policy refused the number.
rate_limited429Too many requests.

POST /calls returns 201 even when origination fails

The outbound call service catches carrier and admission failures, marks the call failed and still answers 201. A concurrency denial, a blocked destination and a carrier outage all look like "created" from the status code alone. Read status and ended_reason in the response body, not the status code, to know whether the call is really going to happen.

Telephony ​

CodeHTTPMeaning
telephony_rejected500The carrier refused the call.
sip_error500A SIP-level failure; the response code is on the event.
transfer_failed500The transfer did not complete.
no_answer500Nobody picked up.
media_failure500Audio stopped flowing.

Contact centre ​

CodeHTTPMeaning
no_operator_available503No operator could take the call.
queue_full503The queue is at max_waiting_calls.

Storage ​

CodeHTTPMeaning
storage_failure500Writing or reading an artifact failed.
recording_unavailable500The recording does not exist or has been purged.

Configuration and lookup ​

CodeHTTPMeaning
invalid_configuration422The assistant, tool or trunk configuration is not valid.
assistant_not_found404
phone_number_not_found404
call_not_found404
invalid_state_transition409Something asked a call to move to a state it cannot reach.

Transport and platform ​

CodeHTTPMeaning
unauthorized401Missing or invalid key.
forbidden403Authenticated, but not permitted — usually a missing scope.
not_found404
validation_failed422Request body failed validation; see errors.
idempotency_conflict409The same idempotency key was used for a different request.
aborted500The operation was cancelled in flight.
internal_error500The fallback. If you see this, request_id is what support needs.

Tool rejection reasons ​

A rejected tool call carries a rejectionReason from a separate, narrower vocabulary. This is what the model is told, so it can route around the refusal rather than retry it:

user_rejected · forbidden_tool · permission_denied · confirmation_required · policy_blocked · ssrf_blocked · rate_limited · invalid_arguments · not_found · timeout · sandbox_denied

See Tools.

Where documentation_url points ​

Rendered errors carry a documentation_url. Do not follow it.

Each one is an absolute URL against https://docs.callagent.dev, hard-coded at its raise site in the control plane — fourteen separate string literals, not one config value. That hostname is not this site and is not served by this deployment, so the link cannot be rescued by a redirect here: a client following it never reaches our origin at all. The paths would not match either (/api/authentication, /api/scopes, /errors/{code} — we have no /api/ or /errors/ tree), but the hostname is the reason it is unreachable.

Treat documentation_url as a stable identifier for the kind of failure — it is reliable for that, and switching on it is fine — and map it yourself:

documentation_url ends inRead this page
/api/authentication, /api/client-auth, /api/api-keysAuthentication
/api/scopesAuthentication → Scopes
/api/rate-limitsRate limits
/api/errors, /api/errors#validation, /api/errors#serverThis page
/billing/plan-limitsRate limits → Other limits
/webhooks#redeliveryWebhooks → Inspecting and replaying
/errors/{code}The shared vocabulary above

Documented against the code. Anything not yet implemented is labelled, never described as working.