Every error the API returns has the same body, at every status code, on both planes. There is nothing to branch on and no second shape to guard against.
Both fields are always present. message is one sentence you can show a person. errors lists the individual problems when a failure had more than one; when it did not, errors is an empty array — never absent, never null.
Write your handler against detail.message and detail.errors and it will work everywhere, including on a failure you have never seen.

Request validation

A request that does not match the operation’s schema comes back as 422, with one line per problem in the form field.path: problem.
The path is the key you actually sent — the body, query and path prefixes are stripped, so config.llm.model is what to look for in your payload, not body.config.llm.model. When there is exactly one problem, that line is also the message, so a single-issue failure reads well without opening errors:
A 400 carries the same shape but means something different: the request parsed and matched the schema, and a rule rejected it. An unknown model, a tool that is not published, a name already taken, a broken operation tree.

Every status code

There is no 429. Nothing on the platform rate-limits inbound requests — see limits. A 429 you receive came from one of your own providers being relayed.

402, in full

402 appears in exactly two places: POST /v1/calls/token (minting a web call token) and POST /v1/calls/outbound (placing a phone call). Nowhere else returns it.
The other two credit gates reach no HTTP caller: an inbound phone call is refused at the door and reads back with close_reason: "insufficient_credits", and a running batch pauses rather than failing. The balance is per region, so this is the balance of whichever base URL you called. See pricing and credits.

401 versus 403 versus 503

All three come out of the same authorization step and they mean three different things. Treating a 503 like a 401 — clearing credentials, forcing a re-login, alerting a human — is the wrong reaction to a network blip. They are opposite verdicts: one is final, one is transient.

502 means a provider, not us

A 502 is Talqing having called something on your behalf and not got an answer. It shows up when a provider-key check cannot reach the provider, when a voice or avatar gallery is unavailable, when a carrier’s API does not respond, and when the code-execution service is unreachable. It is specifically not a verdict on what you sent. A 502 from PUT /v1/byok/{provider} means we could not verify the key, so nothing was stored and your existing key is untouched — retrying costs one click. See provider keys.

Validation responses are not errors

POST /v1/agents/{agent_id}/validate and POST /v1/tools/{tool_id}/validate answer 200 whatever they find. The verdict is the body:
Both keys are always present. errors block a publish. warnings do not. A publish with warnings succeeds and hands the warnings back on the publish response. So an empty errors array means “this will publish”, not “this is good” — read the warnings anyway, because they are where the things that will surprise a caller live. Trying to publish with errors present returns 400 with those same lines in detail.errors. See validation.

Retrying

Retry 500, 502 and 503, with exponential backoff. Retry nothing else. Every other code is a verdict on the request itself, and sending it again changes nothing except your bill. Neither SDK retries for you. A failed call raises immediately, once — there is no hidden backoff loop, so what you write is what happens.

There is no idempotency key

No write endpoint takes an idempotency key. A retried POST is a second request. For an operation that reaches the real world — placing a call, sending an email, running a task, charging a card — that means it happens twice.
There is one exception and three things to do instead:
  • Text messages dedupe themselves. POST /v1/conversations/messages requires client_message_id, a UUID you generate. Re-sending with the same one is ignored rather than duplicated, so retrying that call is safe by construction.
  • Names are unique per workspace. A retried create for an agent, tool or task answers 400 an agent with that name already exists rather than making a second copy. That 400 is evidence the first attempt worked — treat it as success and go and read the resource.
  • Read before you retry anything that spends money. After a timeout on POST /v1/calls/outbound, list calls filtered by contact_key or batch_id before dialling again. The call record is written before the carrier is contacted, so a call that got as far as our side is visible even if the response never reached you.
  • Let batches do the retrying. A call batch or email batch has max_attempts and retry_after_minutes built in, and will not re-dial a recipient it already reached. Driving one call at a time from your own loop gives up all of that.

How the SDKs surface an error

Both SDKs raise one exception type for every non-2xx, carrying the same fields the envelope does.
TalqingApiError is branded so instanceof holds even when a dependency graph pulls in both the ESM and the CommonJS build of the package. Without that, the check above would silently fall through and the error would keep propagating. A failure that never reached the API — DNS, an aborted request, a proxy’s HTML 502 — has no envelope to parse. TypeScript rethrows the transport’s own error unchanged. Python raises TalqingAPIError with the raw body text as the message and an empty errors, so the type stays uniform even when the body is not ours. The TypeScript client also takes an onUnauthorized callback, fired once when the API answers 401. The credentials are gone, so every other in-flight call will fail identically — handling it in one place beats every caller re-deriving it. The error is still thrown afterwards.

Limits

What you can cross, and the two limits that do not exist.

Authentication

Where 401 and 403 come from, and how a role change reaches a token.