POST /api/v1/shipping/quote
Calcule les frais de port d'un panier : une ligne par mode de livraison, avec la provenance du prix.
Calcul sans écriture. Contrôle d'abord la destination (même verdict que la création de commande). Boutique au FORFAIT : aucun transporteur ni prestataire, une ligne « FORFAIT » + les lignes RETRAIT éventuelles (productExternalRefs n'y est pas appliqué). Sinon : une ligne par transporteur actif (filtré par carrierCode), le prestataire interrogé UNE fois si la clé est confiée au Hub, plus les transporteurs actifs chez le prestataire mais non déclarés (seulement sans carrierCode), puis filtrage par les restrictions des produits du panier (intersection). ⚠⚠ CETTE ROUTE RÉPOND PENDANT LE TUNNEL DE PAIEMENT : « message » s'adresse à toi, l'intégrateur ; « messagePourAcheteur » est prêt à afficher tel quel à un consommateur. N'affiche JAMAIS « message » ni « error » à un acheteur. Tous les refus ci-dessous portent « messagePourAcheteur » à la racine, SAUF les formes (1) et (2) où il n'y a rien à expliquer à l'acheteur. Le corps 200 prend l'une de ces formes : (1) cas ordinaire { quotes, filtered?, notice } ; (2) forfait { quotes, shippingMode:"FORFAIT", message } ; (2 bis) forfait dont les articles n'acceptent pas l'expédition, retrait encore ouvert { quotes (RETRAIT seuls), shippingMode:"FORFAIT", messagePourAcheteur, message } ; (2 ter) forfait, plus rien de possible { quotes: [], shippingMode:"FORFAIT", reason:"aucun_mode_commun", messagePourAcheteur, message } ; (3) forfait, poids hors tranches { quotes (ligne FORFAIT à priceCents null, qui porte elle aussi son messagePourAcheteur), shippingMode:"FORFAIT", reason:"poids_hors_forfait", messagePourAcheteur, message } ; (4) aucun transporteur actif et pas de clé confiée { quotes: [], reason:"aucun_transporteur", messagePourAcheteur, message } ; (5) articles incompatibles { quotes: [], reason:"aucun_mode_commun", messagePourAcheteur, message } ; pays qui n'est pas un code ISO connu → 400 details.code "country_unknown" (11/09/2026). ⚠️ Un carrierCode inconnu, sans clé confiée au Hub, tombe dans la forme (4). ⭐ DEVIS PAR COLIS (06/10/2026, contrat 2026-10-06.02) : avec « items », si la boutique a au moins un emballage avec « capacitePieces » ou « maxContentGrams », le Hub lit CES produits dans la boutique de la clé (poids et pièces de la variante, sinon du produit), les répartit en colis (un plein, puis un plus petit), tarife CHAQUE colis par le même calcul que le devis à un poids et ADDITIONNE : « priceCents » de chaque ligne d'expédition = somme de ses « parcels[] », 0 si le franco est atteint quel que soit le nombre de colis. La racine porte alors « packing » { mode: PAR_COLIS | POIDS_UNIQUE | IMPOSSIBLE, reason, parcelCount, message }. Produit ou variante introuvable dans CETTE boutique → 422 details.code « produit_introuvable » | « variante_introuvable ». Colisage impossible → lignes d'expédition à priceCents null (unavailableReason « COLISAGE_IMPOSSIBLE »), retrait gardé, reason « colisage_impossible » au forfait. Sans « items » : réponse STRICTEMENT inchangée, sauf « parcels: [] » sur chaque ligne.
- Authentification
- Clé d'API de la boutique, en lecture. En-tête « Authorization: Bearer <clé> », depuis ton serveur et jamais depuis le navigateur.
- Corps de la requête
- Corps JSON attendu. Sa forme exacte est décrite dans la spécification machine.
- Paramètres
- Aucun. Tout paramètre de requête envoyé est refusé en 400 — jamais ignoré en silence.
- Codes de réponse
- 200 — Devis (voir les formes énumérées dans la description de l'opération).
- 400 — Corps JSON illisible (« Corps JSON illisible. »).
- 401 — Réponse commune à toutes les routes à clé (voir le tableau des codes, plus bas).
- 403 — Réponse commune à toutes les routes à clé (voir le tableau des codes, plus bas).
- 404 — Organisation introuvable.
- 422 — Trois cas, trois formes de « details » : (a) corps invalide → details = error.flatten() de Zod { formErrors: string[], fieldErrors: { <champ>: string[] } } ; (b) pays refusé → details = { code: "country_not_served" (réglage que le gérant peut ouvrir) | "country_forbidden" (interdiction légale), deblocable: boolean } ; (c) 06/10/2026, avec « items » : un article introuvable DANS CETTE BOUTIQUE → details = { code: "produit_introuvable" | "variante_introuvable", ref } (aucun prix deviné). ⚠ Les cas (b) et (c) portent « messagePourAcheteur » ; le corps invalide est un défaut de TON appel, que ton acheteur n'a pas pu provoquer. « message » s'adresse à toi, l'intégrateur ; « messagePourAcheteur » est prêt à afficher tel quel à un consommateur. N'affiche JAMAIS « message » ni « error » à un acheteur.
- 429 — Réponse commune à toutes les routes à clé (voir le tableau des codes, plus bas).
- 503 — Réponse commune à toutes les routes à clé (voir le tableau des codes, plus bas).
- Règle d'usage
- Master prompt, § 4.14.
- Réserves
- Non établi depuis le code : Exhaustivité de quotes[].unavailableReason : valeurs relevées en recherchant les affectations de « motif », sans lecture intégrale de devisExpedition.
- Non établi depuis le code : Exemple : la branche « grille du Hub, clé gardée par le site » (fallbackReason PAS_DE_GARDE_HUB, fallback false) est déduite de phraseRepli et de la fonction de repli, sans lecture ligne à ligne de tout devisExpedition.
- Volontairement non détaillé : quotes[].message : textes produits par devisExpedition / phraseRepli (src/lib/shipping-quote.ts) selon la branche, non recopiés un par un.