L’API Transport renvoie des erreurs au format standard RFC 9457 (« Problem Details for HTTP APIs »), avec le Content-Type application/problem+json. Vous obtenez ainsi un objet structuré, prévisible et identique sur toutes les routes — pas de message texte brut à parser.
Branchez toujours votre logique sur le code HTTP (status) et éventuellement le title, jamais sur le texte de detail, qui peut évoluer sans préavis.

Structure d’une réponse d’erreur

Toute erreur partage la même enveloppe. Trois champs sont toujours présents (type, title, status) ; les autres sont contextuels.
{
  "type": "about:blank",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "billing is required.",
  "errors": [
    { "field": "billing", "error": "required" }
  ]
}
type
string (uri-reference)
requis
URI identifiant le type de problème. Vaut about:blank par défaut (le code HTTP suffit alors à qualifier l’erreur).
title
string
requis
Libellé court, lisible et stable du type de problème (ex. Not Found, Unprocessable Entity).
status
integer
requis
Code de statut HTTP, dupliqué dans le corps pour faciliter le logging.
detail
string
Message lisible spécifique à cette occurrence (ex. Transport not found.). À afficher ou logger, mais à ne jamais utiliser comme clé de branchement.
instance
string (uri-reference)
URI identifiant l’occurrence précise du problème, lorsqu’il est pertinent.
errors
array
Détail des erreurs de validation, champ par champ. Présent surtout sur les réponses 422.

Codes HTTP

L’API utilise un ensemble restreint de codes, chacun avec une sémantique métier claire.

Réponses de succès

CodeNomQuand
200OKLecture réussie, ou mutation appliquée (estimation, modification de dates, annulation, gestion d’options…).
201CreatedRessource créée : mission (POST /transports) ou refacturation (POST /transports/{id}/price-adjustments).
207Multi-StatusAjout de documents (POST /transports/{id}/documents) : la réponse contient un résultat par document, certains pouvant réussir et d’autres échouer.
Un 207 n’est pas une erreur globale : inspectez data[] pour connaître le sort de chaque document (success, error).

Réponses d’erreur

CodeNomSignification métier
400Bad RequestCorps de requête malformé (JSON invalide). Corrigez la charge utile.
401UnauthorizedClé API manquante, invalide, révoquée ou expirée. Vérifiez l’en-tête X-Api-Key.
403ForbiddenLe scope requis est absent de la clé (ex. transports:write pour un POST).
404Not FoundMission inexistante OU appartenant à un autre groupe que celui de votre clé.
409ConflictConflit d’idempotence ou conflit métier (voir ci-dessous).
422Unprocessable EntityÉchec de validation du corps : champ requis manquant, valeur invalide. Lisez errors[].
429Too Many RequestsLimite de débit atteinte. Respectez l’en-tête Retry-After.
502Bad GatewayÉchec d’un service amont (relai de création / tarification). Réessayez.
Une mission qui n’appartient pas à votre groupe renvoie 404, jamais 403. C’est un choix de sécurité (mitigation BOLA) : l’API ne révèle pas l’existence d’une mission hors de votre périmètre. Un 404 ne signifie donc pas forcément que la mission n’existe pas — il peut s’agir d’une mission d’un autre groupe.

Cas particuliers à connaître

Votre clé API est liée à un groupe (flotte, concession, loueur). Toute mission dont le group_id ne correspond pas à celui de votre clé est traitée comme introuvable. Vérifiez que vous utilisez le bon id (alias offer_id) et la clé du bon groupe.
{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "Transport not found."
}
Un 409 couvre plusieurs situations. Le champ detail précise laquelle :
  • Idempotence divergente : la même Idempotency-Key rejouée avec un corps différent, ou une requête concurrente portant la même clé encore en cours.
  • Mission déjà facturée ou payée : une refacturation (price-adjustments) sur une jambe déjà payée ou facturée est refusée.
  • Mission déjà terminée : une annulation (cancel) d’une mission déjà clôturée est refusée.
{
  "type": "about:blank",
  "title": "Conflict",
  "status": 409,
  "detail": "Idempotency-Key reused with a different request body."
}
Rejouer la même Idempotency-Key avec le même corps n’est jamais une erreur : la réponse mise en cache est renvoyée à l’identique, avec l’en-tête Idempotent-Replayed: true.
Le corps est syntaxiquement correct mais ne passe pas la validation métier (champ requis absent, valeur invalide). Le tableau errors[] détaille chaque champ fautif — utilisez-le pour afficher des messages précis à l’utilisateur.
{
  "type": "about:blank",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "billing is required.",
  "errors": [
    { "field": "billing", "error": "required" }
  ]
}
Le débit est limité par IP, par clé et globalement. Un dépassement renvoie un 429 accompagné de l’en-tête Retry-After (en secondes). Mettez en place un back-off et patientez la durée indiquée avant de réessayer.
{
  "type": "about:blank",
  "title": "Too Many Requests",
  "status": 429,
  "detail": "Rate limit (key) exceeded."
}
La création de mission et la tarification s’appuient sur un relai interne. Si celui-ci est temporairement indisponible, l’API renvoie un 502. Ces requêtes sont sûres à rejouer dès lors que vous utilisez une Idempotency-Key : une réponse 5xx n’est pas mise en cache, le verrou d’idempotence est libéré pour permettre un nouvel essai.

