Chaque équipe avec laquelle j'ai travaillé finit par inventer son propre format de réponse d'API. Ça semble inoffensif au début — un petit objet wrapper ici, une forme d'erreur personnalisée là — et six mois plus tard vous écrivez une quatrième version de votre middleware de parsing d'erreurs et argumentez en code review si data.user ou data.result.user est le « bon » chemin. Il n'y a pas de standard universel qui résout tout ça, mais il y a des patterns qui tiennent la route en production et des anti-patterns qui reviendront absolument vous mordre. Voici ce que je mettrais effectivement dans un design doc.

Réponses de succès cohérentes

La première question que chaque équipe débat : chaque réponse doit-elle être enveloppée dans une enveloppe comme {"status": "ok", "data": {...}} ? La réponse honnête est — probablement pas par défaut. Les enveloppes avaient plus de sens au début des années 2000 quand les codes de statut HTTP n'étaient pas toujours fiables à travers les proxys et les réseaux mobiles. Aujourd'hui, une réponse plate qui laisse la ressource parler pour elle-même est presque toujours plus propre. Réservez l'enveloppe pour les endpoints qui retournent vraiment des payloads mixtes, comme une opération en masse qui réussit partiellement.

json
// ✅ Good — flat, direct, the order IS the response
// GET /v1/orders/ord_9kZ2m
{
  "id": "ord_9kZ2m",
  "status": "fulfilled",
  "customer_id": "cus_4xA1p",
  "total_amount": 149.99,
  "currency": "USD",
  "created_at": "2026-03-15T11:42:00Z",
  "line_items": [
    { "sku": "HDPHN-BLK-XM5", "quantity": 1, "unit_price": 149.99 }
  ]
}

// ❌ Avoid — unnecessary envelope adds a layer clients have to unwrap every time
{
  "status": "success",
  "code": 200,
  "data": {
    "order": {
      "id": "ord_9kZ2m"
    }
  }
}

Envelopper a du sens quand vous avez besoin de co-localiser des métadonnées qui ne font pas partie de la ressource elle-même — curseurs de pagination, IDs de requête pour le tracing, ou résumés d'échec partiel dans les endpoints en masse. Pour un simple GET /orders/:id, la commande est la réponse. Ne forcez pas les clients à écrire response.data.order.id quand response.id fonctionne très bien. Si vous voulez une spec à référencer, JSON:API est un standard opinié mais bien réfléchi qui définit exactement quand et comment utiliser les enveloppes — à lire même si vous ne l'adoptez pas en entier.

Réponses d'erreur — Utilisez RFC 7807 Problem Details

Les formes d'erreur personnalisées sont l'une des sources les plus courantes de douleur d'intégration. Chaque API finit avec quelque chose de légèrement différent — {"error": "..."}, {"message": "...", "code": 42}, {"errors": [...]} — et chaque client qui consomme votre API doit écrire une logique de parsing d'erreurs sur mesure. L'IETF a résolu ça avec RFC 7807 — Problem Details for HTTP APIs. C'est un standard léger qui définit une structure JSON cohérente pour les erreurs, avec un Content-Type de application/problem+json. Adoptez-le et votre format d'erreur devient quelque chose que n'importe quel développeur peut lire sans aller chercher la doc.

json
// POST /v1/orders — 422 Unprocessable Entity
// Content-Type: application/problem+json
{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Validation Failed",
  "status": 422,
  "detail": "The order could not be created because one or more fields are invalid.",
  "instance": "/v1/orders/requests/req_7bN3k",
  "errors": [
    {
      "field": "line_items[0].quantity",
      "message": "Quantity must be a positive integer."
    },
    {
      "field": "shipping_address.postal_code",
      "message": "Postal code is required for US shipments."
    }
  ]
}
  • Parsing prévisible : Les clients savent toujours où trouver le message lisible par un humain (detail), la catégorie lisible par machine (type), et le statut HTTP reflété dans le corps (status).
  • Extensible par conception : La spec permet explicitement des champs supplémentaires comme errors pour le détail de validation au niveau du champ — vous ne contournez pas la spec.
  • Support des outils : OpenAPI 3.x supporte application/problem+json comme type de contenu de réponse, donc vos docs générées et vos SDK clients comprennent la forme nativement.
  • L'URI type est un document, pas juste une chaîne : Pointez-le vers une vraie page expliquant l'erreur, et vous venez de remplacer un ticket de support par une réponse en libre-service.

Codes de statut HTTP + corps JSON ensemble

Le code de statut et le corps JSON ne sont pas redondants — ils jouent des rôles différents. Le code de statut dit à la couche HTTP (proxys, caches, navigateurs, outils de monitoring) ce qui s'est passé. Le corps JSON dit à votre couche application. Les deux doivent être corrects. La référence des statuts HTTP de MDN est le moyen le plus rapide de trancher les débats sur quel code convient. Ceux qui perdent le plus souvent les équipes sont 400 vs 422 (les deux sont des erreurs client, mais 422 signifie spécifiquement que la syntaxe était valide et le serveur l'a comprise — la sémantique était fausse), et 401 vs 403 (401 signifie « qui êtes-vous ? », 403 signifie « je sais qui vous êtes — vous ne pouvez pas faire ça »).

