Gestion d'erreurs
Codes d'erreur
Branchez sur le statut HTTP et sur le slug. Jamais sur le message.
Enveloppe
Toute erreur du contrôle d'accès (clé, Client ID, scope) renvoie exactement ces trois champs. Les refus métier y ajoutent des champs de contexte, sans jamais en retirer.
{
"success": false,
"error": "INVALID_API_KEY",
"message": "Clé API invalide ou révoquée"
}- Branchez sur le statut HTTP et sur error. C'est un slug stable, en majuscules, destiné au code.
- Ne branchez jamais sur message. C'est de la prose française destinée à un humain : elle peut être reformulée ou traduite à tout moment, sans préavis. Elle n'est pas contractuelle.
- Il n'y a aucun champ code numérique. Si une ancienne documentation vous a fait écrire
if (err.code === 4001), ce code n'a jamais existé : supprimez-le. - Trois exceptions à cette enveloppe : les erreurs de champ (tableau
errors, aucun slug) ;POST /shipping/batch, qui inverseerroretcode; et les 400 sans slug dePOST /ratesetPOST /shipping.
Slugs d'authentification
| HTTP | error | Cause |
|---|---|---|
| 401 | MISSING_API_KEY | En-tête Authorization absent, mal formé, ou clé vide |
| 401 | MISSING_CLIENT_ID | Aucun clientId (en-tête, query ou body) |
| 401 | INVALID_API_KEY | Clé inconnue ou révoquée |
| 401 | EXPIRED_API_KEY | Date d'expiration dépassée |
| 401 | PROFESSIONAL_NOT_FOUND | Compte lié à la clé introuvable |
| 403 | INVALID_CLIENT_ID | Le Client ID ne correspond pas au propriétaire de la clé |
| 403 | INSUFFICIENT_PERMISSIONS | Scope manquant pour cette méthode HTTP |
| 500 | INTERNAL_ERROR | Erreur interne du middleware d'authentification |
Slugs métier
| HTTP | error | Champs additionnels |
|---|---|---|
| 403 | CLIENT_TYPE_REQUIRED | clientType, contractVersion |
| 403 | CONTRACT_SIGNATURE_REQUIRED | clientType, contractVersion |
| 403 | PICKUP_ADDRESS_REQUIRED | — · réparable par PUT /shipping/pickup-address |
| 400 | PICKUP_ADDRESS_NOT_GEOCODABLE | — · rendu par PUT /shipping/pickup-address |
| 403 | INSUFFICIENT_BALANCE | currentBalance, requiredAmount |
| 403 | ACCOUNT_SUSPENDED | amountDue, paymentStatus, gracePeriodEndsAt |
| 403 | PAYMENT_REQUIRED | amountDue, paymentStatus, gracePeriodEndsAt |
| 409 | IDEMPOTENCY_KEY_CONFLICT | — |
CLIENT_TYPE_REQUIRED et CONTRACT_SIGNATURE_REQUIRED ne se réparent pas par API : remontez l'alerte à l'opérateur de votre côté — il doit se rendre sur le dashboard. Aucun réessai ne les résout.
PICKUP_ADDRESS_REQUIRED fait exception : ne le traitez pas comme les deux précédents. PUT /shipping/pickup-address le lève, et votre intégration peut donc se débloquer seule. Réserver une alerte humaine à ce slug retarde un colis pour un réglage que votre code sait configurer.
Réponses sans slug
N'attendez pas un champ error systématiquement.
- 400 de validation (champ requis absent, mauvais type) :
{ "success": false, "errors": [{ type, msg, path, location }] }— une enveloppe différente : un tableauerrors, et aucun champerror.pathvous donne le champ fautif (ex.parcelDetails.nature). - 400 métier :
{ "success": false, "message": "..." }—messageseul. C'est la forme que prennent les refus de créneau, le dépassement decreditLimitendeferred, et — surPOST /ratesseulement —weighttotalement absent. Le poids non positif n'est plus dans cette liste :0et les négatifs sont rejetés par la validation de champ. - 404 :
{ "success": false, "message": "Bordereau non trouvé" } - 429 :
{ "success": false, "message": "...", "window": "minute" }— transitoire : à rejouer, jamais à ignorer. Sans slugerror, un client qui branche uniquement sur les slugs et surstatus >= 500le laisse tomber en échec définitif. Classez-le avec les 5xx et rejouez la même clé d'idempotence. - 500 :
{ "success": false, "error": "INTERNAL_ERROR", "message": "..." }— un slug, celui-là, et un message générique : le détail de l'exception reste dans nos journaux. C'est la forme rendue par le contrôle d'accès et par la grande majorité des handlers. - 400 sans slug, message brut — propre à
POST /ratesetPOST /shipping, et à eux seuls. Une fois l'authentification passée, ces deux handlers rattrapent toute exception : celle qui ne porte ni slug ni statut propre ressort en 400, avec le message brut de l'exception. - 400 VALIDATION_ERROR — propre aux écritures de webhooks (
POST /webhooks,PUT /webhooks/:id,DELETE /webhooks/:id) :{ "success": false, "error": "VALIDATION_ERROR", "message": "...", "details": "..." }. Le slug y est trompeur : ces handlers rendentVALIDATION_ERRORpour toute exception, y compris une panne interne.detailsporte le message brut : journalisez-le, ne branchez pas dessus.GET /webhooks, lui, rend un500 INTERNAL_ERRORnormal.
Conséquence : pour ces 400 métier, il n'existe aujourd'hui rien de stable sur quoi brancher — seule la prose distingue « créneau invalide » de « créneau dans le passé ». Ne construisez pas de logique dessus : traitez-les comme des erreurs d'intégration à remonter à un opérateur, avec le message en clair dans vos logs. Les blocages de compte, eux, ont chacun leur slug en 403.
Sur POST /rates et POST /shipping, un 400 ne prouve pas que votre requête était invalide
Ces deux handlers rattrapent toute exception et la rendent en { success: false, message } — 400, sans slug. Une panne interne (base de données injoignable, bug) y prend donc exactement la même forme qu'un créneau invalide : le statut seul ne les distingue pas. Le message est le seul indice, et il n'est pas contractuel.
C'est un comportement connu de ces deux routes. Partout ailleurs, une panne interne sort en 500 INTERNAL_ERROR.
Conséquence pratique : puisqu'un 400 sans slug peut être une panne, ne le traitez pas comme définitif : rejouez-le avec la même clé d'idempotence. Si l'appel avait malgré tout abouti, le rejeu vous rend le bordereau existant en 200 plutôt que d'en créer un second.
Gardez malgré tout la règle status >= 500 ⇒ panne : elle vaut partout, y compris sur ces deux routes. Le contrôle d'accès et le quota s'y exécutent avant le handler.
Exemple de traitement
const API_KEY = 'sk_live_VOTRE_CLE';
const CLIENT_ID = 'VOTRE_CLIENT_ID';
const BASE_URL = 'https://api.arelpro.com/pro/api/v1';
// 5xx ET 429 sont TRANSITOIRES : votre appelant doit les rejouer. Sur
// POST /shipping, rejouez-les avec la MEME cle d'idempotence.
class ArelRejouable extends Error {}
async function appelArel(path, options = {}) {
const response = await fetch(`${BASE_URL}${path}`, {
...options,
headers: {
Authorization: `Bearer ${API_KEY}`,
'X-Client-Id': CLIENT_ID,
'Content-Type': 'application/json',
...(options.headers || {}),
},
});
// Un 5xx peut venir d'un proxy et n'etre PAS du JSON : ne lisez pas
// body.error avant d'avoir ecarte ce cas. Reessayable.
if (response.status >= 500) {
throw new ArelRejouable(`HTTP ${response.status} — panne serveur, reessayez.`);
}
// 429 = quota atteint : TRANSITOIRE, a rejouer — pas un refus de la requete.
// Il faut l'ecarter ICI : son corps ne porte AUCUN slug `error`, donc le
// switch ci-dessous le manquerait et il finirait en echec definitif.
// Pas d'en-tete Retry-After ; X-RateLimit-Reset ne vaut que pour `window`
// === 'minute'. Sur 'hour' / 'day', requeuez plus tard.
if (response.status === 429) {
const quota = await response.json().catch(() => ({}));
throw new ArelRejouable(`429 — quota ${quota.window ?? 'inconnu'} atteint.`);
}
const body = await response.json();
// 200 ET 201 sont des succes (201 = cree, 200 = rejeu idempotent).
if (response.ok) return body;
// Branchez sur le STATUT HTTP et sur body.error (slug stable).
// Ne branchez JAMAIS sur body.message : c'est de la prose pour un humain.
switch (body.error) {
case 'MISSING_API_KEY':
case 'MISSING_CLIENT_ID':
case 'INVALID_API_KEY':
case 'EXPIRED_API_KEY':
throw new Error('401 — verifiez vos identifiants.');
case 'INVALID_CLIENT_ID':
throw new Error("403 — le Client ID n'appartient pas a cette cle.");
case 'INSUFFICIENT_PERMISSIONS':
throw new Error("403 — la cle n'a pas le scope requis pour cette methode HTTP.");
case 'PICKUP_ADDRESS_REQUIRED':
// Reparable par API : PUT /shipping/pickup-address, puis rejouez.
// N'alertez un humain que si cet appel echoue a son tour.
throw new Error(
"403 — adresse d'enlevement absente : PUT /shipping/pickup-address."
);
case 'PICKUP_ADDRESS_NOT_GEOCODABLE':
// 400 rendu par PUT /shipping/pickup-address lui-meme : adresse
// introuvable. Renvoyez-la avec coordinates.latitude / .longitude.
throw new Error('400 — adresse non geocodable, fournissez coordinates.');
case 'CLIENT_TYPE_REQUIRED':
case 'CONTRACT_SIGNATURE_REQUIRED':
case 'ACCOUNT_SUSPENDED':
// Ceux-la engagent juridiquement ou financierement : action humaine au
// dashboard, aucun remede par API. Aucun reessai ne les resout.
throw new Error(`403 — blocage compte (${body.error}), aucun remede par API.`);
case 'INSUFFICIENT_BALANCE':
throw new Error(`403 — solde insuffisant : ${body.currentBalance} EUR.`);
default:
break;
}
// Les erreurs de CHAMP ont une enveloppe differente : un tableau 'errors',
// et aucun slug 'error'. Testez-la avant de vous rabattre sur le message.
// 404 => body.message seul (le 429 est deja parti en ArelRejouable)
if (Array.isArray(body.errors)) {
throw new Error(`400 — champs invalides : ${JSON.stringify(body.errors)}`);
}
throw new Error(`HTTP ${response.status} : ${body.message}`);
}Commencer l'intégration
Créez votre compte professionnel et obtenez vos clés API pour démarrer vos premiers envois.