Aller au contenu

Contrat de réponses de l'API (référence frontend)

Ce document décrit tout ce que l'API ikloze renvoie — succès comme erreurs — pour qu'un client puisse écrire une seule couche de traitement des réponses backend.

Sources de vérité : web/exception.http-filter.ts (filtres d'exception), domain/errors.ts (classes d'erreur), app/i18n/ (localisation), app/utils/pagination.ts (enveloppe de liste), web/main.ts (préfixe, CORS).

Contrat cible sur les corps d'erreur

Sur la forme des corps d'erreur, ce document énonce le contrat visé et fait autorité : l'alignement du backend est suivi par #494. Tant qu'il n'est pas livré, quelques réponses produites hors de nos filtres — route inexistante, upload trop lourd, multipart invalide, erreur serveur, URL de fichier signée — sortent encore le format brut de NestJS ({ statusCode, message, error? }). Un client écrit dès maintenant contre la règle ci-dessous n'a rien à changer après #494 ; s'il doit tenir l'intervalle, qu'il traite tout corps portant statusCode comme une erreur générique. Partout ailleurs, c'est le code qui fait foi.


1. Invariants

  1. Toutes les routes applicatives sont préfixées par /api. Seuls le service de fichiers (/_storage/..., /_public/...) et la passerelle websocket (socket.io, à la racine) y échappent.
  2. Le corps est du JSON (application/json), sauf trois cas : 204 (corps vide), les exports CSV, et les fichiers servis en binaire.
  3. Aucune enveloppe globale. Pas de { data, success, error } : une ressource est à la racine du corps, une liste dans { items, … }, une erreur dans son propre format.
  4. Les messages sont déjà localisés par le backend. Le frontend ne re-traduit pas et ne maintient pas de table de clés.
  5. Ne jamais brancher la logique sur le texte d'un message. Décider à partir du statut, puis du champ code quand il existe.
  6. La forme du corps d'erreur se déduit du seul statut : 400 → dictionnaire de champs, tout autre statut → { message }. Aucune inspection du corps n'est nécessaire pour savoir comment le lire.

2. En-têtes de requête qui changent la réponse

En-tête Quand l'envoyer Effet
Authorization: Bearer <accessToken> Toute route authentifiée Absent ou malformé → 401 missing_authorization. Invalide / expiré → 401 invalid_credentials.
X-Workspace-Id: <ULID> Dès que le compte appartient à ≥ 2 workspaces Absent ou inconnu → 401 workspace_header_required avec la liste des workspaces. Inutile (et ignoré) quand le compte n'en a qu'un. Sur /users/me, jamais de 401 : la réponse revient simplement avec activeWorkspaceId: null.
Accept-Language: fr / en Sur chaque requête Choisit la langue de tous les messages renvoyés (§4).
Content-Type application/json ou multipart/form-data (uploads) Un multipart invalide produit un 400, sous la clé detail (§6).

Les origines autorisées en CORS sont configurées côté serveur (CORS_ALLOWED_ORIGINS). La documentation OpenAPI est servie sur /docs (protégée par basic auth).


3. Réponses de succès

3.1 Statuts

Statut Quand Corps
200 Lectures, et la majorité des mutations (les contrôleurs posent explicitement @HttpCode(200)) La ressource ou la liste
201 Créations (POST sans @HttpCode) La ressource créée
204 Actions sans donnée à renvoyer : suppressions, changement de mot de passe, confirmation d'e-mail, reset, désinscription… Vide

204 = corps vide

Ne jamais appeler response.json() sur un 204 : le corps est vide et le parse lève. La couche de transport doit court-circuiter sur status === 204 (et sur content-length: 0).

3.2 Ressource unique

Les champs sont à la racine, sans enveloppe :

{
  "id": "01K3ZQ7T4M9V2X8YB6N0C5RDPE",
  "name": "Acme Closers",
  "currency": "XOF",
  "createdAt": "2026-08-19T09:24:11.318Z",
}

