1. Introduction
La plateforme expose trois canaux distincts. Ils ne partagent ni le même public, ni le même régime d'authentification, ni les mêmes limites. Choisir le bon est la première décision d'une intégration.
| Canal |
Préfixe |
Pour qui |
Authentification |
| API CMS |
/api/v1/cms/ |
Modules e-commerce, ERP, CRM, outils internes |
Clé d'API + signature HMAC en écriture |
| API publique |
/api/v1/public/ |
Widgets d'affichage, JavaScript de boutique |
Aucune — débit limité, CORS restreint |
| MCP |
/api/v1/mcp |
Assistants IA (Claude, ChatGPT, autres) |
Même clé, même signature que l'API CMS |
Avant d'écrire une ligne de code : vérifiez qu'un module ne suffit pas
Les modules PrestaShop et WooCommerce font l'intégralité de ce que décrit cette page : ils transmettent les commandes au bon moment, posent le script des widgets dans le thème, placent les étoiles sur les fiches produit et le bloc d'avis, et gèrent la signature des requêtes. Le marchand ne colle rien et n'écrit rien.
Télécharger les modules →
Cette documentation s'adresse donc à trois cas : une plateforme pour laquelle nous n'avons pas encore de module, un développement sur mesure, ou le branchement d'un outil tiers (ERP, service client, assistant IA) sur les avis déjà collectés.
Adresses de base
Toutes les URL de cette page sont relatives à l'adresse de l'API. Un module ne doit connaître qu'elle : les autres adresses lui sont renvoyées par GET /api/v1/cms/me, ce qui lui évite de les deviner et permet de les changer sans mettre à jour quoi que ce soit chez les marchands.
| Usage | Adresse |
| API (tous canaux) | https://louis.guide |
| Espace marchand | https://louis.guide/app |
| Script des widgets | https://louis.guide/widget/v1/avis.js |
Conventions
- Format — JSON en entrée comme en sortie, encodé en UTF-8. L'en-tête
Content-Type: application/json est attendu sur toute requête portant un corps.
- Nommage — serpent minuscule (
external_order_id, experienced_at), la convention dominante des API que consomment les intégrateurs PHP et JavaScript.
- Dates — ISO 8601 avec fuseau explicite en entrée (
2026-08-01T14:22:00+02:00). En sortie, les dates complètes sont au même format ; les dates publiques d'un avis sont réduites au jour (2026-08-01) parce qu'aucun widget n'affiche l'heure.
- Montants — transmis en chaîne (
"129.90") et non en nombre flottant : un centime perdu à l'arrondi sur une commande devient un écart de facturation.
- Identifiants — les objets que nous créons portent un UUID permanent. Les vôtres (commande, produit, déclinaison) restent les vôtres : nous ne les réécrivons jamais.
-
Erreurs —
toujours la même enveloppe
{ "error": { "code": …, "message": … } }. Le code est stable et destiné à votre programme, le message à l'humain qui débogue. Voir §10.
-
Versionnage —
le
/v1 du chemin est un contrat. Un champ optionnel peut y être ajouté à tout moment ; aucun champ existant ne sera renommé, supprimé ni rendu obligatoire. Une rupture partirait en /v2, l'ancienne version restant servie — les modules tournent chez les marchands et personne ne peut les mettre à jour à distance.
Votre code doit donc ignorer les champs qu'il ne connaît pas plutôt qu'échouer à leur vue.
Obtenir une clé d'API
-
Créez un compte marchand sur l'espace marchand.
- Confirmez l'adresse email, puis ouvrez la section des clés d'API.
- Notez le secret : il n'est affiché qu'une fois. Perdu, il ne se retrouve pas — une nouvelle clé se crée, l'ancienne se révoque.
Un module d'installation n'a pas besoin de cette manipulation : il ouvre lui-même une demande de raccordement que le marchand valide d'un clic. Voir §3.
2. Authentification
L'API CMS et le serveur MCP utilisent le même mécanisme : une clé qui dit qui appelle, et une signature qui prouve que l'appelant détient le secret. Ce sont deux choses distinctes.
| Méthode HTTP | En-têtes exigés | Pourquoi |
GET, HEAD |
X-Api-Key |
Une lecture ne modifie rien : la clé suffit à l'autoriser. |
POST, PUT, PATCH, DELETE |
X-Api-Key, X-Timestamp, X-Signature |
Une écriture engage le marchand : elle doit être prouvée et non rejouable. |
Le secret ne circule jamais
Seule la signature voyage. Cela ferme trois portes qui ne demandent aucune compromission de la boutique : la fuite passive du secret dans les journaux d'un intermédiaire, le rejeu d'une requête interceptée, et l'altération du corps en transit. En revanche, cela ne protège pas d'une boutique dont la base de données est dérobée — contre ce cas, la défense est la rotation des clés.
Ne mettez jamais le secret dans une URL : les URL finissent dans les journaux de tous les intermédiaires traversés.
2.1 La signature, pas à pas
Étape 1 — Les trois en-têtes
| En-tête | Contenu |
X-Api-Key |
Identifiant public de la clé, tel qu'affiché dans l'espace marchand. |
X-Timestamp |
Horodatage Unix en secondes, chiffres uniquement. Pas de millisecondes, pas de date ISO. |
X-Signature |
Le préfixe littéral sha256= suivi du HMAC-SHA256 en hexadécimal minuscule. Le préfixe fait partie de la valeur comparée : l'omettre produit un rejet. |
Étape 2 — Construire la charge à signer
Quatre morceaux concaténés sans séparateur, dans cet ordre exact :
charge = X-Timestamp
+ MÉTHODE HTTP en majuscules
+ chemin logique de la requête
+ corps brut de la requête
| Morceau | Règle exacte |
| Horodatage |
La chaîne identique à celle envoyée dans X-Timestamp. |
| Méthode |
POST, PUT… toujours en majuscules. |
| Chemin |
Le chemin sans schéma, sans hôte, sans chaîne de requête, commençant par / — par exemple /api/v1/cms/orders. Si l'API est servie depuis un sous-répertoire, ce préfixe d'installation n'entre pas dans la signature : il vit dans l'adresse de base, pas dans le chemin logique. |
| Corps |
La chaîne d'octets exactement telle qu'elle est envoyée. Sérialisez une fois, signez cette chaîne, envoyez cette chaîne. Corps vide → chaîne vide. |
Étape 3 — Calculer
X-Signature = "sha256=" + HMAC_SHA256(charge, secret) // hexadécimal minuscule
Étape 4 — Vérifier votre implémentation sur cet exemple
Ces valeurs sont figées et la signature affichée est réellement celle de ces données : si votre code produit autre chose, le problème est dans votre code, pas dans le nôtre.
secret : sk_demo_3f9c1a7e5b2d48a6
X-Timestamp : 1786000000
méthode : POST
chemin : /api/v1/cms/reviews/9f1c2b3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d/response
corps : {"content":"Merci pour votre retour !"}
Charge à signer (une seule ligne, aucun espace ajouté) :
1786000000POST/api/v1/cms/reviews/9f1c2b3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d/response{"content":"Merci pour votre retour !"}
Résultat attendu :
X-Signature: sha256=8a9c0de10516226f965465704902e00c666892b483b45b361f4536a6b2ee36e9
Le même calcul en une ligne de shell :
printf '%s' '1786000000POST/api/v1/cms/reviews/9f1c2b3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d/response{"content":"Merci pour votre retour !"}' \
| openssl dgst -sha256 -hmac 'sk_demo_3f9c1a7e5b2d48a6' -r
printf et non echo : ce dernier ajoute un saut de ligne final, qui change la signature.
Étape 5 — Un appel complet en curl
SECRET='sk_demo_3f9c1a7e5b2d48a6'
CLE='ak_demo_5c2f81b0'
TS=$(date +%s)
CHEMIN='/api/v1/cms/orders'
CORPS='{"external_order_id":"CMD-1042","experienced_at":"2026-08-01T14:22:00+02:00","customer":{"email":"claire.martin@exemple.fr","first_name":"Claire","country":"FR","locale":"fr"},"source":{"platform":"custom","plugin_version":"1.0.0"}}'
SIG=$(printf '%s' "$TS""POST""$CHEMIN""$CORPS" \
| openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
curl -X POST 'https://louis.guide/api/v1/cms/orders' \
-H "X-Api-Key: $CLE" \
-H "X-Timestamp: $TS" \
-H "X-Signature: sha256=$SIG" \
-H 'Content-Type: application/json' \
--data-raw "$CORPS"
--data-raw et non --data : le second interprète certains caractères et peut modifier le corps envoyé, donc invalider la signature.
2.2 Exemple en PHP
Le client minimal, sans dépendance. C'est la même mécanique que celle des modules PrestaShop et WooCommerce, réduite à l'essentiel.
<?php
final class ClientGuideLouis
{
public function __construct(
private string $baseUrl, // ex. 'https://louis.guide'
private string $apiKey,
private string $secret,
) {
}
/**
* @param array<string, mixed> $donnees
*
* @return array{status: int, body: array<string, mixed>}
*/
public function post(string $chemin, array $donnees): array
{
// ON SÉRIALISE UNE SEULE FOIS. La chaîne signée et la chaîne envoyée
// doivent être le même objet : réencoder juste avant l'envoi produit
// tôt ou tard une différence d'échappement, donc un 401 inexplicable.
$corps = json_encode($donnees, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$timestamp = (string) time();
// Le chemin LOGIQUE, sans l'adresse de base : c'est lui qui est signé.
$chemin = '/' . ltrim($chemin, '/');
$signature = hash_hmac(
'sha256',
$timestamp . 'POST' . $chemin . $corps,
$this->secret,
);
$ch = curl_init(rtrim($this->baseUrl, '/') . $chemin);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $corps,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
'X-Api-Key: ' . $this->apiKey,
'X-Timestamp: ' . $timestamp,
'X-Signature: sha256=' . $signature,
],
]);
$reponse = (string) curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['status' => $status, 'body' => json_decode($reponse, true) ?: []];
}
}
2.3 Les quatre pièges
Quatre causes expliquent la quasi-totalité des signature_mismatch. Elles se ressemblent toutes vues de l'extérieur — d'où l'intérêt de les écarter dans cet ordre.
- Le corps a été réencodé après la signature. Le cas le plus fréquent, et le plus difficile à voir : un tableau sérialisé deux fois donne deux chaînes différentes dès qu'il contient un accent ou une barre oblique (
JSON_UNESCAPED_UNICODE, JSON_UNESCAPED_SLASHES, ordre des clés). Signez la chaîne, envoyez cette chaîne, ne la reconstruisez jamais.
- Le chemin signé porte un préfixe qu'il ne devrait pas avoir. Le chemin signé est
/api/v1/cms/orders, même si l'API est servie depuis https://exemple.fr/plateforme/api/v1/cms/orders. Le préfixe d'installation appartient à l'adresse de base. À l'inverse, ne signez pas non plus l'URL complète avec son schéma et son hôte.
-
L'horloge du serveur dérive.
Tolérance : 300 secondes d'écart, dans un sens comme dans l'autre. Au-delà, la réponse est
timestamp_out_of_range et son message indique l'écart mesuré en secondes — l'information exacte à donner à votre hébergeur. Ce cas se manifeste souvent par une intégration qui « fonctionnait hier ».
- La méthode ou le préfixe manquent. La méthode entre dans la charge en majuscules, et la valeur de
X-Signature commence par sha256=. Un HMAC nu, sans préfixe, est rejeté.
Ce que la chaîne de requête ne fait pas
Les paramètres d'URL (?page=2) n'entrent pas dans la charge signée : seul le chemin y figure. Ils sont sans conséquence en pratique, les endpoints signés étant tous des écritures qui portent leurs paramètres dans le corps — mais une implémentation qui les ajouterait à la charge échouerait.
Rejeu et fenêtre de validité
La charge signée couvre l'horodatage, la méthode, le chemin et le corps. Omettre l'un d'eux ouvrirait une faille : sans le chemin, une signature valide pour POST /orders serait rejouable sur DELETE /orders ; sans l'horodatage, la requête serait rejouable indéfiniment.
La fenêtre de 300 secondes est ce qui borne le rejeu : une requête interceptée ne peut être réémise au-delà. Il n'y a pas de dictionnaire de signatures déjà vues — à l'intérieur de cette fenêtre, une requête identique est donc acceptée deux fois. C'est sans effet sur la transmission des commandes, qui est idempotente par external_order_id : la seconde reçoit la commande déjà enregistrée et n'envoie pas un second email.
Réponses d'authentification
Toutes ces réponses portent le statut 401.
| Code | Cause | À faire |
missing_api_key |
En-tête X-Api-Key absent. |
Ajouter l'en-tête. |
invalid_api_key |
Clé inconnue, révoquée ou expirée. Le message est volontairement identique dans les trois cas : les distinguer permettrait de tester en masse quels identifiants existent. |
Vérifier la clé dans l'espace marchand, ou en créer une nouvelle. |
missing_signature |
Écriture sans en-tête X-Signature. |
Signer la requête (§2.1). |
missing_timestamp |
Écriture sans en-tête X-Timestamp. |
Ajouter l'horodatage, et le signer. |
invalid_timestamp |
X-Timestamp n'est pas une suite de chiffres — millisecondes, date ISO ou signe. |
Envoyer un horodatage Unix en secondes. |
timestamp_out_of_range |
Plus de 300 secondes d'écart. Le message donne l'écart exact. |
Synchroniser l'horloge du serveur (NTP). |
signature_mismatch |
La signature ne correspond pas à la charge attendue. |
Reprendre les quatre pièges du §2.3, dans l'ordre. |
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"error": {
"code": "timestamp_out_of_range",
"message": "Horodatage hors tolérance : +412 s d'écart avec notre serveur (maximum 300 s). L'horloge de votre serveur est probablement désynchronisée."
}
}
3. Raccordement d'une boutique
Ces deux endpoints permettent à un module de récupérer une clé sans que le marchand ait à recopier quoi que ce soit. Le module ouvre une demande, affiche un lien, le marchand valide dans son navigateur, et le module reçoit sa clé et son secret à la lecture suivante.
Ils sont sans authentification, par construction. La sécurité ne repose pas sur une identité mais sur trois choses : la demande n'obtient rien tant qu'un marchand connecté ne l'a pas validée, le jeton de sondage ne quitte jamais le serveur de la boutique, et le secret n'est remis qu'une seule fois. Le pire qu'un appel malveillant puisse produire, c'est une demande en attente que personne n'approuvera — et qui expire en quinze minutes.
POST
/api/v1/pairing
Sans authentification
Ouvre une demande de raccordement et renvoie le lien de validation à présenter au marchand.
| Champ | Type | Obligatoire | Description |
shop_domain | chaîne | oui |
Domaine de la boutique, ex. boutique.exemple.fr. |
platform | chaîne | non |
prestashop, woocommerce, custom… unknown par défaut. |
shop_name | chaîne | non |
Nom lisible de la boutique, réutilisé à la création du compte. |
platform_version | chaîne | non |
Version de la plateforme, ex. 8.1.6. |
plugin_version | chaîne | non |
Version du module appelant. |
shop_uid | chaîne | non |
Identifiant unique tiré une fois à l'installation du module. Fortement recommandé en multiboutique : voir §4.3.
|
curl -X POST 'https://louis.guide/api/v1/pairing' \
-H 'Content-Type: application/json' \
--data-raw '{"shop_domain":"boutique.exemple.fr","platform":"prestashop","shop_name":"Tissu Fiesta","platform_version":"8.1.6","plugin_version":"1.2.0","shop_uid":"ps-7f3a91c4"}'
HTTP/1.1 201 Created
{
"code": "4K7M-9QR3",
"poll_token": "pt_8f2c1a7e5b2d48a6c3d9e0f1a2b3c4d5",
"expires_at": "2026-08-13T15:07:44+00:00",
"approve_url": "https://louis.guide/app/raccordement/4K7M-9QR3"
}
approve_url — à ouvrir dans un nouvel onglet du navigateur du marchand, hors de son back-office. C'est là qu'il se connecte ou crée son compte, puis valide.
poll_token — à conserver côté serveur uniquement. Il ne doit jamais apparaître dans une page ni dans une URL : c'est lui qui permettra de récupérer le secret.
code — affichable au marchand, pour qu'il vérifie qu'il valide bien la bonne demande.
Codes d'erreur
| Statut | Code | Cause |
| 422 | invalid_request | shop_domain absent ou inexploitable. |
| 429 | rate_limited | Plus de 10 ouvertures par heure et par IP. |
POST
/api/v1/pairing/{code}
Sans authentification
Interroge l'état de la demande, et remet la clé une fois — et une seule — que le marchand a validé.
En POST bien que ce soit une lecture, parce que l'appel a un effet de bord : il consomme le secret. En GET, un préchargeur de navigateur ou un antivirus qui suit les liens le consommerait à la place du module.
| Champ | Type | Obligatoire | Description |
poll_token | chaîne | oui |
Le jeton reçu à l'ouverture de la demande. |
curl -X POST 'https://louis.guide/api/v1/pairing/4K7M-9QR3' \
-H 'Content-Type: application/json' \
--data-raw '{"poll_token":"pt_8f2c1a7e5b2d48a6c3d9e0f1a2b3c4d5"}'
En attente de validation :
HTTP/1.1 200 OK
{ "status": "pending" }
Validé — les identifiants ne sont remis qu'à cet appel :
HTTP/1.1 200 OK
{
"status": "approved",
"api_key": "ak_live_5c2f81b0",
"secret": "sk_live_3f9c1a7e5b2d48a6",
"merchant": "tissufiesta"
}
Valeurs possibles de status
| Valeur | Signification | Que faire |
pending | Le marchand n'a pas encore tranché. | Continuer à sonder. |
approved | Validé. La réponse porte les identifiants. | Les enregistrer, arrêter le sondage. |
rejected | Le marchand a refusé. | Arrêter, le lui dire. |
expired | Quinze minutes écoulées sans décision. | Rouvrir une demande. |
consumed |
Le secret a déjà été remis, et il ne l'est jamais deux fois. Le module a perdu la réponse. |
Recommencer un raccordement — c'est le comportement sûr. |
unknown |
Code inconnu ou jeton faux. Volontairement indistincts : les séparer ferait de ce point d'entrée un oracle permettant de savoir quelles boutiques se raccordent. |
Vérifier le couple code / jeton. |
rate_limited | Trop de sondages (statut HTTP 429). | Espacer les appels. |
Enregistrez le secret immédiatement. Il n'est transmis qu'à cette réponse-là. Un module qui échoue à le persister devra faire recommencer tout le raccordement au marchand.
Toujours 200, y compris pour un état d'attente. Le module interroge en boucle : un code HTTP d'erreur sur une situation parfaitement normale ferait remonter des alertes pour rien. Sondez toutes les cinq secondes, la limite étant de 240 appels par quart d'heure et par IP — au-delà, la réponse est { "status": "rate_limited" } avec un statut 429.
4. API CMS (signée)
Le canal des intégrations serveur : modules e-commerce, ERP, CRM, outils internes. Toutes les URL sont préfixées par https://louis.guide.
Lectures : la clé suffit. Écritures : clé + signature. Le détail du calcul est au §2. Les fiches ci-dessous rappellent le régime de chacune par une pastille.
Ce que l'API ne permet pas, et ne permettra pas
Aucun endpoint ne modifie ni ne supprime un avis. Le marchand peut répondre publiquement et signaler pour modération, rien d'autre — exactement ce que permet son espace. Une API plus permissive que l'interface serait une porte dérobée dans la conformité, et c'est la première chose que vérifie un audit.
GET
/api/v1/cms/ping
Clé d'API
Vérifie qu'une clé fonctionne. C'est le premier appel à écrire, et celui à proposer au marchand sous forme d'un bouton « Tester la connexion » : il vaut mieux qu'il découvre une clé fautive à la configuration qu'à la première commande non transmise.
Sans signature, volontairement. Une lecture ne modifie rien, et surtout : ce point d'entrée doit rester utilisable pour prouver qu'une clé est bonne alors même que l'implémentation HMAC est encore fautive. Le ping passe, l'écriture non : le problème est dans la signature, pas dans la clé.
curl 'https://louis.guide/api/v1/cms/ping' \
-H 'X-Api-Key: ak_live_5c2f81b0'
HTTP/1.1 200 OK
{
"status": "ok",
"merchant": "tissufiesta",
"api_key": "ak_live_5c2f81b0",
"server_time": "2026-08-13T14:52:07+00:00"
}
server_time est renvoyé pour une raison précise : comparez-le à l'horloge de votre serveur. Un écart supérieur à 300 secondes fera échouer toutes vos écritures signées (§2.3), et c'est ici qu'on le voit avant d'y perdre une journée.
GET
/api/v1/cms/me
Clé d'API
État du compte : identité du marchand, capacités de la formule, quota, et adresses de la plateforme.
curl 'https://louis.guide/api/v1/cms/me' \
-H 'X-Api-Key: ak_live_5c2f81b0'
HTTP/1.1 200 OK
{
"merchant": {
"uuid": "0f1c2b3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
"slug": "tissufiesta",
"display_name": "Tissu Fiesta",
"country": "FR",
"default_locale": "fr",
"timezone": "Europe/Paris",
"status": "active"
},
"plan": {
"code": "pro",
"can_display_product_reviews": true,
"can_use_photos": true,
"can_use_reviews_api": true,
"can_remove_branding": true
},
"quota": {
"year": 2026,
"allowance": 5000,
"used": 1284,
"remaining": 3716,
"resets_at": "2027-01-01T00:00:00+00:00"
},
"urls": {
"api": "https://louis.guide",
"dashboard": "https://louis.guide/app",
"widget": "https://louis.guide/widget/v1/avis.js",
"profile": "https://louis.guide/m/tissufiesta"
},
"server_time": "2026-08-13T14:52:07+00:00"
}
Lisez les capacités, pas le nom de la formule
Le bloc plan expose des capacités (can_…) en plus du code de formule. Testez les premières : un module qui code en dur if (plan === 'pro') cessera d'être juste le jour où une formule s'ajoute ou change de nom, chez tous les marchands en même temps et sans qu'aucun ne puisse le corriger.
| Capacité | Ce qu'elle commande |
can_display_product_reviews |
Affichage des avis produit — les étoiles sur les fiches. |
can_use_photos |
Sortie des photos clients par l'API. Elles sont collectées dès la formule gratuite mais ne sont servies qu'en payant : chez un compte gratuit, la galerie répond une liste vide, jamais une erreur. |
can_use_reviews_api |
Lecture des avis par l'API, réponse aux avis, et accès MCP. La transmission des commandes, elle, n'est pas concernée : elle est incluse dans toutes les formules. |
can_remove_branding |
Retrait de la mention de la plateforme sur les widgets et emails. |
Le bloc urls évite de deviner
Votre intégration ne doit connaître qu'une seule adresse : celle de l'API. Les autres — espace marchand, script des widgets, page publique du marchand — sont renvoyées ici. Un module qui les recompose à partir d'une base unique suppose que tout vit sur le même hôte, ce qui cesse d'être vrai dès qu'un canal passe sur un sous-domaine, et produit des liens morts chez tous les marchands déjà installés.
quota.remaining mérite un affichage dans votre interface : à zéro, les commandes continuent d'être acceptées mais plus aucune sollicitation ne part. Prévenir à 90 % de consommation évite au marchand de le découvrir sur ses statistiques.
POST
/api/v1/cms/orders
Signature exigée
L'endpoint central. Il enregistre une commande et planifie la demande d'avis. Tout le reste de la plateforme découle de cet appel : sans lui, il n'y a ni sollicitation, ni avis, ni note.
Idempotent par external_order_id
Réémettre la même référence renvoie la commande déjà enregistrée avec un statut 200 au lieu de 201, sans créer de doublon et sans envoyer un second email au client. Le champ idempotent de la réponse vaut alors true. Vous pouvez donc réessayer sans précaution après une coupure réseau ou un délai d'attente dépassé — c'est le comportement à préférer à toute logique de déduplication maison.
Quand appeler
Au moment où l'expérience est vécue, pas commandée : à la livraison, à l'expédition selon votre métier, ou au passage au statut qui en tient lieu. C'est experienced_at qui porte cette date, et c'est elle qui fait démarrer le délai de sollicitation.
Corps de la requête
Racine
| Champ | Type | Oblig. | Description |
external_order_id | chaîne (100) | oui |
Référence de la commande chez vous. Clé d'idempotence et preuve d'achat conservée cinq ans (AFNOR §6.3). Doit être stable dans le temps. |
customer | objet | oui |
Identité du client à solliciter — voir le tableau suivant. |
experienced_at | ISO 8601 | oui |
Date de livraison ou de consommation, avec fuseau explicite. Voir l'encart ci-dessous : ce n'est pas la date de commande. |
source | objet | oui |
Contexte technique de l'émission — voir plus bas. |
items | tableau (200 max) | non |
Articles. Sans eux, aucun avis produit ne sera demandé — seul l'avis sur l'enseigne. |
amount | chaîne décimale | non |
Montant total, ex. "129.90". Jamais un flottant. |
currency | ISO 4217 | non |
"EUR", "CHF"… |
channel | énumération | non |
ecommerce_order (défaut), pos_transaction, qr_scan, manual, csv_import, nfc. |
location_id | chaîne (100) | non |
Établissement concerné, tel que déclaré par le marchand. Une valeur inconnue fait échouer la requête en 422 plutôt que de rattacher la commande au mauvais point de vente. |
solicitation_delay_days | entier 0–365 | non |
Délai propre à cette commande, qui surcharge le réglage du compte. Utile quand un même vendeur expédie un bouquet à solliciter demain et un matelas à solliciter dans un mois. Hors bornes, la valeur est ignorée et le réglage du compte s'applique — une valeur aberrante ne doit pas faire perdre une commande. |
order_status_id | chaîne (20) | non |
Statut de la commande chez vous au moment de l'envoi. Purement diagnostic — nous ne l'interprétons pas — mais c'est la seule information qui permette de répondre à « pourquoi cette commande n'a rien déclenché ». |
order_status_label | chaîne (120) | non |
Libellé lisible de ce statut. |
experienced_at : la date de livraison, pas celle de la commande
Un colis commandé le 1er et livré le 6 porte le 6. Ce n'est pas une subtilité : deux essais randomisés portant sur plus de 300 000 consommateurs (Jung, Ryu, Han & Cho, Journal of Marketing, 2023) établissent qu'une sollicitation envoyée avant que le client ait pu se forger un avis a un effet négatif sur le taux de dépôt. Ancrer le délai sur la date de commande revient à solliciter systématiquement trop tôt, de tout le délai de livraison.
C'est aussi l'une des trois dates affichées publiquement à côté de l'avis (AFNOR §6.3).
customer
| Champ | Type | Oblig. | Description |
email | email (255) | oui |
Seule donnée personnelle en clair que nous acceptons. Effacée après le délai de dépôt ; seule son empreinte subsiste. |
country | ISO 3166-1 alpha-2 | non* |
*Fortement recommandé. Google calcule ses notes marchand par pays et écarte les avis dont le pays est inconnu. Cette information n'existe qu'au moment de la commande : une fois l'adresse purgée, elle est définitivement irrécupérable, sans rattrapage possible. |
locale | fr, en, nl, de, it, es | non |
Langue de l'email de sollicitation. À défaut, la langue par défaut du marchand — solliciter un client néerlandophone en français fait chuter le taux de réponse. |
phone | chaîne (32) | non |
Mobile pour la sollicitation par SMS. Format international (+33612345678) fortement recommandé : c'est le seul non ambigu. Un numéro national est converti à partir de country ; sans pays connu, il est écarté sans faire échouer la commande. Voir l'avertissement ci-dessous. |
first_name, last_name | chaîne (100) | non |
Personnalisation de la sollicitation et nom affiché de l'auteur. |
company | chaîne (255) | non |
Raison sociale, pour une commande professionnelle. |
postal_code, city | chaîne | non |
Purgés en même temps que l'adresse email. |
N'envoyez le numéro de mobile que si le marchand a souscrit le SMS. Sans l'option, il est reçu et conservé sans qu'aucun message ne parte : une donnée personnelle collectée sans finalité, ce qu'aucune des deux parties ne peut justifier en cas de contrôle.
source — obligatoire
Ce bloc n'est pas de la statistique. Quand un marchand écrit « mes avis ne partent plus depuis la mise à jour », la réponse est déjà dedans : version de la plateforme, version du module, événement déclencheur. Le rendre optionnel reviendrait à ne l'avoir jamais — les intégrateurs remplissent ce qui est exigé, pas ce qui est suggéré.
| Champ | Type | Oblig. | Description |
platform | chaîne (50) | oui |
prestashop, woocommerce, shopify, magento, custom… |
platform_version | chaîne (30) | non |
Ex. 8.1.6. |
plugin_version | chaîne (30) | non |
Version de votre intégration. À incrémenter à chaque livraison. |
trigger | chaîne (100) | non |
Événement à l'origine de l'envoi, ex. woocommerce_order_status_completed. Permet de comprendre pourquoi une commande part trop tôt ou trop tard. |
shop_uid | chaîne (80) | non* |
*Décisif en multiboutique. Identifiant tiré une fois à l'installation et conservé. Voir l'encart. |
shop_id | chaîne (50) | non |
Identifiant de boutique chez la plateforme. Sert de repli quand shop_uid est absent. |
shop_name | chaîne (255) | non |
Nom lisible de cette boutique. Sans lui, le marchand découvre dans son espace un établissement appelé « 3 » et doit deviner lequel c'est. |
shop_group_id, lang_id | chaîne | non |
Conservés pour le diagnostic, jamais interprétés. lang_id ne sépare rien : la langue de l'avis vient de customer.locale. |
Multiboutique : shop_id ne suffit pas
Il vaut « 1 » sur toute installation mono-boutique. Un marchand qui exploite deux sites sous le même compte — une marque par domaine, cas courant — enverrait donc « 1 » depuis les deux : les deux boutiques se fondraient en un seul établissement, les avis de l'une s'afficheraient sur la page de l'autre, et le nom retenu serait celui de la dernière commande reçue. Défaut constaté en recette sur deux PrestaShop réels.
shop_uid règle le problème : tirez-le une fois à l'installation, conservez-le. Il survit à un changement de domaine comme à un renouvellement de clé — les deux autres discriminants auxquels on pense d'abord, et qui bougent l'un comme l'autre.
items[] — facultatif, 200 articles au maximum
| Champ | Type | Oblig. | Description |
external_product_id | chaîne (100) | oui |
Identifiant du produit dans votre catalogue. |
name | chaîne (255) | oui |
Nom du produit tel affiché au client. |
variant_id | chaîne (100) | non* |
*Le champ le plus important de cette liste. Sans lui, la chaise rouge et la chaise bleue partagent la même clé produit : leurs avis se mélangent et « le pied s'est cassé » ne désigne plus rien. Correspond à id_product_attribute (PrestaShop), à la variation (WooCommerce), au variant (Shopify). |
variant_label | chaîne (255) | non |
Libellé lisible : « Couleur : rouge, Taille : L ». |
gtin | 8 à 14 chiffres | non* |
EAN-13 ou UPC-A converti. Clé d'agrégation entre marchands, et exigence de Google pour afficher les étoiles dans ses résultats. |
upc, isbn, mpn | chaîne | non |
Tenus séparément du GTIN parce que les catalogues les tiennent dans des colonnes distinctes. L'ISBN est décisif sur le livre, où le GTIN est souvent vide. |
sku, brand | chaîne | non |
Référence interne et marque. |
category_id, category_name | chaîne | non |
Catégorie principale dans votre catalogue. |
product_url, image_url | URL (500) | non |
Utilisées dans l'email de sollicitation : un visuel de produit améliore nettement le taux de dépôt. |
images | liste d'URL (10 max) | non |
Visuels supplémentaires. |
description | chaîne (5000) | non |
Reçue, jamais réaffichée telle quelle : c'est votre texte, pas celui de l'auteur de l'avis. Elle sert à situer le produit en modération. |
tags | liste (30 max) | non |
Mots-clés du produit, 60 caractères chacun. |
meta_title, meta_description | chaîne | non |
Métadonnées de la fiche. |
quantity | entier > 0 | non |
1 par défaut. |
unit_price | chaîne décimale | non |
Ex. "19.90". En chaîne, comme tous les montants. |
Exemple complet
POST /api/v1/cms/orders
X-Api-Key: ak_live_5c2f81b0
X-Timestamp: 1786000000
X-Signature: sha256=…
Content-Type: application/json
{
"external_order_id": "CMD-1042",
"experienced_at": "2026-08-06T09:15:00+02:00",
"amount": "129.90",
"currency": "EUR",
"order_status_id": "5",
"order_status_label": "Livré",
"customer": {
"email": "claire.martin@exemple.fr",
"first_name": "Claire",
"last_name": "Martin",
"locale": "fr",
"country": "FR",
"postal_code": "69003",
"city": "Lyon"
},
"items": [
{
"external_product_id": "REF-42",
"variant_id": "REF-42-ROUGE-L",
"variant_label": "Couleur : rouge, Taille : L",
"name": "Nappe en lin lavé",
"sku": "NAP-LIN-42",
"gtin": "3401579874120",
"brand": "Tissu Fiesta",
"quantity": 2,
"unit_price": "64.95",
"product_url": "https://boutique.exemple.fr/nappe-lin-42",
"image_url": "https://boutique.exemple.fr/img/nappe-42.jpg"
}
],
"source": {
"platform": "prestashop",
"platform_version": "8.1.6",
"plugin_version": "1.2.0",
"trigger": "actionOrderHistoryAddAfter",
"shop_uid": "ps-7f3a91c4",
"shop_id": "1",
"shop_name": "Tissu Fiesta"
}
}
Commande enregistrée :
HTTP/1.1 201 Created
{
"uuid": "3b8e1f42-6c7d-4e9a-91b2-5d0c3a7f4e18",
"external_order_id": "CMD-1042",
"status": "pending",
"channel": "ecommerce_order",
"experienced_at": "2026-08-06T07:15:00+00:00",
"imported_at": "2026-08-06T09:41:22+00:00",
"items_count": 1,
"idempotent": false
}
Même requête rejouée :
HTTP/1.1 200 OK
{ "…": "…", "idempotent": true }
L'adresse email n'est jamais renvoyée, même si vous venez de la transmettre : toute donnée renvoyée est une donnée qui peut fuiter dans vos propres journaux.
Valeurs possibles de status
| Valeur | Signification |
pending | Reçue, en attente de planification. |
scheduled | Sollicitation programmée. |
solicited | Demande d'avis envoyée au client. |
reviewed | Le client a déposé son avis. |
cancelled | Annulée avant envoi. |
expired | Délai de dépôt écoulé sans avis. |
Erreurs
Cet endpoint est le seul servi par API Platform : ses erreurs de validation arrivent donc sous forme de liste de violations, et non dans l'enveloppe { "error": … } du reste de l'API. Votre code doit accepter les deux formes.
HTTP/1.1 422 Unprocessable Content
{
"status": 422,
"detail": "customer.email: \"claire.martin\" n'est pas une adresse email valide.",
"violations": [
{
"propertyPath": "customer.email",
"message": "\"claire.martin\" n'est pas une adresse email valide."
}
]
}
| Statut | Cause | À faire |
| 401 |
Clé absente, invalide, ou signature refusée. |
Voir §2. |
| 422 |
Un champ manque ou est mal formé — voir violations. |
Corriger le champ désigné par propertyPath. |
| 422 |
experienced_at est dans le futur (au-delà d'un jour de marge). |
Vérifier le fuseau horaire du serveur : c'est presque toujours de là que vient l'écart. |
| 422 |
experienced_at remonte à plus de 90 jours. |
Voir l'encart ci-dessous. Pour reprendre un historique, contactez le support. |
| 422 |
Aucun établissement ne correspond à location_id. |
Créer l'établissement dans l'espace marchand, ou omettre le champ. |
| 415 |
En-tête Content-Type absent ou inattendu. |
Envoyer Content-Type: application/json. |
Pourquoi les commandes de plus de 90 jours sont refusées
Le scénario de sinistre est connu : un module s'installe et pousse trois ans d'historique d'un coup. Des milliers d'invitations partent vers des adresses périmées, le taux de rejet explose — et comme tous les emails partent de notre domaine, c'est la délivrabilité de tous les marchands qui s'effondre, pas seulement celle du nouveau venu.
Le refus est prononcé à l'entrée, avec un message explicite, plutôt qu'à la planification : l'intégrateur comprend immédiatement au lieu de voir ses commandes disparaître en silence.
GET
/api/v1/cms/reviews
Clé d'API
Formule payante
Liste les avis du marchand, du plus récent au plus ancien, avec la réponse publiée et le signalement éventuel de chacun. C'est cet endpoint qui permet de rapatrier les avis dans un ERP, un CRM ou un outil de service client.
| Paramètre | Défaut | Description |
type | merchant |
merchant pour les avis sur l'enseigne, product pour les avis produit. |
status | tous |
published, pending, awaiting_email, rejected, disputed, withdrawn. Une valeur inconnue est ignorée — le filtre ne s'applique alors pas, plutôt que de renvoyer une erreur. |
page | 1 | Numéro de page. |
per_page | 25 |
De 1 à 100. Au-delà, la valeur est ramenée à 100. |
curl 'https://louis.guide/api/v1/cms/reviews?status=published&per_page=50' \
-H 'X-Api-Key: ak_live_5c2f81b0'
HTTP/1.1 200 OK
{
"reviews": [
{
"id": "9f1c2b3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
"score": 4,
"title": "Livraison rapide",
"comment": "Nappe conforme, le lin est épais. Un pli à la livraison.",
"author": "Claire M.",
"language": "fr",
"country": "FR",
"status": "published",
"verified_purchase": true,
"experienced_at": "2026-08-06T07:15:00+00:00",
"submitted_at": "2026-08-13T18:02:41+00:00",
"published_at": "2026-08-13T18:04:10+00:00",
"order_reference": "CMD-1042",
"response": {
"content": "Merci Claire, nous notons pour l'emballage.",
"created_at": "2026-08-14T08:11:00+00:00",
"updated_at": null
},
"report": null
}
],
"page": 1,
"per_page": 50,
"total": 318
}
Les trois dates, et pourquoi elles sont trois
experienced_at (l'expérience vécue), submitted_at (le dépôt) et published_at (la mise en ligne) sont trois choses différentes, et l'AFNOR impose de pouvoir les distinguer. Un intégrateur qui les confond affiche « il y a 3 jours » sur une expérience vieille de trois semaines. published_at vaut null tant que l'avis n'est pas publié.
order_reference reprend votre external_order_id : c'est lui qui rattache l'avis à la commande dans votre système. Il vaut null pour un avis déposé hors sollicitation.
Synchronisation incrémentale
Interrogez avec status=published et comparez published_at au dernier passage : rapatrier tout l'historique à chaque exécution fonctionne les premiers mois, puis devient une requête de plusieurs milliers de lignes toutes les heures. La pagination commence à 1 et le champ total donne le nombre d'avis correspondant au filtre, pas le nombre de pages.
| Statut | Code | Cause |
| 402 | plan_required |
La formule du marchand n'inclut pas l'API avis. Les avis restent lisibles sans clé par l'API publique — ce n'est pas la même chose : celle-ci sert l'affichage public, pas l'export. |
POST
/api/v1/cms/reviews/{uuid}/response
Signature exigée
Formule payante
Publie une réponse publique à un avis sur l'enseigne, ou met à jour celle qui existe déjà. Un avis ne porte qu'une seule réponse : réémettre remplace le texte.
| Champ | Type | Oblig. | Description |
content | chaîne | oui |
Texte de la réponse. Tronqué à 3000 caractères sans erreur — vérifiez la longueur de votre côté si la coupe vous gêne. |
curl -X POST 'https://louis.guide/api/v1/cms/reviews/9f1c2b3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d/response' \
-H 'X-Api-Key: ak_live_5c2f81b0' \
-H 'X-Timestamp: 1786000000' \
-H 'X-Signature: sha256=…' \
-H 'Content-Type: application/json' \
--data-raw '{"content":"Merci Claire, nous notons pour l’emballage."}'
HTTP/1.1 201 Created
{
"review_id": "9f1c2b3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
"response": {
"content": "Merci Claire, nous notons pour l'emballage.",
"created": true,
"status": "published",
"published": true
}
}
Lisez published avant d'annoncer quoi que ce soit
Le marchand règle dans son espace si les réponses écrites par un programme partent directement ou attendent sa relecture. Ce réglage vit sur le compte et n'est pas un paramètre de la requête : si l'appelant pouvait choisir lui-même s'il doit être relu, la garantie ne vaudrait rien.
Conséquence pour votre interface : une réponse acceptée n'est pas forcément visible. published: false signifie « enregistrée en brouillon, à valider dans l'espace marchand » — dites-le, plutôt que d'afficher un « publié » qui sera démenti par la page publique.
201 à la création, 200 à la mise à jour ; le champ created reprend la même information dans le corps. Une réponse déjà publiée le reste : une mise à jour ne la repasse jamais en brouillon, ce qui la ferait disparaître de la page sans que personne ne l'ait décidé.
| Statut | Code | Cause |
| 402 | plan_required | Formule sans réponse aux avis. |
| 404 | review_not_found |
Identifiant inconnu, mal formé, ou appartenant à un autre marchand — les trois cas sont indistinguables, et c'est le cloisonnement qui l'impose. |
| 422 | content_required | content absent ou vide. |
POST
/api/v1/cms/reviews/{uuid}/report
Signature exigée
Signale un avis pour modération. L'avis passe au statut « contesté » et le dossier entre dans la file d'instruction.
| Champ | Type | Oblig. | Description |
reason | énumération | oui |
Motif, à choisir dans la liste ci-dessous. |
detail | chaîne | non |
Précisions pour le modérateur, tronquées à 1000 caractères. C'est ici qu'on écrit « commande n° X, jamais livrée à cette adresse » — un signalement motivé est instruit plus vite. |
Motifs recevables
| Valeur | Quand l'invoquer |
inappropriate_content | Injure, propos haineux, contenu illicite. |
spam_or_advertising | Publicité, lien commercial, contenu automatisé. |
off_topic | Sans rapport avec l'expérience vécue — le transporteur, la météo. |
conflict_of_interest | Concurrent, ancien salarié, avis rémunéré. |
personal_data_disclosure | L'avis expose des données personnelles. |
Une note basse n'est pas un motif
Aucun motif ne permet de contester un avis en raison de sa note, et ce n'est pas un oubli : c'est l'interdit qui fait la différence entre une plateforme d'avis et une vitrine. Un signalement mal motivé est rejeté, et l'avis reste en ligne.
HTTP/1.1 202 Accepted
{
"review_id": "9f1c2b3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
"report": {
"reason": "off_topic",
"status": "pending",
"review_remains_visible": true
}
}
review_remains_visible vaut toujours true, et le champ existe pour qu'aucune interface ne soit conçue en supposant le contraire : l'avis reste public pendant toute l'instruction. Le retirer sur simple signalement reviendrait à laisser le marchand décoter ce qui lui déplaît — Google l'interdit explicitement, l'AFNOR aussi. La réponse est un 202 : la demande est enregistrée, pas tranchée.
| Statut | Code | Cause |
| 404 | review_not_found | Identifiant inconnu, mal formé, ou hors de votre compte. |
| 409 | already_reported | Un signalement est déjà ouvert sur cet avis. |
| 422 | invalid_reason |
Motif absent ou hors liste. Le message rappelle les valeurs admises. |
5. API publique
Lecture seule, sans authentification, sur /api/v1/public/. C'est ce que consomment les widgets, et ce que peut consommer n'importe quel affichage sur mesure.
Le {slug} des chemins est l'identifiant public du marchand — celui de sa page publique, visible dans urls.profile renvoyé par /cms/me.
Ce qui protège une API sans clé
Il n'y a pas d'identité à vérifier : ce code s'exécute chez les visiteurs d'une boutique, aucun secret ne peut y vivre. La protection tient donc à trois autres choses, qu'il faut connaître avant d'intégrer.
-
Aucune donnée sensible ne sort d'ici. Ni email, ni empreinte d'email, ni référence de commande, ni identifiant interne. Un widget affiche des avis publics ; tout ce qui sort par ce canal est lisible par n'importe qui.
-
Débit limité à 60 requêtes par minute et par adresse IP, en fenêtre glissante. Une fiche produit fait deux appels (note + avis) : cela laisse 30 chargements par minute depuis une même adresse — large pour un visiteur, étroit pour un aspirateur de contenu. Voir §9.
-
CORS restreint aux domaines déclarés du marchand. Un joker
* autoriserait n'importe quel site — concurrent, comparateur, contrefacteur — à afficher les avis de n'importe quel marchand comme s'ils étaient les siens.
CORS : ce qu'il faut déclarer pour que le navigateur accepte la réponse
L'en-tête Access-Control-Allow-Origin n'est posé que si l'origine appelante correspond à un domaine raccordé au marchand désigné dans l'URL. Les sous-domaines sont acceptés : un domaine déclaré exemple.fr autorise www.exemple.fr et boutique.exemple.fr.
Symptôme typique d'un domaine non déclaré : la requête part, le serveur répond 200, et le navigateur bloque la lecture en console. Le remède est dans l'espace marchand, pas dans le code.
Un appel serveur à serveur n'est pas concerné : sans en-tête Origin, il n'y a pas de contrôle CORS. C'est la limitation de débit qui couvre ce cas. Le CORS protège le navigateur d'un autre site, jamais la donnée elle-même.
Cache
Toutes les réponses sont publiques et mises en cache : 60 secondes pour les avis et les notes, 300 secondes pour les réglages d'affichage. C'est ce qui permet d'absorber le trafic d'une boutique en promotion sans dimensionner pour le pic. Ne construisez pas un affichage qui suppose l'apparition instantanée d'un avis publié.
GET
/api/v1/public/merchants/{slug}/score
Sans authentification
Note globale de la boutique et répartition par note.
curl 'https://louis.guide/api/v1/public/merchants/tissufiesta/score'
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
{
"merchant": { "slug": "tissufiesta", "name": "Tissu Fiesta" },
"score": {
"average": 4.6,
"count": 318,
"distribution": { "1": 4, "2": 6, "3": 18, "4": 91, "5": 199 },
"scale": { "min": 1, "max": 5 }
}
}
scale est renvoyé explicitement plutôt que sous-entendu : un intégrateur qui code « sur 10 » parce que son précédent prestataire l'était produit un affichage faux que personne ne relit. count ne compte que les avis publiquement visibles.
404 merchant_not_found si le slug est inconnu.
GET
/api/v1/public/merchants/{slug}/reviews
Sans authentification
Avis sur l'enseigne, du plus récent au plus ancien.
| Paramètre | Défaut | Description |
page | 1 | Numéro de page. |
per_page | 10 |
De 1 à 50. Au-delà, ramené à 50. |
HTTP/1.1 200 OK
{
"reviews": [
{
"id": "9f1c2b3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
"author": "Claire M.",
"score": 4,
"title": "Livraison rapide",
"comment": "Nappe conforme, le lin est épais. Un pli à la livraison.",
"language": "fr",
"verified_purchase": true,
"experienced_at": "2026-08-06",
"submitted_at": "2026-08-13",
"published_at": "2026-08-13",
"disputed": false,
"photos": [
{
"id": "6d2a1f80-9b3c-4d5e-8f70-1a2b3c4d5e6f",
"url": "https://louis.guide/photos/6d2a1f80-9b3c-4d5e-8f70-1a2b3c4d5e6f",
"thumbnail_url": "https://louis.guide/photos/6d2a1f80-9b3c-4d5e-8f70-1a2b3c4d5e6f/thumb",
"width": 1600,
"height": 1200
}
],
"reply": {
"content": "Merci Claire, nous notons pour l'emballage.",
"published_at": "2026-08-14"
}
}
],
"page": 1,
"per_page": 10
}
L'ordre chronologique est imposé, pas choisi
Il n'y a pas de paramètre de tri, et il n'y en aura pas : l'AFNOR (§6.3) exige l'ordre chronologique inverse comme affichage par défaut. Proposer « les mieux notés d'abord » comme tri initial serait une présentation orientée. Un tri en JavaScript sur la page reçue relève de votre responsabilité, pas de la nôtre.
Ce que votre affichage doit reprendre
-
Deux dates au minimum — celle de l'expérience et celle de la publication. C'est une obligation d'affichage, et seule l'API peut vous les fournir. Les dates publiques sont réduites au jour (
2026-08-06) : aucun widget n'affiche l'heure.
verified_purchase — l'avis est rattaché à une commande réelle. C'est ce qui distingue un avis collecté d'un avis déposé spontanément.
-
disputed — l'avis est contesté et son instruction est en cours. Il reste affiché (voir §4.6) ; signalez-le plutôt que de le masquer.
reply — la réponse du marchand fait partie de l'avis pour le lecteur. published_at y porte la date de dernière modification quand il y en a eu une : afficher la date d'origine sous un texte réécrit induirait en erreur.
photos — vide chez un marchand dont la formule ne les sert pas. Les avis restent complets, seules les images manquent.
Il n'y a pas de champ total sur ce canal : une page vide signifie qu'il n'y a plus rien à charger. C'est ce que fait le bouton « voir plus » du widget.
GET
/api/v1/public/products/{slug}/{productId}/score
Sans authentification
Note d'un produit. {productId} est votre identifiant de catalogue, celui transmis en external_product_id — nous ne le réécrivons jamais. Pensez à l'encoder s'il contient des caractères réservés.
| Paramètre | Défaut | Description |
variant | — |
Restreint la note à une déclinaison. Absent, la note porte sur toutes les variantes confondues — ce qui est le bon comportement tant que le visiteur n'a pas choisi sa taille. |
with_variants | false |
Ajoute le détail par déclinaison. Coûte une requête de plus : ne l'activez pas sur la fiche produit, qui est la page la plus vue de la boutique. |
curl 'https://louis.guide/api/v1/public/products/tissufiesta/REF-42/score?with_variants=true'
HTTP/1.1 200 OK
{
"product": { "external_id": "REF-42", "variant_id": null },
"score": {
"average": 4.4,
"count": 27,
"distribution": { "1": 0, "2": 1, "3": 3, "4": 7, "5": 16 },
"scale": { "min": 1, "max": 5 }
},
"variants": [
{ "variant_id": "REF-42-ROUGE-L", "average": 4.8, "count": 12 },
{ "variant_id": "REF-42-BLEU-M", "average": 4.1, "count": 15 }
]
}
variants vaut null quand with_variants n'est pas demandé — c'est une absence de calcul, pas une absence de déclinaisons.
GET
/api/v1/public/products/{slug}/{productId}/reviews
Sans authentification
Avis d'un produit. Même structure de réponse et mêmes paramètres de pagination que les avis d'enseigne, avec en plus le filtre variant — utile quand un sélecteur de taille veut n'afficher que les avis de la variante choisie.
curl 'https://louis.guide/api/v1/public/products/tissufiesta/REF-42/reviews?variant=REF-42-ROUGE-L&per_page=5'
Chez un marchand dont la formule n'inclut pas l'affichage des avis produit, prévoyez un affichage qui se réduit proprement plutôt qu'un cadre vide : le widget, lui, disparaît de la page.
GET
/api/v1/public/merchants/{slug}/photos
Sans authentification
Photos clients approuvées, sans le texte des avis. C'est ce qui alimente un carrousel : sans cet endpoint, il faudrait charger cinquante avis complets — texte, dates, notes — pour n'en garder que les images, sur une fiche produit qui charge déjà le thème du marchand.
| Paramètre | Défaut | Description |
produit | — |
Restreint à un produit. Absent, renvoie les photos de toute la boutique — ce qui alimente un carrousel de page d'accueil. |
limite | 24 |
De 1 à 50. |
HTTP/1.1 200 OK
{
"photos": [
{
"id": "6d2a1f80-9b3c-4d5e-8f70-1a2b3c4d5e6f",
"url": "https://louis.guide/photos/6d2a1f80-9b3c-4d5e-8f70-1a2b3c4d5e6f",
"thumbnail_url": "https://louis.guide/photos/6d2a1f80-9b3c-4d5e-8f70-1a2b3c4d5e6f/thumb",
"width": 1600,
"height": 1200
}
]
}
Les URL sont absolues : ce JSON est lu par du JavaScript exécuté sur le domaine de la boutique, où une URL relative pointerait vers la boutique elle-même. Servez-vous de width et height pour réserver la place avant chargement — sans quoi la fiche produit sautera sous les yeux du visiteur.
Chez un marchand dont la formule ne sert pas les photos, la réponse est { "photos": [] } avec un statut 200, jamais une erreur : le carrousel disparaît proprement au lieu d'afficher un cadre en échec.
GET
/api/v1/public/merchants/{slug}/display
Sans authentification
Réglages d'affichage décidés par le marchand dans son espace. C'est ce qui permet de poser les balises une fois pour toutes dans un thème, puis d'allumer, éteindre ou déplacer un élément sans retoucher le code de la boutique.
HTTP/1.1 200 OK
Cache-Control: public, max-age=300
{
"merchant": {
"slug": "tissufiesta",
"name": "Tissu Fiesta",
"accent_color": "#7c3aed"
},
"display": {
"badge_flottant": false,
"badge_cote": "droite",
"badge_decalage": 16,
"seuil_avis": 1,
"etoiles_fiche": true,
"etoiles_vignettes": true,
"onglet_avis": true,
"bloc_accueil": true
},
"profile_url": "https://louis.guide/m/tissufiesta"
}
| Réglage | Défaut | Signification |
badge_flottant | false |
Pastille de note fixée dans un coin de l'écran. Éteinte par défaut : elle se superpose à la page du marchand, et rien ne doit apparaître chez lui sans qu'il l'ait demandé. |
badge_cote | droite | droite ou gauche. |
badge_decalage | 16 | Décalage en pixels par rapport au bord. |
seuil_avis | 1 |
Nombre d'avis en dessous duquel l'affichage disparaît. Voir §6 : afficher « aucun avis » est pire que ne rien afficher. |
etoiles_fiche | true | Étoiles sur la fiche produit. |
etoiles_vignettes | true | Étoiles sur les vignettes de listing. |
onglet_avis | true | Onglet « Avis » de la fiche produit. |
bloc_accueil | true | Bloc d'avis en page d'accueil. |
display est toujours complet, défauts inclus : votre code n'a pas à connaître nos valeurs par défaut, ni à les recopier — le jour où l'une change, il suit.
accent_color vaut null quand le marchand n'a pas choisi de couleur ou quand sa formule ne l'autorise plus. Prévoyez toujours une couleur de repli de votre côté : c'est ce que fait le widget, dont la teinte n'existe que si elle est déclarée.
GET
/api/v1/public/health
Sans authentification
Disponibilité du service. À interroger par une sonde ou par le contrôle de santé d'un module.
HTTP/1.1 200 OK
{ "status": "ok" }
Volontairement minimal : aucun accès base, aucune dépendance externe. Une lenteur de la base ne doit pas déclencher une fausse alerte d'indisponibilité — et inversement, ce point d'entrée ne dit rien de l'état de la base. Pour vérifier qu'une clé fonctionne, c'est /cms/ping qu'il faut appeler.
6. Widgets d'affichage
Quatre éléments HTML à poser dans un thème. Un seul script à charger, aucune dépendance, aucune configuration : l'adresse de l'API est déduite de l'URL du script lui-même.
<script src="https://louis.guide/widget/v1/avis.js" async></script>
<avis-score marchand="tissufiesta"></avis-score>
<avis-liste marchand="tissufiesta" par-page="5"></avis-liste>
Le script est chargé une fois par page, où l'on veut ; les éléments peuvent être posés avant lui. Il est servi sous /widget/v1/ : une évolution incompatible partira en /v2/ et celui-ci restera servi tel quel — il vit dans des thèmes que personne ne mettra à jour.
Les quatre éléments
| Élément | Ce qu'il affiche | Où le poser |
<avis-score> |
Note moyenne, étoiles, nombre d'avis. |
Fiche produit, en-tête de boutique, page « à propos ». |
<avis-liste> |
Avis paginés, avec photos et réponses du marchand. |
Onglet « Avis » d'une fiche produit, page dédiée. |
<avis-carrousel> |
Photos clients seules, cliquables. |
Fiche produit, page d'accueil. |
<avis-flottant> |
Pastille de note fixée dans un coin, cliquable. |
Le gabarit commun, une seule fois pour tout le site. |
Attributs
| Attribut | Éléments | Défaut | Rôle |
marchand | tous | — |
Obligatoire. Identifiant public du marchand, celui de sa page publique. |
produit |
score, liste, carrousel | — |
Votre identifiant de catalogue (external_product_id). Absent, l'élément porte sur toute la boutique. |
langue | tous | lang de la page |
Langue des libellés. À défaut, l'attribut lang du document — que le thème renseigne déjà — puis le français. Seuls fr et en sont réellement traduits ; toute autre valeur retombe sur le français plutôt que d'afficher des libellés à moitié traduits. |
mini | score | 1 |
Nombre d'avis en dessous duquel l'élément disparaît. À 3, une fiche qui n'a que deux avis n'affiche rien plutôt qu'une note fondée sur presque rien. |
par-page | liste | 5 |
Avis chargés à la fois ; un bouton « Voir plus » charge la suite. |
max | carrousel | 12 |
Nombre de photos, 50 au maximum. |
Exemple complet sur une fiche produit
<!-- Sous le titre du produit -->
<avis-score marchand="tissufiesta" produit="REF-42" mini="3"></avis-score>
<!-- Photos des acheteurs, sous la galerie du catalogue -->
<avis-carrousel marchand="tissufiesta" produit="REF-42" max="8"></avis-carrousel>
<!-- Dans l'onglet « Avis » -->
<avis-liste marchand="tissufiesta" produit="REF-42" par-page="10"></avis-liste>
<!-- Une seule fois, dans le gabarit commun -->
<avis-flottant marchand="tissufiesta"></avis-flottant>
Un widget qui n'affiche rien n'est pas forcément en panne
Trois situations font disparaître un élément, et c'est à chaque fois voulu : aucun avis (ou moins que le seuil), aucune photo pour le carrousel, et toute erreur réseau ou serveur.
Afficher « Aucun avis pour le moment » sur une fiche produit est pire que ne rien afficher : le visiteur en conclut que personne n'a commandé. Et un bandeau d'erreur sur la boutique d'un marchand parce que notre API tousse serait indéfendable — l'élément se retire de la mise en page, la fiche produit reste intacte.
Conséquence pratique pour l'intégrateur : ne construisez pas de mise en page qui réserve une hauteur fixe pour un widget. Il peut ne rien occuper du tout.
Ce que le marchand pilote sans vous
Les éléments lisent /display au chargement. Deux réglages leur viennent de là plutôt que d'un attribut, et c'est délibéré : le marchand peut poser ses balises une fois pour toutes, puis changer d'avis depuis son espace sans rouvrir son thème.
-
La pastille flottante — allumée ou éteinte, à droite ou à gauche, avec son décalage.
<avis-flottant> posé dans le gabarit n'affiche rien tant que le marchand ne l'a pas activée. Elle est éteinte par défaut : rien ne doit apparaître chez lui sans qu'il l'ait demandé.
-
La couleur de marque — appliquée aux surfaces qui peuvent l'être. Les étoiles gardent leur ambre, le vert d'« Achat vérifié » et l'ambre de « Contesté » aussi : ces couleurs portent un sens, elles ne décorent pas, et les repeindre rendrait la note illisible chez un marchand dont la marque est jaune pâle ou blanche.
Les réglages ne sont demandés qu'une fois par page, même avec quatre éléments : la requête en vol est partagée. C'est ce qui évite au widget d'être le script qui ralentit la fiche produit — reproche fondé qu'on peut faire à la plupart des modules d'avis.
Isolation vis-à-vis du thème
Chaque élément rend son contenu dans un shadow DOM : le CSS du thème ne déborde pas sur le widget, et celui du widget ne déborde pas sur la boutique. Aucun des deux ne serait acceptable dans l'autre sens.
Corollaire à connaître avant d'essayer : vos règles CSS n'atteindront pas l'intérieur des widgets. La seule personnalisation prévue est la couleur de marque, réglée dans l'espace marchand. Un affichage réellement sur mesure passe par l'API publique — c'est exactement ce pour quoi elle est documentée.
Avant de coller quoi que ce soit
Sous PrestaShop et WooCommerce, le module pose ces balises lui-même, au bon endroit du thème. Le collage manuel s'adresse aux autres plateformes et aux thèmes sur mesure — voir les modules.
7. Serveur MCP
MCP (Model Context Protocol) expose les mêmes capacités que l'API, sous une forme qu'un assistant IA peut découvrir seul. Là où un développeur lit une documentation, écrit l'authentification et interprète le JSON, l'assistant demande la liste des outils, lit leurs descriptions et les appelle.
Concrètement : le marchand branche son assistant sur ce serveur, puis écrit « quels avis n'ont pas encore de réponse ? » ou « réponds à celui-ci en t'excusant pour le délai ». Personne n'a écrit de code d'intégration.
| Valeur |
| Adresse | https://louis.guide/api/v1/mcp |
| Transport | JSON-RPC 2.0 sur HTTP, en POST |
| Version du protocole | 2024-11-05 |
| Serveur annoncé | avis-clients, version 1.0.0 |
| Capacités | tools — ni ressources, ni invites |
| Authentification |
Jeton porteur (§7.1) ou clé d'API + signature HMAC (§7.2) |
| Formule | Payante — sinon erreur JSON-RPC -32001 |
7.1 Brancher un assistant : le jeton
C'est la voie normale, et la seule qui ne demande rien d'installé. Le marchand crée un jeton dans son espace — Réglages · Collecte, section « Brancher un assistant » —, puis le colle dans la configuration de son assistant avec l'adresse du serveur.
POST https://louis.guide/api/v1/mcp
Authorization: Bearer mcp_live_…
Content-Type: application/json
Forme usuelle des fichiers de configuration d'un client MCP :
{
"mcpServers": {
"avis-clients": {
"url": "https://louis.guide/api/v1/mcp",
"headers": { "Authorization": "Bearer mcp_live_…" }
}
}
}
Vérification en une commande, avant de brancher quoi que ce soit :
curl -X POST 'https://louis.guide/api/v1/mcp' \
-H 'Authorization: Bearer mcp_live_…' \
-H 'Content-Type: application/json' \
--data-raw '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Ce que le jeton peut, et ce qu'il ne peut pas
| Propriété | Comportement |
| Portée |
Uniquement /api/v1/mcp. Présenté sur l'API CMS, il n'est même pas examiné : ni transmission de commandes, ni export d'avis, ni raccordement. |
| Écriture |
Interdite par défaut. Le marchand coche explicitement « autoriser la rédaction de réponses » à la création. Sans elle, l'outil repondre_a_un_avis n'apparaît même pas dans tools/list — l'assistant ne le proposera donc pas. |
| Durée de vie |
Un an, puis il cesse de valoir. Il se recrée en dix secondes. |
| Révocation |
Immédiate et définitive, jeton par jeton, sans toucher aux clés d'API ni aux modules du marchand. |
| Conservation |
Affiché une seule fois. Nous n'en gardons qu'une empreinte : personne ne peut le réafficher, nous compris. |
| Nombre |
Trois jetons valides au maximum par compte. |
Un jeton porteur voyage : traitez-le comme un mot de passe
Contrairement au secret HMAC, il part à chaque requête et vit dans la configuration d'un service que nous ne contrôlons pas. C'est le prix du branchement direct, et c'est pourquoi il est cloisonné, expirant, révocable et muet en écriture par défaut. Ne le mettez jamais dans une URL ni dans un dépôt de code : les URL finissent dans les journaux de tous les intermédiaires traversés.
Les tentatives sont bornées à 20 échecs par quart d'heure et par adresse IP — au-delà, la réponse est un 429.
7.2 Alternative : clé d'API et signature HMAC
Le même point d'entrée accepte l'authentification décrite au §2 : clé d'API et signature HMAC. Elle a un avantage réel — le secret ne quitte jamais le serveur du marchand — et un inconvénient qui la réserve aux intégrateurs : aucun client MCP ne sait recalculer un HMAC à chaque appel, ils ne posent que des en-têtes fixes.
Il faut donc un pont : un petit programme lancé par l'assistant, qui reçoit le JSON-RPC sur son entrée standard, le signe, l'envoie et rend la réponse. Node.js 18 ou plus récent, aucune dépendance. Enregistrez-le sous pont-mcp.js.
#!/usr/bin/env node
'use strict';
// Pont MCP : entrée standard (JSON-RPC) → API signée → sortie standard.
const crypto = require('crypto');
const readline = require('readline');
const BASE = process.env.LG_API_URL;
const CLE = process.env.LG_API_KEY;
const SECRET = process.env.LG_API_SECRET;
const CHEMIN = '/api/v1/mcp';
readline.createInterface({ input: process.stdin }).on('line', async (ligne) => {
const corps = ligne.trim();
if (!corps) return;
let requete;
try { requete = JSON.parse(corps); } catch (e) { return; }
// Une notification n'a pas d'identifiant, et n'attend AUCUNE réponse :
// en écrire une romprait le protocole côté client.
const attendUneReponse = requete.id !== undefined && requete.id !== null;
const ts = Math.floor(Date.now() / 1000).toString();
// On signe la chaîne qu'on envoie, pas un objet réencodé : réencoder
// produirait tôt ou tard un échappement différent, donc un 401 inexplicable.
const signature = crypto.createHmac('sha256', SECRET)
.update(ts + 'POST' + CHEMIN + corps)
.digest('hex');
try {
const reponse = await fetch(BASE + CHEMIN, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': CLE,
'X-Timestamp': ts,
'X-Signature': 'sha256=' + signature,
},
body: corps,
});
const texte = await reponse.text();
if (attendUneReponse) process.stdout.write(texte + '\n');
} catch (erreur) {
if (attendUneReponse) {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
id: requete.id,
error: { code: -32603, message: 'Pont MCP : ' + erreur.message },
}) + '\n');
}
}
});
Déclaration côté client MCP (forme usuelle des fichiers de configuration) :
{
"mcpServers": {
"avis-clients": {
"command": "node",
"args": ["/chemin/absolu/vers/pont-mcp.js"],
"env": {
"LG_API_URL": "https://louis.guide",
"LG_API_KEY": "ak_live_5c2f81b0",
"LG_API_SECRET": "sk_live_3f9c1a7e5b2d48a6"
}
}
}
}
Le secret ne sort pas de la machine. Il sert à signer localement ; ce qui part sur le réseau, c'est la signature. Un chemin absolu est indispensable : l'assistant ne lance pas le programme depuis le dossier où vous l'avez écrit.
Pour vérifier le pont avant de brancher quoi que ce soit, envoyez-lui une ligne à la main. Une liste d'outils doit revenir :
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
| LG_API_URL='https://louis.guide' LG_API_KEY='ak_live_…' LG_API_SECRET='sk_live_…' node pont-mcp.js
7.3 Méthodes
| Méthode | Effet |
initialize |
Annonce la version du protocole, les capacités et l'identité du serveur. |
tools/list | Catalogue des outils et de leurs schémas d'entrée. |
tools/call | Exécute un outil — params.name et params.arguments. |
notifications/initialized, ping | Acquittés par un résultat vide. |
POST /api/v1/mcp
{"jsonrpc":"2.0","id":1,"method":"initialize"}
HTTP/1.1 200 OK
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": { "tools": {} },
"serverInfo": { "name": "avis-clients", "version": "1.0.0" }
}
}
7.4 Les trois outils
Deux lisent, un écrit. La ligne de partage n'est pas technique : lire des avis ne présente aucun risque, tandis que publier une réponse fait parler le marchand en public sur une page que nous hébergeons — une formulation malheureuse sur un avis sensible, et c'est une capture d'écran qui circule.
C'est pourquoi tools/list ne renvoie que deux outils lorsque l'appelant présente un jeton en lecture seule. Ne codez donc pas la liste en dur : demandez-la, et n'annoncez au marchand que ce qu'elle contient.
outil
lister_avis
Avis publiés sur l'enseigne, du plus récent au plus ancien. L'outil que l'assistant appelle pour « montre-moi les clients mécontents » ou « qu'est-ce qui n'a pas encore de réponse ? ».
| Argument | Type | Défaut | Effet |
note_max | entier 1–5 | — |
Ne remonte que les avis dont la note est inférieure ou égale. |
sans_reponse | booléen | false |
Écarte les avis auxquels une réponse a déjà été publiée. |
limite | entier 1–50 | 20 |
Nombre d'avis lus. |
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "lister_avis",
"arguments": { "note_max": 3, "sans_reponse": true, "limite": 10 }
}
}
Le résultat est un bloc de texte contenant du JSON — c'est la forme que le protocole prévoit pour un résultat structuré, et celle que les assistants savent lire :
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [
{
"type": "text",
"text": "{\"avis\":[{\"id\":\"9f1c2b3d-…\",\"note\":2,\"titre\":\"Colis abîmé\",\"commentaire\":\"…\",\"langue\":\"fr\",\"auteur\":\"Claire M.\",\"publie_le\":\"2026-08-13\",\"deja_repondu\":false}],\"total\":1}"
}
],
"isError": false
}
}
sans_reponse filtre après la limite, pas avant. Demander 20 avis sans réponse lit les 20 derniers avis publiés puis en retire ceux qui ont déjà été traités : le résultat peut en compter beaucoup moins, et total le dit. Relevez limite pour élargir la fenêtre de lecture.
Ne remontent ici que les avis publiés : ni les avis en attente, ni les rejetés, ni les retirés. Pour ceux-là, c'est GET /cms/reviews et son filtre status.
outil
resume_reputation
Vue d'ensemble, sans argument. Ce que l'assistant appelle pour « comment va ma réputation ? ».
{
"boutique": "Tissu Fiesta",
"note_moyenne": 4.6,
"avis_publies": 318,
"repartition": { "1": 4, "2": 6, "3": 18, "4": 91, "5": 199 },
"avis_produit": 127
}
avis_publies compte les avis sur l'enseigne ; avis_produit compte séparément ceux qui portent sur un article. Les additionner donnerait un total qui ne correspond à aucune note affichée.
outil
repondre_a_un_avis
Écriture publique
| Argument | Type | Oblig. | Effet |
avis_id | chaîne | oui | Identifiant de l'avis, tel que renvoyé par lister_avis. |
contenu | chaîne | oui |
Texte de la réponse, tronqué à 3000 caractères. |
{
"avis_id": "9f1c2b3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
"publiee": false,
"message": "Réponse enregistrée en brouillon. Le marchand doit la valider dans son espace avant qu'elle ne paraisse."
}
Publiée ou en brouillon : c'est le marchand qui l'a décidé, pas l'appel
Le marchand règle dans son espace si les réponses rédigées par un assistant partent directement ou attendent sa relecture. Aucun des deux comportements n'est bon dans l'absolu : celui qui reçoit deux avis par semaine veut relire, celui qui en reçoit deux cents veut que ça parte.
Ce réglage n'est pas un paramètre de la requête, et c'est essentiel : si l'assistant pouvait choisir lui-même s'il doit être relu, la garantie ne vaudrait plus rien. Le champ publiee et le champ message disent ce qui s'est réellement passé — un assistant doit l'annoncer tel quel au marchand.
Un avis déjà répondu voit sa réponse remplacée. Une réponse déjà publique le reste : une réécriture ne la renvoie jamais en brouillon, ce qui la ferait disparaître de la page sans décision de personne.
Ce qu'un assistant doit savoir avant de rédiger
-
Répondre dans la langue de l'avis — le champ
langue est là pour ça. Une réponse en français sous un avis néerlandais dit au lecteur qu'il n'a pas été lu.
-
Ne jamais promettre un geste commercial qu'on ne peut pas engager : remboursement, renvoi, remise. Cette réponse est publique et opposable au marchand.
-
Aucun outil ne modifie ni ne supprime un avis, et il n'y en aura pas. Un assistant à qui l'on demande de « faire retirer » un avis ne peut que le signaler, avec un motif recevable (§4.6) — la note n'en est pas un.
7.5 Erreurs
Toujours un statut HTTP 200, y compris en cas d'erreur : en JSON-RPC, l'erreur voyage dans le corps. Un 4xx ferait croire au client que le transport a échoué, et la plupart réessaieraient au lieu d'afficher le message.
Seule exception : l'authentification, refusée avant d'atteindre la couche JSON-RPC. Elle répond avec l'enveloppe d'erreur habituelle de l'API.
| Statut | Code | Cause |
| 401 | invalid_mcp_token |
Jeton inconnu, révoqué ou expiré — indistinguables à dessein. Le marchand en recrée un dans son espace. |
| 401 | codes de §2 |
Voie HMAC : clé absente, signature ou horodatage refusés. |
| 429 | too_many_attempts |
Plus de 20 échecs d'authentification en quinze minutes depuis la même adresse. Attendez plutôt que de réessayer en boucle. |
| Code | Signification | À faire |
-32001 |
La formule du marchand n'inclut pas l'accès MCP. |
Passer en formule payante ; les avis restent lisibles publiquement. |
-32601 | Méthode JSON-RPC inconnue. | Vérifier method. |
-32602 | Outil inconnu. | Appeler tools/list, ne pas coder les noms en dur. |
-32603 |
Erreur du pont local — réseau, secret absent. |
Ce code vient du pont ci-dessus, pas du serveur. |
Les erreurs métier d'un outil ne sont pas des erreurs JSON-RPC : la réponse reste un résultat, avec isError: true et un objet { "erreur": "…" } dans le texte. C'est le cas d'un avis introuvable, d'un contenu vide, ou d'une réponse tentée avec un jeton en lecture seule. L'assistant peut ainsi l'expliquer au marchand au lieu d'annoncer une panne.
8. Webhooks entrants
Il n'y a pas de webhook sortant
La plateforme ne vous appelle pas : elle n'émet aucune notification vers votre serveur à la publication d'un avis, d'une réponse ou d'une décision de modération. Pour suivre l'activité, interrogez GET /api/v1/cms/reviews à votre rythme, en filtrant sur status=published et en comparant published_at à votre dernier passage.
Un passage toutes les heures convient à la quasi-totalité des usages : les avis n'arrivent pas à la seconde, et le rythme de publication d'une boutique se compte en unités par jour. Interroger toutes les minutes ne fera rien apparaître plus vite.
Les deux points d'entrée ci-dessous existent pour des appelants précis — notre opérateur SMS et notre prestataire de paiement. Aucun intégrateur n'a à les appeler, et aucun ne peut le faire : ils sont l'un et l'autre fermés par un secret qui n'est pas distribué.
POST
/api/v1/stripe/webhook
Signature Stripe
Reçoit les événements d'abonnement : checkout.session.completed, customer.subscription.created, .updated et .deleted. C'est ce qui fait basculer un compte en formule payante, et donc ce qui ouvre l'API avis et l'accès MCP.
La signature de la charge utile est la seule chose qui protège cette route : sans elle, n'importe qui pourrait poster « abonnement actif » et s'offrir la formule payante d'une requête curl. Elle est vérifiée avant toute lecture du contenu, et un secret absent fait échouer la requête plutôt que de la laisser passer.
Les événements non traités sont acquittés par un 200 ({ "ignored": … }) : Stripe considère toute réponse non-2xx comme un échec et rejoue pendant trois jours, avec un intervalle croissant. Répondre 404 à un type d'événement dont nous n'avons pas l'usage provoquerait des milliers de renvois inutiles, puis la désactivation du point de terminaison de leur côté. À l'inverse, un échec réel de traitement répond bien 500 — là, on veut que Stripe rejoue plutôt que de laisser un marchand qui a payé en formule gratuite.
POST
/api/v1/sms/inbound/{token}
Jeton partagé
Reçoit les SMS entrants, c'est-à-dire les « STOP ». L'opérateur traite le mot-clé de son côté et cesse d'acheminer — mais sans ce point d'entrée, nous n'en saurions rien : nous continuerions à lui envoyer des messages facturés et jamais reçus, l'opposition disparaîtrait le jour d'un changement d'opérateur, et nous ne pourrions pas prouver l'avoir honorée alors que la charge de la preuve nous incombe.
Le jeton voyage dans le chemin, ce qui est plus faible qu'une signature — mais c'est ce que les interfaces des opérateurs français savent configurer. D'où le fait que ce point d'entrée ne puisse rien faire d'autre qu'ajouter une opposition : le pire qu'un appel frauduleux produise, c'est empêcher l'envoi de SMS à un numéro. Gênant, jamais dangereux, et réversible depuis le back-office.
Le mot-clé est cherché en premier mot du message, pas n'importe où dedans : quelqu'un qui écrit « il faut que ça s'arrête, ce magasin est nul » ne demande pas à se désinscrire, et le désinscrire d'office lui retirerait le canal par lequel on le sollicite légitimement. L'opposition est enregistrée pour tous les marchands : le message entrant ne dit pas de quelle boutique il s'agit — la personne répond au numéro d'envoi — et deviner serait à la fois faux et dangereux.
Elle ne coupe que le canal SMS. L'email continue de partir : c'est lui qui porte le lien de gestion de l'avis et les mentions obligatoires, et une opposition exprimée sur un canal ne vaut pas pour l'autre.
9. Limites de débit
Les limites sont calculées en fenêtre glissante : pas de compteur qui se remet à zéro à l'heure ronde, donc pas de rafale possible en début de période.
| Canal | Limite | Clé | Pourquoi ce chiffre |
API publique /api/v1/public/ |
60 / minute |
Adresse IP |
Une fiche produit fait deux appels : cela laisse 30 chargements par minute depuis une même adresse. Large pour un visiteur, étroit pour un aspirateur de contenu. |
Disponibilité /public/health |
aucune |
— |
Exclu volontairement : il est interrogé en continu par la supervision, et le brider ferait remonter de fausses alertes d'indisponibilité. |
Ouverture de raccordement POST /pairing |
10 / heure |
Adresse IP |
Chaque appel crée une ligne en base sans aucune authentification. Dix suffisent largement à un intégrateur qui recommence. |
Sondage POST /pairing/{code} |
240 / 15 minutes |
Adresse IP |
Généreux à dessein : le module interroge toutes les cinq secondes pendant que le marchand crée son compte, confirme son adresse et valide. |
| Authentification MCP par jeton |
20 échecs / 15 minutes |
Adresse IP |
Ne compte que les échecs : un branchement qui fonctionne n'y touche jamais. Arrête le balayage de jetons trouvés ailleurs, et évite qu'un client mal configuré ne noie les journaux. |
| Dépôt d'un avis |
10 / minute |
Jeton de l'invitation |
Par jeton et non par IP : plusieurs clients d'une même entreprise partagent souvent une adresse de sortie, et les brider ensemble punirait des dépôts légitimes. |
| Signalement public d'un avis |
5 / heure |
Adresse IP |
Ouvert à tout lecteur (obligation DSA), donc à tout robot. Chaque envoi crée une ligne dans la file de modération. |
L'API CMS n'est pas bridée, ce qui n'autorise pas tout
Aucune limite de débit n'est appliquée aujourd'hui aux endpoints signés (/api/v1/cms/ et MCP) : ils sont authentifiés, et le volume réel est borné par le quota de sollicitations du marchand. Traitez quand même le 429 — une limite pourra être ajoutée, et une intégration qui ne sait pas la lire tombera en panne le jour où elle apparaîtra.
En pratique : transmettez les commandes au fil de l'eau plutôt qu'en lot nocturne de plusieurs milliers, et interrogez les avis à l'heure plutôt qu'à la minute (§8). Un volume anormal est visible de notre côté et déclenche une prise de contact, pas une coupure silencieuse.
Ce que renvoie un dépassement
HTTP/1.1 429 Too Many Requests
Retry-After: 37
X-RateLimit-Remaining: 0
{
"error": {
"code": "rate_limit_exceeded",
"message": "Trop de requêtes. Réessayez dans quelques instants."
}
}
Retry-After donne le nombre de secondes à attendre. Respectez-le : réessayer immédiatement ne fait que consommer la fenêtre suivante. Une temporisation exponentielle, plafonnée à une minute, suffit à tous les cas décrits ici.
Le sondage de raccordement fait exception et répond { "status": "rate_limited" } : c'est le même événement, exprimé dans le vocabulaire d'un endpoint que le module interroge en boucle.
10. Codes d'erreur communs
Deux formats, et un seul à traiter dans la plupart des cas
Partout dans l'API, une erreur porte la même enveloppe :
{
"error": {
"code": "invalid_api_key",
"message": "Clé d'API inconnue, révoquée ou expirée."
}
}
Le code est stable et destiné à votre programme ; le message est destiné à l'humain qui débogue et peut être reformulé sans préavis. Ne construisez jamais votre logique sur le texte du message.
Une seule exception : POST /cms/orders, servi par une couche différente, renvoie ses erreurs de validation sous forme de liste de violations. Un client robuste lit donc error.code s'il existe, et se rabat sur violations sinon.
Statuts HTTP
| Statut | Sens | Faut-il réessayer ? |
| 200 | Succès. En JSON-RPC, l'erreur éventuelle est dans le corps. | — |
| 201 | Créé — commande enregistrée, réponse publiée. | — |
| 202 | Accepté mais non tranché : le signalement entre en file. | — |
| 400 | Requête illisible. | Non, corrigez. |
| 401 | Clé absente, invalide, ou signature refusée. | Non, sauf horloge à resynchroniser. |
| 402 | La formule du marchand n'inclut pas cette fonction. | Non. |
| 404 | Ressource inconnue — ou hors de votre compte. | Non. |
| 409 | Conflit : l'action a déjà été faite. | Non, c'est un état, pas une panne. |
| 415 | Content-Type absent ou inattendu. | Non, envoyez du JSON. |
| 422 | Requête bien formée mais refusée : champ manquant, valeur hors bornes. | Non, corrigez. |
| 429 | Débit dépassé. | Oui, après Retry-After. |
| 5xx | Incident de notre côté. | Oui, avec temporisation croissante. |
Récapitulatif des codes
| Code | Statut | Où | Cause et remède |
missing_api_key | 401 | CMS, MCP |
En-tête X-Api-Key absent. |
invalid_api_key | 401 | CMS, MCP |
Clé inconnue, révoquée ou expirée — les trois sont volontairement indistinguables. Vérifiez-la dans l'espace marchand. |
missing_signature, missing_timestamp |
401 | CMS, MCP |
Écriture non signée. Voir §2.1. |
invalid_timestamp | 401 | CMS, MCP |
X-Timestamp n'est pas un horodatage Unix en secondes — millisecondes ou date ISO, le plus souvent. |
timestamp_out_of_range | 401 | CMS, MCP |
Plus de 300 s d'écart. Le message donne l'écart exact : synchronisez l'horloge (NTP). |
signature_mismatch | 401 | CMS, MCP |
Reprenez les quatre pièges du §2.3, dans l'ordre. |
invalid_mcp_token | 401 | MCP |
Jeton porteur inconnu, révoqué ou expiré — indistinguables. Le marchand en recrée un depuis son espace (§7.1). |
too_many_attempts | 429 | MCP |
Trop d'échecs d'authentification depuis la même adresse. |
plan_required | 402 | Avis, réponse |
Fonction incluse à partir de la formule payante. L'affichage public des avis, lui, reste gratuit. |
merchant_not_found | 404 | API publique |
Identifiant public inconnu. Vérifiez le slug, pas le nom commercial. |
review_not_found | 404 | Réponse, signalement |
Identifiant inconnu, mal formé, ou appartenant à un autre marchand : le cloisonnement impose de ne pas les distinguer. |
already_reported | 409 | Signalement |
Un dossier est déjà ouvert sur cet avis. |
content_required | 422 | Réponse |
content absent ou vide après nettoyage. |
invalid_reason | 422 | Signalement |
Motif hors liste. Une note basse n'est pas un motif recevable (§4.6). |
invalid_request | 422 | Raccordement |
shop_domain absent ou inexploitable. |
rate_limit_exceeded | 429 | API publique |
Voir §9 et l'en-tête Retry-After. |
-32001 | 200 | MCP |
Formule sans accès MCP (erreur JSON-RPC, pas HTTP). |
-32601, -32602 | 200 | MCP |
Méthode ou outil inconnu. Passez par tools/list. |
Trois symptômes, et par où commencer
| Symptôme | Cause la plus fréquente |
| « Tout marchait hier, tout est en 401 aujourd'hui. » |
L'horloge du serveur a dérivé. GET /cms/ping renvoie server_time : comparez-le à la vôtre avant de chercher ailleurs. |
| « Le ping passe, mais toutes mes écritures échouent. » |
La clé est bonne, la signature non — c'est justement ce que ce partage de régime permet de conclure. Le corps a presque toujours été réencodé après avoir été signé (§2.3). |
| « Le widget n'affiche rien, mais l'API répond 200 en console. » |
Domaine non déclaré côté marchand : le navigateur bloque la lecture faute d'en-tête CORS. Ou, tout simplement, il n'y a pas encore d'avis : un widget vide se retire de la page (§6). |
Si rien de tout cela ne colle
Écrivez-nous depuis l'espace marchand en joignant trois choses : le chemin appelé, l'horodatage de la requête, et le code d'erreur reçu. Avec ces trois éléments, la requête se retrouve dans les journaux ; sans eux, la seule réponse possible est de vous demander de les fournir.
↑ Revenir au début