Exemple complet

Une requête POST /transports à laquelle il manque l’objet billing :
curl -i -X POST "https://api.myexpressdriver.com/v1/transports" \
  -H "X-Api-Key: med_live_xxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "pickup":   { "address": "10 Rue de Rivoli, 75004 Paris" },
    "delivery": { "address": "1 Place Bellecour, 69002 Lyon" }
  }'

Gestion côté client (Node.js)

L’exemple ci-dessous montre un wrapper minimal qui branche sa logique sur le code HTTP, respecte Retry-After sur les 429, et retente automatiquement les erreurs amont (502).
// med-client.mjs — Node.js >= 18 (fetch natif)
const BASE = "https://api.myexpressdriver.com/v1";
const API_KEY = process.env.MED_API_KEY; // med_live_...

/**
 * Erreur typée portant le Problem Details (RFC 9457).
 */
class MedApiError extends Error {
  constructor(problem, httpStatus) {
    super(problem?.detail || problem?.title || `HTTP ${httpStatus}`);
    this.name = "MedApiError";
    this.status = httpStatus;
    this.title = problem?.title;
    this.type = problem?.type;
    this.detail = problem?.detail;
    this.errors = problem?.errors ?? []; // [{ field, error }]
  }
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function medRequest(method, path, { body, idempotencyKey, retries = 2 } = {}) {
  const headers = {
    "X-Api-Key": API_KEY,
    "Content-Type": "application/json",
  };
  if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;

  const res = await fetch(`${BASE}${path}`, {
    method,
    headers,
    body: body ? JSON.stringify(body) : undefined,
  });

  // Succès (200/201/207) : on renvoie le corps tel quel.
  if (res.ok) {
    return res.status === 204 ? null : res.json();
  }

  // Erreur : le corps est un Problem Details (application/problem+json).
  const problem = await res.json().catch(() => ({}));

  // 429 — respecter Retry-After, puis retenter.
  if (res.status === 429 && retries > 0) {
    const wait = Number(res.headers.get("Retry-After") ?? 1);
    await sleep(wait * 1000);
    return medRequest(method, path, { body, idempotencyKey, retries: retries - 1 });
  }

  // 502 — échec amont transitoire : sûr à rejouer si une clé d'idempotence est posée.
  if (res.status === 502 && retries > 0) {
    await sleep(500);
    return medRequest(method, path, { body, idempotencyKey, retries: retries - 1 });
  }

  // Brancher sur le STATUT (jamais sur le texte de detail).
  switch (res.status) {
    case 401:
      throw new MedApiError(problem, 401); // clé invalide/révoquée/expirée
    case 403:
      throw new MedApiError(problem, 403); // scope manquant
    case 404:
      throw new MedApiError(problem, 404); // hors groupe OU inexistante
    case 409:
      throw new MedApiError(problem, 409); // conflit idempotence/métier
    case 422:
      // Exploiter problem.errors[] pour un retour champ par champ.
      throw new MedApiError(problem, 422);
    default:
      throw new MedApiError(problem, res.status);
  }
}

// Exemple d'usage
try {
  const transport = await medRequest("GET", "/transports/-O9xAbCdEf");
  console.log(transport.status); // statut public stable
} catch (err) {
  if (err instanceof MedApiError && err.status === 422) {
    for (const { field, error } of err.errors) {
      console.warn(`Champ « ${field} » invalide : ${error}`);
    }
  } else if (err instanceof MedApiError && err.status === 404) {
    console.warn("Mission introuvable ou hors de votre groupe.");
  } else {
    throw err;
  }
}
Points clés d’une intégration robuste : brancher sur status, lire errors[] sur les 422, respecter Retry-After sur les 429, et poser une Idempotency-Key pour pouvoir rejouer les mutations en cas de 502.

Étapes suivantes

Idempotence

Posez une Idempotency-Key pour rejouer les mutations sans effet de bord double, notamment après un 429 ou un 502.

Authentification

Comprenez les clés X-Api-Key, les scopes et l’isolation par groupe — la source des 401, 403 et 404.

Démarrage rapide

Créez votre première mission de bout en bout et observez les réponses de succès et d’erreur en conditions réelles.