3.3 Listes paginées

Toute collection de taille non bornée est paginée, avec la même enveloppe :

{
  "items": [
    /* … */
  ],
  "page": 1,
  "pageSize": 20,
  "total": 137,
}
  • Paramètres de requête : ?page=1&pageSize=20. Défauts : page=1, pageSize=20. Maximum : pageSize=100.
  • total est le nombre de lignes après filtres et scoping workspace, avant pagination → nombre de pages = Math.ceil(total / pageSize).
  • Hors bornes (page=0, pageSize=500) → 400 de validation sur le champ concerné.

Surfaces publiques (vitrine anonyme) — annuaire des closers, annonces : pageSize plafonné à 20 et page doit valoir 1. page=2 est refusé (400) plutôt que replié silencieusement sur la première page, pour ne pas empoisonner le cache d'URL du client.

3.4 Tableaux nus

Quelques collections fermées et courtes renvoient un tableau JSON brut, sans pagination : GET /api/permissions, GET /api/groups. C'est l'exception, pas la règle — se fier au schéma Swagger de l'endpoint.

3.5 Types de valeurs sur le fil

Donnée Forme Exemple
Identifiants ULID, chaîne de 26 caractères "01K3ZQ7T4M9V2X8YB6N0C5RDPE"
Instants ISO 8601 UTC (sérialisation Date) "2026-08-19T09:24:11.318Z"
Dates calendaires YYYY-MM-DD "2026-09-01"
Montants et taux Chaîne décimale, jamais un number "12000", "0.3"
Devises Code ISO 4217 "XOF"
Texte optionnel Chaîne vide — jamais null ""
Relation optionnelle null "managerId": null
Énumérations Valeurs machine stables (le plus souvent en MAJUSCULES) "CREDIT", "PENDING"

Les montants sont des chaînes

L'arithmétique monétaire côté backend se fait en décimal exact (voir ADR-001) et les montants traversent le fil en toString(). Côté frontend, les traiter avec une bibliothèque décimale (decimal.js) ou comme des chaînes opaques pour l'affichage. Un Number("12000.0001") réintroduit exactement le problème que la chaîne évite.

3.6 Réponses non-JSON et charges utiles particulières

Endpoint Type Note
GET /api/leads?format=csv text/csv; charset=utf-8 + Content-Disposition: attachment. Le même endpoint renvoie du JSON sans format=csv.
GET /api/leads/import-csv/template text/csv; charset=utf-8 Gabarit d'import
/_storage/<bucket>/<clé>?token=&expires= Binaire URL signée et expirante (pièces jointes, documents KYC)
/_public/<bucket>/<clé> Binaire URL permanente et publique (avatars, logos)
GET /api/health/live { "status": "ok" } Liveness, ne touche aucune dépendance
GET /api/health/ready Format @nestjs/terminus { status, info, error, details } ; 503 si une dépendance est indisponible

La sonde de disponibilité sort du contrat d'erreur

Le 503 de GET /api/health/ready porte un diagnostic, pas un message d'erreur applicative : son corps reste celui de @nestjs/terminus. C'est une sonde d'infrastructure, jamais consommée par une interface utilisateur.


4. Localisation (i18n)

Langues supportées : fr (défaut) et en.

Résolution de la locale, à chaque requête :

  1. Requête authentifiée → la langue portée par l'utilisateur, que la garde d'authentification (re)calcule à partir de l'en-tête Accept-Language de la requête ;
  2. Requête anonyme → l'en-tête Accept-Language ;
  3. À défaut → fr.

Accept-Language est parsé avec les q-values et les sous-tags : fr-CA;q=0.9, en;q=0.8fr. Un tag non supporté est ignoré.

👉 Conséquence pratique : envoyer Accept-Language sur chaque requête, avec la langue de l'interface. C'est le seul levier du client sur la langue des messages.

