Errors
There are two error vocabularies in CallAgent, and confusing them is the most common integration mistake. Read this section before the tables.
error.type | error.code | |
|---|---|---|
| Where | Every non-2xx HTTP response from /api/v1 | Provider failures, call events, tool rejections |
| Answers | "what kind of failure was this request?" | "what specifically went wrong in the voice pipeline?" |
| Size | 10 values from the central renderer, plus a few local ones | 55 values, shared across the platform |
| Always present | Yes, on a rendered error | No — 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
{
"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
type | Status | What it means |
|---|---|---|
authentication_error | 401 403 | Missing, invalid, revoked or expired key; or a key not permitted from this origin or address. |
organization_error | 403 | The organization is suspended or otherwise not active. |
permission_error | 403 | Authenticated, but the key does not carry the required scope. The message names the scope. |
not_found | 404 | No such resource — or it belongs to another organization. The two are deliberately indistinguishable. |
validation_error | 422 | The body failed validation. errors is a field-keyed map. |
plan_limit_exceeded | 402 | A plan ceiling. code names the entitlement, with limit and used. |
rate_limit_error | 429 | Too many requests. retryable: true, plus Retry-After. |
provider_error | 502 504 429 | An upstream provider failed. Carries provider, code, retryable and often provider_request_id. |
ssrf_blocked | 422 | A URL you supplied resolved somewhere it may not go. code names the reason. |
http_error | varies | An HTTP error with no more specific mapping. |
server_error | 500 | Our 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.
type | Status | Where |
|---|---|---|
retrieval_error | 422 | Knowledge-base query |
invalid_integration | 422 | Integration connect |
invalid_state | 422 | Webhook delivery replay — note the name suggests 409, but it is 422 |
forbidden | 403 | Contact-centre permission checks |
billing_unavailable | 503 | Stripe is not configured on this deployment |
telephony_error | 409 422 502 | Carrier and provisioning failures. A different envelope: {type, message, provider, code, retryable} with no documentation_url and no request_id. |
not_implemented | 501 | A 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.
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
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) . ')',
);
}# -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 forThe 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
| Code | HTTP | Meaning |
|---|---|---|
provider_timeout | 504 | The provider did not answer in time. |
provider_auth | 502 | The provider rejected the credential. |
provider_rate_limit | 429 | The provider is throttling us. |
provider_unavailable | 503 | The provider is down or unreachable. |
provider_bad_response | 500 | The provider answered with something unusable. |
provider_quota_exceeded | 402 | The account's quota with the provider is spent. |
provider_unsupported | 500 | The provider does not support what was asked. |
all_providers_failed | 503 | The primary and every fallback failed. |
circuit_open | 503 | The breaker is open; the provider is being rested. |
Outbound safety
| Code | HTTP | Meaning |
|---|---|---|
ssrf_blocked | 403 | A tenant-supplied URL resolved somewhere it may not go. |
sandbox_denied | 403 | A sandboxed tool asked for something the sandbox refuses. |
sandbox_timeout | 504 | A sandboxed tool ran past its limit. |
response_too_large | 500 | A 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
| Code | HTTP | Meaning |
|---|---|---|
tool_rejected | 500 | The call was refused — see the rejection reason. |
tool_timeout | 504 | The tool exceeded timeoutSeconds. |
tool_failed | 500 | The tool ran and failed. |
tool_not_found | 404 | No such tool on this assistant. |
invalid_tool_arguments | 422 | The model's arguments did not match the parameter schema. |
mcp_unauthorized | 502 | The MCP server rejected the credential. |
mcp_connection_failed | 500 | The MCP server could not be reached. |
mcp_tool_not_found | 404 | The named tool is not on that server. |
mcp_timeout | 504 | The MCP call exceeded its timeout. |
mcp_permission_denied | 403 | Policy or risk tier blocked the call. |
Knowledge
| Code | HTTP | Meaning |
|---|---|---|
knowledge_unavailable | 500 | Retrieval failed. |
knowledge_insufficient | 500 | Nothing 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.
| Code | HTTP | Meaning |
|---|---|---|
concurrency_exceeded | 429 | No concurrency headroom. |
insufficient_credits | 402 | The wallet cannot fund the call. |
organization_suspended | 403 | The organization is not allowed to place calls. |
destination_blocked | 403 | Destination policy refused the number. |
rate_limited | 429 | Too 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
| Code | HTTP | Meaning |
|---|---|---|
telephony_rejected | 500 | The carrier refused the call. |
sip_error | 500 | A SIP-level failure; the response code is on the event. |
transfer_failed | 500 | The transfer did not complete. |
no_answer | 500 | Nobody picked up. |
media_failure | 500 | Audio stopped flowing. |
Contact centre
| Code | HTTP | Meaning |
|---|---|---|
no_operator_available | 503 | No operator could take the call. |
queue_full | 503 | The queue is at max_waiting_calls. |
Storage
| Code | HTTP | Meaning |
|---|---|---|
storage_failure | 500 | Writing or reading an artifact failed. |
recording_unavailable | 500 | The recording does not exist or has been purged. |
Configuration and lookup
| Code | HTTP | Meaning |
|---|---|---|
invalid_configuration | 422 | The assistant, tool or trunk configuration is not valid. |
assistant_not_found | 404 | |
phone_number_not_found | 404 | |
call_not_found | 404 | |
invalid_state_transition | 409 | Something asked a call to move to a state it cannot reach. |
Transport and platform
| Code | HTTP | Meaning |
|---|---|---|
unauthorized | 401 | Missing or invalid key. |
forbidden | 403 | Authenticated, but not permitted — usually a missing scope. |
not_found | 404 | |
validation_failed | 422 | Request body failed validation; see errors. |
idempotency_conflict | 409 | The same idempotency key was used for a different request. |
aborted | 500 | The operation was cancelled in flight. |
internal_error | 500 | The 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 in | Read this page |
|---|---|
/api/authentication, /api/client-auth, /api/api-keys | Authentication |
/api/scopes | Authentication → Scopes |
/api/rate-limits | Rate limits |
/api/errors, /api/errors#validation, /api/errors#server | This page |
/billing/plan-limits | Rate limits → Other limits |
/webhooks#redelivery | Webhooks → Inspecting and replaying |
/errors/{code} | The shared vocabulary above |