Conventions
Cross-cutting conventions that apply to every endpoint: timestamp formatting and the unified JSON error body shape you can write one handler for.
Timestamps
Every timestamp the API returns is ISO 8601 with microsecond precision and an explicit `+00:00` UTC offset. Times are always UTC; you will not see a Z suffix and you will not see second-level precision.The format is what Python's datetime.isoformat() produces for a timezone-aware UTC datetime; it is RFC 3339 / ISO 8601 compliant, just on the verbose end of what the standards allow. Many strict parsers in the wild expect the shorter Z form (e.g. 2025-01-15T14:30:00Z) and will reject the verbose form, so pick a permissive ISO 8601 parser for your language.Applies to every timestamp field on every endpoint: created_at, updated_at, started_at, ended_at, webhook_delivered_at, billing period anchors, and so on.
Z, and do not assume second precision. A Z-only parser will throw on every response.{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"created_at": "2026-05-18T10:38:01.587702+00:00",
"updated_at": "2026-05-18T10:38:01.587702+00:00"
}Parsing in common languages
Use the standard library parser in each language; all of these accept the verbose +00:00 form. Avoid hand-rolled regexes.
Error responses
Every 4xx and 5xx response carries the same canonical body shape: a human-readable error string, a stable code slug you can switch on, and the request_id to quote in a support ticket. Legacy fields used by older endpoints (detail, admission-control reason/resource_class/retry_after) are still emitted alongside the canonical fields, so existing client code keeps working without changes.Write one handler keyed on code and you cover every endpoint, every status code, and every exception class. The code slug is the contract: not_found, validation_error, conflict, rate_limited, rate_limiter_unavailable, service_unavailable, capacity, plus endpoint-specific codes like billing.payg_card_repeatedly_failed.
X-Request-ID response header to the same value as request_id in the body. Capture either and include it when you contact [email protected].The canonical shape
Every error response — whatever the status code, whatever the endpoint — includes these three top-level keys:- error (string) — human-readable message safe to surface to end users
- code (string) — stable namespaced slug for programmatic branching
- request_id (string) — UUID matching the X-Request-ID response headerDepending on the error class, the body may also carry one or more of these legacy / extension fields:- detail — string (most 4xx/5xx), object (business-logic 422s like POST /calls/trigger), or array of per-field errors (Pydantic 422 validation failures). Always preserved verbatim for backward compatibility.
- fields — same array as detail on a 422 validation failure, exposed under a discoverable name so new code does not need to know that an array under detail is special.
- reason, resource_class, retry_after, resource, current, cap — admission-control rejections on voice-call and WebRTC routes. Always paired with a Retry-After HTTP header.
- Endpoint-specific keys inside an object detail — e.g. detail.invalid_recipients on POST /calls/trigger, detail.code on billing 402s.
{
"error": "Agent 'd53208ef-2f34-4be2-8bea-35cd55b399ac' not found",
"code": "not_found",
"request_id": "0cf409f7-382a-4c24-be9f-ae1192c38619",
"detail": "Agent 'd53208ef-2f34-4be2-8bea-35cd55b399ac' not found"
}Validation failures (422)
Field-level schema validation failures (missing required field, wrong type, regex mismatch) return 422 with the canonical fields plus a detail array of per-field error objects. Each entry has loc (JSON path of the failing field), msg (a human-readable reason), and type (the Pydantic validator that fired, e.g. missing, string_too_short, string_pattern_mismatch). Pydantic may include additional keys per entry (input, ctx, url); ignore unknown keys so a future Pydantic upgrade does not break your client.The same array is also exposed under fields — use whichever name your code finds clearer. fields is only present on Pydantic-layer 422s (request body / schema failures processed by validation_exception_handler); business-logic 422s raised via HTTPException(422) go through http_exception_handler and return detail as a plain string with no fields key.
{
"error": "Validation failed",
"code": "validation_error",
"request_id": "3fddfe81-53f3-4ca1-9cea-f5fbc73ce5a2",
"detail": [
{
"loc": ["body", "name"],
"msg": "Field required",
"type": "missing"
},
{
"loc": ["body", "voice_id"],
"msg": "Field required",
"type": "missing"
}
],
"fields": [
{
"loc": ["body", "name"],
"msg": "Field required",
"type": "missing"
},
{
"loc": ["body", "voice_id"],
"msg": "Field required",
"type": "missing"
}
]
}Business-logic errors with structured detail
Some endpoints carry endpoint-specific structured info inside detail. The canonical error / code / request_id are still at the top level — the structured info is in addition, not instead.Two concrete variants in production:- Calls dispatch (`POST /calls/trigger`, `POST /calls/batch`): detail is {"message": "...", "invalid_recipients": [{"recipient_index": N, "from_number": "...", "reason": "from_number_required" | "not_registered" | "outbound_disabled"}, ...]}. The reason slugs inside each recipient are stable and safe to switch on.
- Billing (`POST /billing/...`): detail is {"code": "billing.payg_card_repeatedly_failed", "message": "...", "action_url": "..."}. The inner code is what your billing handler should branch on; the top-level code mirrors it.When detail is an object, look inside it for reason, code, or domain-specific keys to decide how to branch.
{
"error": "from_number '+15551234567' is not registered to this tenant. Register it via POST /api/v1/phone-numbers/{provider}/register first.",
"code": "validation_error",
"request_id": "dfcc56ac-f99a-4815-92f4-8b2373bc3992",
"detail": {
"message": "from_number '+15551234567' is not registered to this tenant. Register it via POST /api/v1/phone-numbers/{provider}/register first.",
"invalid_recipients": [
{
"recipient_index": 0,
"from_number": "+15551234567",
"reason": "not_registered"
}
]
}
}Admission control (capacity)
Admission-control rejections (concurrent-call caps) on voice-call and WebRTC routes return the canonical body plus the admission-specific fields. The top-level code mirrors reason so a single error handler can switch on code whether the response is a 404 (code="not_found"), a validation failure (code="validation_error"), or an admission rejection (code="capacity").Returned on 409 (transient duplicate-lease retry), 429 (concurrency cap reached), and 503 (admission Redis backend unavailable) from POST /calls/trigger, POST /calls/batch, POST /conversation/connect, and the WebRTC offer endpoints. Always carries a Retry-After header; honor it before retrying.Stable code / reason slugs:- capacity — concurrent-call cap reached for the tenant or resource class (429).
- duplicate_lease — admission generated a duplicate lease id (409, transient; retry).
- redis_unavailable — admission Redis backend cannot be reached (503).
- redis_error — admission Redis script failed (503).resource_class defaults to "call" for voice-call paths.Not this shape: RPM/IP rate-limit 429s on the same routes come from a different middleware and use code="rate_limited". Both shapes carry the canonical error / code / request_id, so the distinction is invisible if you only branch on code.
{
"error": "Service at capacity. Please retry shortly.",
"code": "capacity",
"request_id": "4dd852cb-b080-4cd8-bdf1-d9a17420cbb8",
"detail": "Service at capacity. Please retry shortly.",
"reason": "capacity",
"resource_class": "call",
"retry_after": 5,
"resource": "tenant:abc123",
"current": 50,
"cap": 50
}Writing one error handler
Because every error response carries error / code / request_id at the top level, a single error handler can cover every endpoint. Branch on code for programmatic decisions; surface error to the user; capture request_id for support tickets. Drop into the legacy detail, reason, invalid_recipients, etc. only when you need the extra context.
def parse_error(response):
request_id = response.headers.get("X-Request-ID")
status_code = response.status_code
try:
body = response.json()
except ValueError:
return {
"message": response.text,
"status_code": status_code,
"request_id": request_id,
}
# Canonical fields are present on every error response.
parsed = {
"message": body.get("error"),
"code": body.get("code"),
"status_code": status_code,
"request_id": body.get("request_id") or request_id,
}
# Optional extras — only present on specific error classes.
detail = body.get("detail")
if isinstance(detail, list):
parsed["field_errors"] = detail
elif isinstance(detail, dict):
parsed["invalid_recipients"] = detail.get("invalid_recipients")
# Admission control on voice routes carries Retry-After + capacity info.
# The "resource", "current", and "cap" fields are optional — capture them
# when present so the caller can show a current/cap in-use banner.
# "duplicate_lease" (409) is the transient retry case from the same
# subsystem and goes through the same payload path.
if body.get("code") in {"capacity", "duplicate_lease", "redis_unavailable", "redis_error"}:
parsed["retry_after"] = body.get("retry_after")
parsed["resource_class"] = body.get("resource_class")
for key in ("resource", "current", "cap"):
if key in body:
parsed[key] = body[key]
return parsed