Skip to content

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:

json
{
  "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"
  }
}
FieldWhen it appears
typeAlways. The category — see the table below.
messageAlways. Safe to show a developer; never the raw exception text.
request_idAlways. A ULID, or the X-Request-Id you sent if it was ≤64 characters. Quote this in a bug report.
errorsValidation failures only. Field-keyed, Laravel's shape.
codeProvider and plan-limit failures.
retryableProvider, rate-limit and server failures.
provider, provider_request_idProvider failures.
documentation_urlUsually.

The type values ​

typeHTTPMeaningDo
validation_error422Your body is wrong. errors says which field.Fix and resend.
authentication_error401Missing, invalid or revoked key.Check the key. Rotation overlap may have expired.
permission_error403The key lacks the scope, or the origin/IP restriction refused it.Widen the scope or call from an allowed origin.
not_found404No such resource — and this is also what another tenant's resource returns.Check the id and the organization the key belongs to.
rate_limit_error429Too many requests.Wait Retry-After seconds. See Rate limits.
plan_limit_exceeded402A commercial ceiling, not a technical one. limit and used are on the body.Upgrade, or delete something.
provider_error502 / 504 / 429A vendor rejected or timed out. provider names them.Read retryable.
ssrf_blocked422A URL you supplied resolves somewhere it may not.Use a public, resolvable host.
invalid_state422The action does not apply in this state.
http_errorvariesA framework-level HTTP error.
server_error500Ours. 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 ​

GroupValues
Somebody hung upcustomer_hung_up, operator_hung_up, assistant_ended, assistant_ended_call_tool, transferred_away, api_request
The conversation ran outsilence_timeout, max_duration_reached, voicemail_detected
The carrier said nono_answer, busy, rejected
We said noinsufficient_credit, concurrency_limit, destination_blocked
Something brokepipeline_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's included_concurrency + burst_concurrency was 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 ​

bash
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"
  1. ended_reason on the call. Nine times out of ten it is enough.
  2. events — populated even for a call that never connected. call.created, then call.state.changed for every transition with an offset_ms from call start. The last event before the failure is the one that matters.
  3. metrics if it connected but felt slow — it breaks the turn down by pipeline stage.
  4. debug for 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 4xxNot retried. A 404 or 410 is treated exactly like a 400 — you said no.
Your endpoint returned 5xx or timed outRetried at 10 s, 30 s, 2 min, 10 min, 30 min, then hourly, up to max_attempts (default 6).
Your URL was SSRF-blockedDead immediately. Retrying a blocked destination just blocks again.
You subscribed to nothing that firesAn empty events list excludes the four high-volume types.
20 consecutive failuresThe 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.

ResultCause
not_foundA function tool with no server URL, or an MCP tool that has gone.
tool_timeoutThe tool's execution budget expired. Retryable.
internal_errorThe tool threw. Check GET /calls/{id}/events for tool.failed.
RejectedPolicy, 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_side or custom_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_request tool with no config.url, dropped during conversion;
  • an unpublished tool, or one attached with pin_mode: pinned to a version that no longer exists.

GET /assistants/{id}/preview lists what actually resolved, with exposed_name.

Quoting a problem ​

Include:

  • the request_id from the error body, or the call ULID;
  • the ended_reason and ended_by;
  • the assistant's config_hash from GET /assistants/{id}/versions, so the exact configuration is identifiable;
  • the timestamp with an offset. Everything is stored ISO 8601 with an explicit offset.

Next ​

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