Ce que le backend garantit

  • Tout texte renvoyé est traduit et affichable tel quel. La traduction est entièrement à la charge du backend : les erreurs y transportent une clé interne et ses paramètres, résolues au moment de la réponse. Le client n'a ni table de clés à maintenir, ni cas de repli à prévoir.
  • L'interpolation ({{minimum}}, {{keys}}) est déjà résolue.
  • Une clé manquante dans un catalogue est un bug backend, corrigé côté backend — pas une forme de réponse que le client doit reconnaître.

5. Erreurs — deux formes, aucune exception

Statut Origine Forme du corps Extras
400 Validation (Zod ou métier), upload refusé, multipart invalide, payload rejetée { [champ]: string[] }
401 Authentification { message, code, workspaces? } code, workspaces?
403 Autorisation (RBAC, workspace suspendu) { message }
404 Ressource introuvable, ou route inexistante { message }
409 Conflit d'état ou d'unicité { message, code? } code?
413 Fichier trop volumineux { message }
429 Rate limiting { message } en-tête Retry-After?
500 Erreur serveur { message }
501 Fonctionnalité non implémentée { message }
503 Prestataire de paiement / d'intégration injoignable { message }

La règle tient en une phrase : 400 → dictionnaire de champs ; tout autre statut → { message }. Les seuls champs supplémentaires sont code (obligatoire sur 401, optionnel sur 409) et workspaces (uniquement 401 / workspace_header_required). Tous les messages sont localisés.

Une seule réponse échappe volontairement à la localisation, sans rompre la forme : une signature de webhook invalide répond 401 { "message": "invalid_signature" }. L'appelant est le serveur d'un prestataire, il n'a pas de locale, et un expéditeur non authentifié ne doit rien apprendre de plus. Aucun client frontend ne rencontre ce cas.


6. Détail par statut

400 Bad Request — erreurs de validation

Le corps est un dictionnaire : chaque clé est un champ fautif, chaque valeur un tableau de messages (un champ peut accumuler plusieurs erreurs).

{
  "email": ["Adresse email invalide"],
  "password": [
    "Doit contenir au moins 8 caractères",
    "Le mot de passe doit contenir au moins 8 caractères, dont une lettre, un chiffre et un symbole",
  ],
}

Chemins imbriqués. La clé est le chemin du champ, segments joints par . — objets imbriqués et index de tableau compris :

{
  "address.city": ["Ce champ est obligatoire"],
  "amounts.2": ["Doit être supérieur ou égal à 1"],
}

Uploads. Un fichier au type MIME refusé est rattaché à son champ, comme n'importe quelle valeur invalide :

{ "file": ["Formats d'avatar acceptés : JPEG, PNG, WebP et SVG"] }

La clé detail — le problème global. Quand l'erreur ne porte sur aucun champ identifiable, elle est rangée sous detail :

{ "detail": ["Les mots de passe ne correspondent pas"] }

Y atterrissent notamment :

  • les validations croisées entre champs (mots de passe non concordants, min supérieur à max) ;
  • un corps de mise à jour vide (« fournissez au moins un champ à modifier ») ;
  • un multipart invalide (trop de fichiers, champ de fichier inattendu, corps malformé) ;
  • des identifiants d'intégration tierce refusés par le prestataire.

Un service peut aussi choisir une clé ad hoc décrivant la cible plutôt qu'un champ de formulaire — status pour une transition interdite, id pour une action impossible sur la ressource visée, managerId pour un rattachement invalide. Le client les traite comme detail : à afficher au niveau formulaire.

Gestion frontend

  • Itérer sur les clés pour positionner les erreurs sous chaque champ.
  • Réserver un emplacement pour detail et pour toute clé inconnue du formulaire → erreur de niveau formulaire.
  • Afficher tous les éléments du tableau, pas seulement le premier.
  • Type : Record<string, string[]>.

401 Unauthorized — authentification

{
  "message": "Jeton invalide",
  "code": "invalid_credentials",
}