json
// 400 Bad Request — malformed JSON or missing required field at the HTTP level
{
  "type": "https://api.example.com/problems/bad-request",
  "title": "Bad Request",
  "status": 400,
  "detail": "Request body is not valid JSON."
}

// 422 Unprocessable Entity — valid JSON, but business rules rejected it
{
  "type": "https://api.example.com/problems/insufficient-inventory",
  "title": "Insufficient Inventory",
  "status": 422,
  "detail": "HDPHN-BLK-XM5 has 0 units available; requested 2.",
  "instance": "/v1/orders/requests/req_7bN3k"
}

// 404 Not Found — resource doesn't exist (or you don't want to reveal it does)
{
  "type": "https://api.example.com/problems/not-found",
  "title": "Order Not Found",
  "status": 404,
  "detail": "No order with ID ord_XXXXX exists in this account."
}
  • 200 OK — GET, PUT, PATCH réussi qui retourne un corps
  • 201 Created — POST réussi qui a créé une ressource ; incluez un en-tête Location pointant vers la nouvelle ressource
  • 204 No Content — DELETE ou action réussie sans corps de réponse ; pas de JSON nécessaire
  • 400 Bad Request — syntaxe de requête malformée, le serveur ne peut même pas la parser
  • 401 Unauthorized — identifiants d'authentification manquants ou invalides
  • 403 Forbidden — authentifié mais pas autorisé
  • 404 Not Found — la ressource n'existe pas
  • 409 Conflict — conflit d'état (ex. commande en double, échec de verrou optimiste)
  • 422 Unprocessable Entity — syntaxe valide, validation sémantique/métier échouée
  • 429 Too Many Requests — limite de taux atteinte ; incluez toujours un en-tête Retry-After
  • 500 Internal Server Error — quelque chose a cassé côté serveur ; ne laissez jamais fuir les stack traces dans le corps

Dates et heures — Toujours ISO 8601

Les timestamps Unix ont l'air propres — juste un nombre. Mais c'est un piège. Est-ce que 1710499200 est en secondes ou millisecondes ? (Les deux sont courants. Date.now() de JavaScript donne des millisecondes, POSIX donne des secondes.) Quel fuseau horaire ? Ils sont illisibles dans les logs sans convertisseur. Ils ne peuvent pas représenter les dates avant 1970 proprement. Et ils déborderont les entiers 32-bit en 2038 sur les systèmes qui n'ont pas migré. ISO 8601 résout tout ça. Utilisez UTC et incluez toujours le décalage de fuseau horaire — un 2026-03-15T11:42:00 nu sans Z final ou +00:00 est ambigu et finira par causer un bug dans un client qui suppose l'heure locale.

json
// ✅ Good — unambiguous, human-readable, timezone-explicit
{
  "created_at": "2026-03-15T11:42:00Z",
  "updated_at": "2026-04-01T08:15:33Z",
  "scheduled_delivery": "2026-03-18T00:00:00Z",
  "expires_at": "2026-04-15T23:59:59Z"
}

// ❌ Avoid — ambiguous, unreadable, seconds vs ms confusion
{
  "created_at": 1710499200,
  "updated_at": 1743494133000,
  "scheduled_delivery": "15/03/2026",
  "expires_at": "April 15, 2026"
}

Null vs champs omis

Ces deux-là ne sont pas la même chose et les confondre crée des bugs subtils qui ne font surface que dans les cas limites. Null signifie que le champ existe, le serveur le connaît, et sa valeur actuelle est « rien » — comme un timestamp fulfilled_at sur une commande qui n'a pas encore été expédiée. Omettre un champ entièrement signifie qu'il ne s'applique pas dans ce contexte — comme un return_tracking_number sur une commande non retournée. Si un client voit "fulfilled_at": null, il sait que le champ fait partie du schéma de cette ressource et est explicitement non défini. Si le champ est absent, le client devrait le traiter comme hors du champ de cette réponse — ce qui importe quand vous faites des mises à jour partielles avec PATCH. Envoyer null signifie « effacez ce champ » ; l'omettre signifie « n'y touchez pas ».

json
// Order that exists but hasn't shipped yet
// fulfilled_at: null — we know about this field, it's just not set yet
// return_tracking_number: omitted — returns don't apply to this order
{
  "id": "ord_9kZ2m",
  "status": "processing",
  "created_at": "2026-03-15T11:42:00Z",
  "fulfilled_at": null,
  "shipped_at": null,
  "tracking_number": null,
  "total_amount": 149.99
}

// PATCH /v1/orders/ord_9kZ2m — cancel the scheduled delivery
// Only include fields you want to change
{
  "scheduled_delivery": null,
  "status": "cancelled"
}
// "total_amount" is omitted — we're NOT zeroing it out, just not touching it

Pagination — Curseur plutôt qu'offset

