When something fails
What an API caller, a webhook receiver and an on-call engineer actually see when something goes wrong, and what to do about each.
The full code table is on Error codes. This page is the practical half: which failures to retry, which to fix, and where to look when a call went wrong and nobody knows why.
The API error body
Every non-2xx response from /api/v1 has this shape. It is snake_case and wrapped in error:
{
"error": {
"type": "validation_error",
"message": "The request payload failed validation.",
"errors": { "secrets": ["The secrets field is required."] },
"documentation_url": "https://docs.callagent.dev/api/errors#validation",
"request_id": "01M1ZKS6SVXR3CHEV87WCSFM60"
}
}| Field | When it appears |
|---|---|
type | Always. The category — see the table below. |
message | Always. Safe to show a developer; never the raw exception text. |
request_id | Always. A ULID, or the X-Request-Id you sent if it was ≤64 characters. Quote this in a bug report. |
errors | Validation failures only. Field-keyed, Laravel's shape. |
code | Provider and plan-limit failures. |
retryable | Provider, rate-limit and server failures. |
provider, provider_request_id | Provider failures. |
documentation_url | Usually. |
The type values
type | HTTP | Meaning | Do |
|---|---|---|---|
validation_error | 422 | Your body is wrong. errors says which field. | Fix and resend. |
authentication_error | 401 | Missing, invalid or revoked key. | Check the key. Rotation overlap may have expired. |
permission_error | 403 | The key lacks the scope, or the origin/IP restriction refused it. | Widen the scope or call from an allowed origin. |
not_found | 404 | No such resource — and this is also what another tenant's resource returns. | Check the id and the organization the key belongs to. |
rate_limit_error | 429 | Too many requests. | Wait Retry-After seconds. See Rate limits. |
plan_limit_exceeded | 402 | A commercial ceiling, not a technical one. limit and used are on the body. | Upgrade, or delete something. |
provider_error | 502 / 504 / 429 | A vendor rejected or timed out. provider names them. | Read retryable. |
ssrf_blocked | 422 | A URL you supplied resolves somewhere it may not. | Use a public, resolvable host. |
invalid_state | 422 | The action does not apply in this state. | |
http_error | varies | A framework-level HTTP error. | |
server_error | 500 | Ours. retryable: true, and it is reported server-side. | Retry once, then quote request_id. |
404 is deliberately identical for "does not exist" and "exists in another organization". That is not an oversight — distinguishing them tells an attacker which ids are real.
Two endpoints answer outside this envelope
The API-key middleware returns its own 401/403 body with no request_id, because it runs before the renderer. And POST /webhook-deliveries/{id}/redeliver builds its own 422 for "this delivery cannot be redelivered". Both use the same error.type / error.message field names, so a receiver keyed on those still works.
What to retry
Ten error codes are retryable, and retryable: true is derived from that 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.
When you retry POST /calls, send the same idempotency_key. Otherwise a retry after a timeout dials the person twice. See Telephony.
When a call fails
POST /calls answers 201 as soon as the call row exists. That is not a promise that anybody was dialled. Read status and ended_reason.
The 20 ended_reason values
| Group | Values |
|---|---|
| Somebody hung up | customer_hung_up, operator_hung_up, assistant_ended, assistant_ended_call_tool, transferred_away, api_request |
| The conversation ran out | silence_timeout, max_duration_reached, voicemail_detected |
| The carrier said no | no_answer, busy, rejected |
| We said no | insufficient_credit, concurrency_limit, destination_blocked |
| Something broke | pipeline_error, provider_error, telephony_error, gateway_lost |
unknown |
ended_by says who: customer, assistant, operator, system, api, supervisor.
The three that mean "we refused"
These are the ones people file bugs about, and each has a specific cause:
concurrency_limit— your organization'sincluded_concurrency + burst_concurrencywas already in use, or a per-campaign, per-number or per-trunk limit was. Check concurrent calls before assuming a bug.destination_blocked— the destination policy refused. Blocklist, country policy, a premium (1900,1976), satellite (870–874,881–883) or high-cost prefix, an hourly, daily or daily-minutes cap, or an outbound route explicitly marked blocked.insufficient_credit— the ledger balance, a daily spend cap, a plan volume ceiling, or a dunning freeze.
telephony_error covers a specific set: missing destination, no route, missing from, an unverified caller ID (you asked to present a number your organization does not own), an unknown provider, a not-implemented provider, a missing credential, or a connection failure to the carrier.
gateway_lost means the voice gateway died mid-call and a reaper finalised the record. The transcript stops where the gateway stopped.
Where to look, in order
curl "$CALLAGENT_API_URL/calls/$CALL_ID" -H "Authorization: Bearer $KEY"
curl "$CALLAGENT_API_URL/calls/$CALL_ID/events" -H "Authorization: Bearer $KEY"
curl "$CALLAGENT_API_URL/calls/$CALL_ID/debug" -H "Authorization: Bearer $KEY"ended_reasonon the call. Nine times out of ten it is enough.events— populated even for a call that never connected.call.created, thencall.state.changedfor every transition with anoffset_msfrom call start. The last event before the failure is the one that matters.metricsif it connected but felt slow — it breaks the turn down by pipeline stage.debugfor the whole bundle.
A call that connects and then dies silently
The specific failure worth naming, because it is easy to cause and impossible to see from the API: the gateway's call config was rejected, so there is nothing to run.
The config is dropped whole — not partially — if any of these is missing: model.provider, model.model, transcriber.provider, voice.provider, voice.voice_id. GET /assistants/{id}/validate checks four of the five and misses voice_id.
voice_id is also the one you cannot set over the API: POST/PATCH /assistants prune every sub-key of voice, model, transcriber and server that has no validation rule, and voice.voice_id is one of them. Check it with GET /assistants/{id} — if voice comes back as {"provider": "…"} and nothing else, that assistant cannot start a call. Set the voice in the dashboard. Full detail on What is not built.
It is also rejected if the transcriber is one the gateway has no adapter for. validate does catch that one, which is why running it before publishing is worth the extra call.
When a webhook fails
From your side, a delivery that never arrives is one of:
Your endpoint returned 4xx | Not retried. A 404 or 410 is treated exactly like a 400 — you said no. |
Your endpoint returned 5xx or timed out | Retried at 10 s, 30 s, 2 min, 10 min, 30 min, then hourly, up to max_attempts (default 6). |
| Your URL was SSRF-blocked | Dead immediately. Retrying a blocked destination just blocks again. |
| You subscribed to nothing that fires | An empty events list excludes the four high-volume types. |
| 20 consecutive failures | The endpoint is disabled. active: false, with disabled_reason set. One success anywhere resets the counter. |
GET /webhook-deliveries?status=dead is the first thing to check. Each delivery keeps the response status, the (redacted) body and the error, and there is one insert-only row per attempt with the resolved IP.
If your handler ran but you answered slowly, you will get a retry of an event you already processed. Dedupe on X-CallAgent-Event-Id — see Webhooks.
When a tool fails
A tool failure never takes the call down. It returns a structured result to the model, which can say something useful and carry on.
| Result | Cause |
|---|---|
not_found | A function tool with no server URL, or an MCP tool that has gone. |
tool_timeout | The tool's execution budget expired. Retryable. |
internal_error | The tool threw. Check GET /calls/{id}/events for tool.failed. |
| Rejected | Policy, risk tier, or a declined confirmation. The assistant says the rejection_plan line. |
A tool the model never called at all is usually one of:
- a tool of type
bash,computer,text_editor,client_sideorcustom_provider— these are dropped from every call, silently; - a tool whose name the model cannot address. Anything outside
[A-Za-z0-9_-]is dropped rather than mangled, because a renamed tool is uncallable in a way nothing in the interface shows; - an
api_requesttool with noconfig.url, dropped during conversion; - an unpublished tool, or one attached with
pin_mode: pinnedto a version that no longer exists.
GET /assistants/{id}/preview lists what actually resolved, with exposed_name.
Quoting a problem
Include:
- the
request_idfrom the error body, or the call ULID; - the
ended_reasonandended_by; - the assistant's
config_hashfromGET /assistants/{id}/versions, so the exact configuration is identifiable; - the timestamp with an offset. Everything is stored ISO 8601 with an explicit offset.
Next
- Error codes — the full code table with HTTP statuses.
- Rate limits — the 429 in detail.
- What is not built — before you file a bug against a feature that was never wired up.