workspaces n'est présent que dans le cas workspace_header_required — sinon la clé est absente du JSON (et non null).

Valeurs possibles de code

code Signification Action frontend
missing_authorization En-tête Authorization absent ou malformé Rediriger vers la connexion.
invalid_credentials Identifiants faux, jeton invalide/expiré, ou compte introuvable Tenter un refresh ; en cas d'échec, purger la session et rediriger.
account_not_activated E-mail non confirmé Écran « activez votre compte » + renvoi du lien.
user_suspended Compte suspendu Écran dédié, aucun retry.
workspace_header_required Plusieurs workspaces, aucun sélectionné Afficher un sélecteur à partir de workspaces, mémoriser le choix, rejouer avec X-Workspace-Id.
no_membership Le compte n'appartient à aucun workspace Rediriger vers la création / jonction d'un workspace.
social_token_invalid Jeton du fournisseur social invalide Relancer le flux social.
social_email_unverified E-mail non vérifié chez le fournisseur social Demander la vérification chez le fournisseur, ou la connexion par mot de passe.
social_account_not_found Aucun compte ikloze pour cet e-mail Rediriger vers l'inscription — la connexion sociale ne crée jamais de compte.

Le champ workspaces

{
  "message": "L'en-tête X-Workspace-Id est requis lorsque l'utilisateur appartient à plusieurs espaces de travail",
  "code": "workspace_header_required",
  "workspaces": [
    { "id": "01K3ZQ7T4M9V2X8YB6N0C5RDPE", "name": "Acme Closers" },
    { "id": "01K3ZQ8F2H4A1B7CD9E0F5GHJK", "name": "Side Project" },
  ],
}

Le client présente ces options, puis rejoue la requête avec X-Workspace-Id: <id choisi>.

Un 401 n'implique pas de déconnecter

Seuls missing_authorization et invalid_credentials concernent la session. workspace_header_required, no_membership, account_not_activated et user_suspended arrivent avec un jeton parfaitement valide : les traiter comme une expiration de session fait boucler l'utilisateur sur l'écran de connexion.

403 Forbidden — autorisation

L'utilisateur est authentifié mais n'a pas la permission (RBAC), ou le workspace actif est suspendu (toute mutation est alors refusée aux non-staff).

{ "message": "Accès refusé" }
  • Afficher message, ne pas déconnecter.
  • Masquer en amont les actions interdites : le 403 est un filet, pas le canal d'information nominal.

404 Not Found — ressource introuvable

La ressource n'existe pas ou n'est pas visible dans le workspace actif : le backend répond volontairement 404 plutôt que 403 pour ne pas révéler l'existence d'une ressource d'un autre workspace (anti-énumération). Un chemin inexistant répond dans la même forme.

{ "message": "Ressource introuvable" }
  • Afficher un état « introuvable » ou rediriger vers la liste parente.
  • Ne pas l'interpréter comme un refus d'accès dans l'UI.

409 Conflict — conflit d'état ou d'unicité

// Forme courante
{ "message": "Cet email est déjà utilisé" }

// Avec code machine (liste d'attente)
{ "message": "Votre demande est déjà approuvée", "code": "already_approved" }
  • code est optionnel et rare (aujourd'hui : already_approved, already_pending sur la liste d'attente). Ne jamais supposer sa présence.
  • Sans code, afficher message au niveau formulaire.

413 Payload Too Large — fichier trop volumineux

Émis par la couche multipart avant tout traitement applicatif. Le nom du champ est perdu à ce stade, d'où un { message } et non une erreur de champ :

{ "message": "Le fichier dépasse la taille maximale autorisée" }
  • Rappeler la limite de l'endpoint dans l'UI (2 Mo pour un avatar, 5 Mo pour un document KYC ou un CSV, 10 Mo par pièce jointe).
  • Contrôler la taille côté client avant l'envoi reste la meilleure UX.