La pagination par offset (?page=3&per_page=20) est intuitive à implémenter et facile à expliquer, mais elle casse silencieusement sur des données en direct. Si un enregistrement est inséré pendant qu'un client pagine — entre la page 2 et la page 3 — il sautera un élément. Si un enregistrement est supprimé, il verra un doublon. Pour tout dataset qui change fréquemment (commandes, événements, notifications), la pagination par curseur est la valeur par défaut correcte. Vous donnez au client un curseur opaque (typiquement un ID ou timestamp encodé en base64) qui représente sa position dans l'ensemble de résultats. La page suivante commence à ce point exact, indépendamment des insertions ou suppressions. La pagination par offset est acceptable pour les UIs admin où le dataset est stable et les utilisateurs ont vraiment besoin de sauter à la page 47. Ce n'est pas acceptable pour un client mobile faisant du scroll infini.

json
// GET /v1/orders?limit=20&cursor=eyJpZCI6Im9yZF85a1oybSJ9
{
  "orders": [
    { "id": "ord_9kZ2m", "status": "fulfilled", "total_amount": 149.99, "created_at": "2026-03-15T11:42:00Z" },
    { "id": "ord_8jY1l", "status": "processing", "total_amount": 89.00, "created_at": "2026-03-14T09:10:00Z" }
  ],
  "pagination": {
    "next_cursor": "eyJpZCI6Im9yZF84alk1bCJ9",
    "has_more": true,
    "limit": 20
  }
}

// When has_more is false, omit next_cursor entirely (or set to null)
// Clients: fetch next page with ?cursor=<next_cursor> until has_more === false

Nommage des champs — snake_case vs camelCase

Choisissez une convention et imposez-la avec un linter. Le choix réel importe moins que la cohérence. Cela dit : si vos consommateurs primaires sont des clients JavaScript/TypeScript, camelCase s'intègre proprement avec le destructuring et le spread d'objet. Si vos consommateurs primaires sont des backends Python ou Ruby, snake_case semble naturel. Si vous servez les deux, la solution pragmatique est de documenter la convention et de laisser les clients utiliser une couche de transformation — JSON.parse avec un reviver, une bibliothèque Python humps, ou une seule config de sérialisation dans votre framework. Ce que vous ne devriez jamais faire est de mélanger les conventions dans la même API — customerId à côté de order_total est un signe que différents ingénieurs ont écrit différents endpoints sans se parler entre eux. Utilisez le JSON Schema Generator pour documenter vos noms de champs de manière cohérente à travers les endpoints.

Astuce de design : Quand vous concevez ou déboguez une vraie réponse d'API, collez le JSON dans le JSON Formatter — il formatera joliment les réponses minifiées, mettra en évidence la structure, et vous laissera repérer les incohérences de nommage en un coup d'œil avant qu'elles ne se retrouvent dans un SDK client.

Versioning

Deux écoles : versioning par URL (/v1/orders, /v2/orders) et versioning par en-tête (Accept: application/vnd.example.v2+json ou un en-tête personnalisé API-Version: 2026-03-15). Le versioning par URL gagne en pratique presque à chaque fois. Il est visible dans les logs sans parser les en-têtes, il fonctionne avec chaque client HTTP sans configuration, vous pouvez le tester dans un navigateur, et vous pouvez faire tourner v1 et v2 côte à côte dans la même passerelle avec une simple règle de préfixe de chemin. Le versioning par en-tête est théoriquement plus RESTful selon le modèle media type de l'IANA, mais il crée une complexité invisible — une requête qui semble identique dans l'URL se comporte en fait différemment selon un en-tête que la plupart des développeurs ne vérifient pas en premier. Le versioning basé sur la date de Stripe (Stripe-Version: 2024-06-20) est le meilleur des deux mondes pour les grandes plateformes, mais c'est un problème différent du choix de votre premier schéma de version. Quoi que vous choisissiez, versionnez dès le premier jour. Rétrofit le versioning sur une API non versionnée en production est pénible et se passe rarement proprement. Utilisez le Validateur JSON pour confirmer que les réponses des deux versions d'API sont structurellement saines pendant les tests de migration.

Pour conclure

Rien de tout cela n'est révolutionnaire — mais c'est précisément le point. Les équipes qui peinent le plus avec le design d'API ne sont pas celles qui ont fait des choix techniquement faux. Ce sont celles qui ont fait des choix différents dans différents endpoints et ne les ont jamais écrits. Réponses de succès plates. Corps d'erreur RFC 7807. Dates ISO 8601. Pagination par curseur sur des données en direct. Null pour « connu et vide », omis pour « ne s'applique pas ». Versioning par URL dès le premier jour. Ces patterns ne sont pas parfaits, mais ils sont prévisibles — et la prévisibilité est ce qui rend une API un plaisir à intégrer plutôt qu'un puzzle à rétro-ingénier. La spécification JSON formelle vit à RFC 8259 si vous avez besoin de trancher un argument au niveau de la spec. Pour tout ce qui est au-dessus de cette couche, le meilleur standard est celui que votre équipe écrit réellement et suit de manière cohérente.