Conventions
Conventions transversales qui s'appliquent à chaque endpoint : format des horodatages et structure unifiée du corps d'erreur JSON pour laquelle vous pouvez écrire un seul gestionnaire.
Horodatages
Chaque horodatage retourné par l'API est en ISO 8601 avec une précision à la microseconde et un décalage UTC explicite `+00:00`. Les heures sont toujours en UTC ; vous ne verrez pas de suffixe Z et vous ne verrez pas de précision à la seconde.Le format est celui que produit datetime.isoformat() de Python pour un datetime UTC avec fuseau horaire ; il est conforme RFC 3339 / ISO 8601, simplement à l'extrémité verbeuse de ce que les standards autorisent. De nombreux parseurs stricts dans la nature attendent la forme courte Z (ex. 2025-01-15T14:30:00Z) et rejetteront la forme verbeuse, donc choisissez un parseur ISO 8601 permissif pour votre langage.S'applique à chaque champ horodatage sur chaque endpoint : created_at, updated_at, started_at, ended_at, webhook_delivered_at, les ancres de période de facturation, etc.
Z, et ne supposez pas une précision à la seconde. Un parseur Z-uniquement échouera sur chaque réponse.{
"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 dans les langages courants
Utilisez le parseur de bibliothèque standard dans chaque langage ; tous acceptent la forme verbeuse +00:00. Évitez les regex faites maison.
Réponses d'erreur
Chaque réponse 4xx et 5xx porte la même structure de corps canonique : une chaîne error lisible par l'humain, un slug code stable sur lequel vous pouvez brancher, et le request_id à citer dans un ticket de support. Les champs legacy utilisés par les anciens endpoints (detail, contrôle d'admission reason/resource_class/retry_after) sont toujours émis en plus des champs canoniques, donc le code client existant continue à fonctionner sans changements.Écrivez un gestionnaire basé sur code et vous couvrez chaque endpoint, chaque code de statut et chaque classe d'exception. Le slug code est le contrat : not_found, validation_error, conflict, rate_limited, rate_limiter_unavailable, service_unavailable, capacity, plus des codes spécifiques aux endpoints comme billing.payg_card_repeatedly_failed.
X-Request-ID avec la même valeur que request_id dans le corps. Capturez l'un ou l'autre et incluez-le quand vous contactez [email protected].La structure canonique
Chaque réponse d'erreur — quel que soit le code de statut, quel que soit l'endpoint — inclut ces trois clés de premier niveau :- error (string) — message lisible par l'humain, sûr à afficher aux utilisateurs finaux
- code (string) — slug stable avec namespace pour le branchement programmatique
- request_id (string) — UUID correspondant au header de réponse X-Request-IDSelon la classe d'erreur, le corps peut aussi porter un ou plusieurs de ces champs legacy / d'extension :- detail — string (la plupart des 4xx/5xx), objet (422 de logique métier comme POST /calls/trigger), ou tableau d'erreurs par champ (échecs de validation Pydantic 422). Toujours préservé verbatim pour la rétrocompatibilité.
- fields — même tableau que detail sur un échec de validation 422, exposé sous un nom découvrable pour que le nouveau code n'ait pas besoin de savoir qu'un tableau sous detail est spécial.
- reason, resource_class, retry_after, resource, current, cap — rejets du contrôle d'admission sur les routes d'appel vocal et WebRTC. Toujours accompagnés d'un header HTTP Retry-After.
- Clés spécifiques à l'endpoint dans un detail objet — ex. detail.invalid_recipients sur POST /calls/trigger, detail.code sur les 402 de facturation.
{
"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"
}Échecs de validation (422)
Les échecs de validation de schéma au niveau des champs (champ requis manquant, mauvais type, regex non correspondante) retournent 422 avec les champs canoniques plus un tableau detail d'objets d'erreur par champ. Chaque entrée a loc (chemin JSON du champ en échec), msg (une raison lisible par l'humain) et type (le validateur Pydantic qui s'est déclenché, ex. missing, string_too_short, string_pattern_mismatch). Pydantic peut inclure des clés supplémentaires par entrée (input, ctx, url) ; ignorez les clés inconnues pour qu'une future mise à niveau Pydantic ne casse pas votre client.Le même tableau est également exposé sous fields — utilisez celui qui vous semble plus clair. fields n'est présent que sur les 422 de couche Pydantic (échecs de corps de requête ou de schéma traités par validation_exception_handler) ; les 422 de logique métier déclenchés via HTTPException(422) passent par http_exception_handler et retournent detail comme une chaîne simple sans clé fields.
{
"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"
}
]
}Erreurs de logique métier avec détail structuré
Certains endpoints portent des informations structurées spécifiques à l'endpoint dans detail. Le error / code / request_id canonique est toujours au premier niveau — les informations structurées s'y ajoutent, elles ne les remplacent pas.Deux variantes concrètes en production :- Déclenchement d'appels (`POST /calls/trigger`, `POST /calls/batch`) : detail est {"message": "...", "invalid_recipients": [{"recipient_index": N, "from_number": "...", "reason": "from_number_required" | "not_registered" | "outbound_disabled"}, ...]}. Les slugs reason dans chaque destinataire sont stables et sûrs pour le branchement.
- Facturation (`POST /billing/...`) : detail est {"code": "billing.payg_card_repeatedly_failed", "message": "...", "action_url": "..."}. Le code interne est ce sur quoi votre gestionnaire de facturation doit brancher ; le code de premier niveau le reflète.Quand detail est un objet, regardez à l'intérieur pour reason, code ou des clés spécifiques au domaine pour décider comment brancher.
{
"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"
}
]
}
}Contrôle d'admission (capacité)
Les rejets du contrôle d'admission (plafonds d'appels simultanés) sur les routes d'appel vocal et WebRTC retournent le corps canonique plus les champs spécifiques à l'admission. Le code de premier niveau reflète reason pour qu'un seul gestionnaire d'erreur puisse brancher sur code que la réponse soit un 404 (code="not_found"), un échec de validation (code="validation_error") ou un rejet d'admission (code="capacity").Retourné sur 409 (tentative de retry de lease en double transitoire), 429 (plafond de concurrence atteint) et 503 (backend Redis d'admission indisponible) depuis POST /calls/trigger, POST /calls/batch, POST /conversation/connect et les endpoints d'offre WebRTC. Porte toujours un header Retry-After ; respectez-le avant de réessayer.Slugs code / reason stables :- capacity — plafond d'appels simultanés atteint pour le tenant ou la classe de ressource (429).
- duplicate_lease — l'admission a généré un id de lease en double (409, transitoire ; réessayez).
- redis_unavailable — le backend Redis d'admission est inaccessible (503).
- redis_error — le script Redis d'admission a échoué (503).resource_class vaut par défaut "call" pour les chemins d'appel vocal.Pas cette forme : Les 429 de limite de taux RPM/IP sur les mêmes routes viennent d'un middleware différent et utilisent code="rate_limited". Les deux formes portent le error / code / request_id canonique, donc la distinction est invisible si vous ne branchez que sur 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
}Écrire un seul gestionnaire d'erreur
Parce que chaque réponse d'erreur porte error / code / request_id au premier niveau, un seul gestionnaire d'erreur peut couvrir chaque endpoint. Branchez sur code pour les décisions programmatiques ; affichez error à l'utilisateur ; capturez request_id pour les tickets de support. Plongez dans le detail legacy, reason, invalid_recipients, etc. uniquement quand vous avez besoin du contexte supplémentaire.
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