429 Too Many Requests — rate limiting

{ "message": "Trop de requêtes" }
  • L'en-tête Retry-After (en secondes) est présent quand le délai est connu — toujours pour le limiteur global, pas systématiquement pour les limites métier (relance d'invitation, analyse IA…). Prévoir un backoff par défaut quand il manque.
  • Ordres de grandeur : 100 req/min par défaut, 20/min sur /api/auth/*, 30/min sur les annuaires, 10/min sur les propositions de mission et les candidatures. Les valeurs exactes sont pilotées par variables d'environnement.
  • Le budget est décompté par adresse IP appelante : un même réseau (cybercafé, bureau, opérateur mobile derrière CGNAT) le partage — d'où des plafonds volontairement larges.

500 Internal Server Error — erreur serveur

Toute exception non rattachée à un filtre (bug, panne d'infrastructure, ou invariant interne : LedgerError, ExchangeRateUnavailableError, PegDeviationError) :

{ "message": "Une erreur est survenue, veuillez réessayer" }
  • Le message est générique et localisé : il ne dit rien de l'exception, et est affichable tel quel.
  • L'incident est tracé côté backend (Sentry) ; inutile de le remonter depuis le client.

501 Not Implemented

{ "message": "Non implémenté" }

Endpoint volontairement non encore implémenté → masquer ou désactiver la fonctionnalité côté UI.

503 Service Unavailable

Deux origines, même contrat : le prestataire de paiement (Mobile Money / carte) est injoignable, ou une intégration tierce l'est.

{ "message": "Le service de paiement est momentanément indisponible" }
  • L'utilisateur n'a rien fait de mal : proposer de réessayer plus tard, ne pas considérer un paiement comme échoué définitivement.
  • Ne pas relancer automatiquement en boucle.

7. Lire une erreur

Le statut suffit à choisir la branche :

type ApiError =
  | { kind: 'validation'; status: 400; fields: Record<string, string[]> }
  | {
      kind: 'auth';
      status: 401;
      message: string;
      code: AuthErrorCode;
      workspaces?: WorkspaceOption[];
    }
  | { kind: 'message'; status: number; message: string; code?: string };

export function readApiError(status: number, body: unknown): ApiError {
  const record = (body ?? {}) as Record<string, unknown>;

  if (status === 400) {
    return {
      kind: 'validation',
      status,
      fields: record as Record<string, string[]>,
    };
  }

  const message = record.message as string;

  if (status === 401) {
    return {
      kind: 'auth',
      status,
      message,
      code: record.code as AuthErrorCode,
      workspaces: record.workspaces as WorkspaceOption[] | undefined,
    };
  }

  return {
    kind: 'message',
    status,
    message,
    code: typeof record.code === 'string' ? record.code : undefined,
  };
}

Deux conventions valent pour toutes les branches :

  • detail et toute clé inconnue d'un 400 sont des erreurs de niveau formulaire, pas de champ ;
  • le texte n'est jamais un discriminant : brancher sur le statut, puis sur code.

8. Types TypeScript de référence

// --- Succès ---
export interface Paginated<T> {
  items: T[];
  page: number;
  pageSize: number;
  total: number;
}

// --- Erreurs ---
export type ValidationErrorBody = Record<string, string[]>; // 400, `detail` pour le global

export type AuthErrorCode =
  | 'invalid_credentials'
  | 'missing_authorization'
  | 'workspace_header_required'
  | 'no_membership'
  | 'account_not_activated'
  | 'user_suspended'
  | 'social_token_invalid'
  | 'social_email_unverified'
  | 'social_account_not_found';

export interface WorkspaceOption {
  id: string;
  name: string;
}

export interface AuthErrorBody {
  // 401
  message: string;
  code: AuthErrorCode;
  workspaces?: WorkspaceOption[]; // uniquement pour workspace_header_required
}

export interface MessageErrorBody {
  // 403 / 404 / 413 / 429 / 500 / 501 / 503
  message: string;
}

export interface ConflictErrorBody {
  // 409
  message: string;
  code?: string;
}

export type ApiErrorBody =
  | ValidationErrorBody
  | AuthErrorBody
  | ConflictErrorBody
  | MessageErrorBody;

9. Couche unifiée de référence

Squelette d'un client qui applique tout le contrat : en-têtes, 204, non-JSON, refresh unique, sélection de workspace, backoff sur 429.

export class ApiRequestError extends Error {
  constructor(
    readonly status: number,
    readonly detail: ApiError,
  ) {
    super(`API ${status}`);
  }
}

async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
  const response = await fetch(`${API_URL}/api${path}`, {
    ...init,
    headers: {
      Accept: 'application/json',
      'Accept-Language': currentLocale(), // fr | en
      ...(session.accessToken
        ? { Authorization: `Bearer ${session.accessToken}` }
        : {}),
      ...(session.workspaceId ? { 'X-Workspace-Id': session.workspaceId } : {}),
      ...init.headers,
    },
  });

  if (response.status === 204) return undefined as T; // corps vide

  const contentType = response.headers.get('content-type') ?? '';
  if (!contentType.includes('application/json')) {
    if (response.ok) return (await response.blob()) as T; // CSV, fichiers
    throw new ApiRequestError(response.status, {
      kind: 'message',
      status: response.status,
      message: t('errors.generic'),
    });
  }

  const body = await response.json();
  if (response.ok) return body as T;

  const error = readApiError(response.status, body);

  if (error.kind === 'auth') {
    if (
      error.code === 'invalid_credentials' ||
      error.code === 'missing_authorization'
    ) {
      await session.refreshOnce(); // un seul refresh, partagé entre requêtes concurrentes
      return request<T>(path, init); // rejoue une fois ; un second échec déconnecte
    }
    if (error.code === 'workspace_header_required') {
      await session.chooseWorkspace(error.workspaces ?? []);
      return request<T>(path, init);
    }
  }

  if (response.status === 429) {
    const retryAfter =
      Number(response.headers.get('retry-after') ?? '') ||
      DEFAULT_BACKOFF_SECONDS;
    scheduleRetry(retryAfter);
  }

  throw new ApiRequestError(response.status, error);
}

Côté UI, un seul point de branchement suffit :

function handle(error: ApiRequestError, form?: FormController) {
  switch (error.detail.kind) {
    case 'validation':
      form?.setFieldErrors(error.detail.fields); // `detail` et clés inconnues → erreur de formulaire
      break;
    case 'auth':
      // déjà traité dans la couche transport ; ici : suspension, activation, aucun workspace
      routeToAuthState(error.detail.code);
      break;
    case 'message':
      toast.error(error.detail.message); // déjà localisé, affichable tel quel
      break;
  }
}

10. Récapitulatif des décisions frontend

  • 204 → ne pas parser le corps.
  • Listes → toujours { items, page, pageSize, total } ; les tableaux nus sont l'exception.
  • Montants → chaînes décimales, arithmétique en décimal exact.
  • Accept-Language → envoyé à chaque requête ; les messages reçus sont affichables tels quels.
  • 400 → mapper { champ: messages[] } sur le formulaire ; detail et clés inconnues en erreur globale.
  • 401 → brancher sur code, jamais sur le texte ; ne déconnecter que sur invalid_credentials / missing_authorization.
  • 403 → afficher message, ne pas déconnecter.
  • 404 → état « introuvable », pas un refus d'accès.
  • 409 → traitement ciblé si code, sinon message.
  • 413 → afficher message et rappeler la limite ; contrôler la taille avant l'envoi.
  • 429 → respecter Retry-After, backoff par défaut sinon.
  • 500 / 501 / 503 → afficher message, proposer de réessayer plus tard.
  • Toujours → la forme se déduit du statut : 400 → dictionnaire, tout le reste → { message }.

Voir aussi