1. Introducción
La plataforma expone tres canales distintos. No comparten ni el mismo público, ni el mismo régimen de autenticación, ni los mismos límites. Elegir el correcto es la primera decisión de una integración.
| Canal |
Prefijo |
Para quién |
Autenticación |
| API CMS |
/api/v1/cms/ |
Módulos de comercio electrónico, ERP, CRM, herramientas internas |
Clave de API + firma HMAC en escritura |
| API pública |
/api/v1/public/ |
Widgets de visualización, JavaScript de la tienda |
Ninguna — caudal limitado, CORS restringido |
| MCP |
/api/v1/mcp |
Asistentes de IA (Claude, ChatGPT, otros) |
La misma clave y la misma firma que la API CMS |
Antes de escribir una línea de código: compruebe que un módulo no baste
Los módulos de PrestaShop y WooCommerce hacen íntegramente lo que describe esta página: transmiten los pedidos en el momento adecuado, colocan el script de los widgets en la plantilla, ponen las estrellas en las fichas de producto y el bloque de reseñas, y se ocupan de la firma de las peticiones. El comerciante no pega nada y no escribe nada.
Descargar los módulos →
Esta documentación se dirige por tanto a tres casos: una plataforma para la que aún no tenemos módulo, un desarrollo a medida, o la conexión de una herramienta de terceros (ERP, atención al cliente, asistente de IA) a las reseñas ya recogidas.
Direcciones base
Todas las URL de esta página son relativas a la dirección de la API. Un módulo solo debe conocer esa: las demás direcciones se le devuelven mediante GET /api/v1/cms/me, lo que le evita adivinarlas y nos permite cambiarlas sin actualizar nada en casa de los comerciantes.
| Uso | Dirección |
| API (todos los canales) | https://louis.guide |
| Área del comerciante | https://louis.guide/app |
| Script de los widgets | https://louis.guide/widget/v1/avis.js |
Convenciones
- Formato — JSON tanto de entrada como de salida, codificado en UTF-8. La cabecera
Content-Type: application/json se espera en toda petición que lleve cuerpo.
- Nomenclatura — serpiente en minúsculas (
external_order_id, experienced_at), la convención dominante de las API que consumen los integradores de PHP y JavaScript.
- Fechas — ISO 8601 con zona horaria explícita en la entrada (
2026-08-01T14:22:00+02:00). En la salida, las fechas completas usan el mismo formato; las fechas públicas de una reseña se reducen al día (2026-08-01) porque ningún widget muestra la hora.
- Importes — transmitidos como cadena (
"129.90") y nunca como número en coma flotante: un céntimo perdido al redondear en un pedido se convierte en una diferencia de facturación.
- Identificadores — los objetos que creamos llevan un UUID permanente. Los suyos (pedido, producto, variante) siguen siendo suyos: nunca los reescribimos.
-
Errores —
siempre el mismo sobre
{ "error": { "code": …, "message": … } }. El code es estable y está destinado a su programa, el message al humano que depura. Véase §10.
-
Versionado —
el
/v1 de la ruta es un contrato. Puede añadirse un campo opcional en cualquier momento; ningún campo existente será renombrado, eliminado ni convertido en obligatorio. Una ruptura saldría en /v2, quedando la versión anterior aún servida — los módulos corren en casa de los comerciantes y nadie puede actualizarlos a distancia.
Su código debe por tanto ignorar los campos que no conoce en lugar de fallar al verlos.
Obtener una clave de API
-
Cree una cuenta de comerciante en el área del comerciante.
- Confirme la dirección de correo y abra a continuación la sección de claves de API.
- Anote el secreto: solo se muestra una vez. Una vez perdido no se recupera — se crea una clave nueva y se revoca la antigua.
Un módulo de instalación no necesita esta maniobra: abre él mismo una solicitud de conexión que el comerciante valida con un clic. Véase §3.
2. Autenticación
La API CMS y el servidor MCP usan el mismo mecanismo: una clave que dice quién llama, y una firma que prueba que quien llama posee el secreto. Son dos cosas distintas.
| Método HTTP | Cabeceras exigidas | Por qué |
GET, HEAD |
X-Api-Key |
Una lectura no modifica nada: la clave basta para autorizarla. |
POST, PUT, PATCH, DELETE |
X-Api-Key, X-Timestamp, X-Signature |
Una escritura compromete al comerciante: debe estar probada y no ser reproducible. |
El secreto nunca circula
Solo viaja la firma. Eso cierra tres puertas que no requieren compromiso alguno de la tienda: la fuga pasiva del secreto en los registros de un intermediario, la reproducción de una petición interceptada y la alteración del cuerpo en tránsito. En cambio no protege de una tienda cuya base de datos haya sido sustraída — contra ese caso la defensa es la rotación de claves.
No ponga nunca el secreto en una URL: las URL acaban en los registros de todos los intermediarios atravesados.
2.1 La firma, paso a paso
Paso 1 — Las tres cabeceras
| Cabecera | Contenido |
X-Api-Key |
Identificador público de la clave, tal como aparece en el área del comerciante. |
X-Timestamp |
Marca de tiempo Unix en segundos, solo cifras. Nada de milisegundos, nada de fecha ISO. |
X-Signature |
El prefijo literal sha256= seguido del HMAC-SHA256 en hexadecimal minúsculo. El prefijo forma parte del valor comparado: omitirlo produce un rechazo. |
Paso 2 — Construir la carga que se va a firmar
Cuatro piezas concatenadas sin separador, exactamente en este orden:
charge = X-Timestamp
+ MÉTHODE HTTP en majuscules
+ chemin logique de la requête
+ corps brut de la requête
| Pieza | Regla exacta |
| Marca de tiempo |
La cadena idéntica a la enviada en X-Timestamp. |
| Método |
POST, PUT… siempre en mayúsculas. |
| Ruta |
La ruta sin esquema, sin host, sin cadena de consulta, empezando por / — por ejemplo /api/v1/cms/orders. Si la API se sirve desde un subdirectorio, ese prefijo de instalación no entra en la firma: vive en la dirección base, no en la ruta lógica. |
| Cuerpo |
La cadena de bytes exactamente tal como se envía. Serialice una vez, firme esa cadena, envíe esa cadena. Cuerpo vacío → cadena vacía. |
Paso 3 — Calcular
X-Signature = "sha256=" + HMAC_SHA256(charge, secret) // hexadécimal minuscule
Paso 4 — Comprobar su implementación con este ejemplo
Estos valores son fijos y la firma mostrada es realmente la de estos datos: si su código produce otra cosa, el problema está en su código, no en el nuestro.
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 !"}
Carga que se va a firmar (una sola línea, sin espacios añadidos):
1786000000POST/api/v1/cms/reviews/9f1c2b3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d/response{"content":"Merci pour votre retour !"}
Resultado esperado:
X-Signature: sha256=8a9c0de10516226f965465704902e00c666892b483b45b361f4536a6b2ee36e9
El mismo cálculo en una línea 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 y no echo: este último añade un salto de línea final, que cambia la firma.
Paso 5 — Una llamada completa con 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 y no --data: el segundo interpreta ciertos caracteres y puede modificar el cuerpo enviado, invalidando así la firma.
2.2 Ejemplo en PHP
El cliente mínimo, sin dependencias. Es el mismo mecanismo que el de los módulos de PrestaShop y WooCommerce, reducido a lo esencial.
<?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 Las cuatro trampas
Cuatro causas explican la práctica totalidad de los signature_mismatch. Vistas desde fuera se parecen todas — de ahí el interés de descartarlas en este orden.
- El cuerpo se recodificó después de la firma. El caso más frecuente y el más difícil de ver: un array serializado dos veces da dos cadenas distintas en cuanto contiene un acento o una barra (
JSON_UNESCAPED_UNICODE, JSON_UNESCAPED_SLASHES, orden de las claves). Firme la cadena, envíe esa cadena, no la reconstruya jamás.
- La ruta firmada lleva un prefijo que no debería tener. La ruta firmada es
/api/v1/cms/orders, aunque la API se sirva desde https://ejemplo.es/plataforma/api/v1/cms/orders. El prefijo de instalación pertenece a la dirección base. A la inversa, tampoco firme la URL completa con su esquema y su host.
-
El reloj del servidor se ha desviado.
Tolerancia: 300 segundos de desfase, en un sentido como en otro. Más allá, la respuesta es
timestamp_out_of_range y su mensaje indica el desfase medido en segundos — exactamente la información que hay que dar a su alojador. Este caso se manifiesta a menudo como una integración que "ayer funcionaba".
- Faltan el método o el prefijo. El método entra en la carga en mayúsculas, y el valor de
X-Signature empieza por sha256=. Un HMAC desnudo, sin prefijo, se rechaza.
Lo que la cadena de consulta no hace
Los parámetros de URL (?page=2) no entran en la carga firmada: solo figura la ruta. En la práctica carecen de consecuencia, ya que los endpoints firmados son todos escrituras que llevan sus parámetros en el cuerpo — pero una implementación que los añadiera a la carga fallaría.
Reproducción y ventana de validez
La carga firmada cubre la marca de tiempo, el método, la ruta y el cuerpo. Omitir uno de ellos abriría una brecha: sin la ruta, una firma válida para POST /orders sería reproducible en DELETE /orders; sin la marca de tiempo, la petición sería reproducible indefinidamente.
La ventana de 300 segundos es lo que acota la reproducción: una petición interceptada no puede reemitirse más allá. No hay diccionario de firmas ya vistas — dentro de esa ventana una petición idéntica se acepta por tanto dos veces. Eso no afecta a la transmisión de pedidos, que es idempotente por external_order_id: la segunda recibe el pedido ya registrado y no envía un segundo correo.
Respuestas de autenticación
Todas estas respuestas llevan el estado 401.
| Código | Causa | Qué hacer |
missing_api_key |
Falta la cabecera X-Api-Key. |
Añadir la cabecera. |
invalid_api_key |
Clave desconocida, revocada o caducada. El mensaje es deliberadamente idéntico en los tres casos: distinguirlos permitiría comprobar en masa qué identificadores existen. |
Comprobar la clave en el área del comerciante, o crear una nueva. |
missing_signature |
Escritura sin cabecera X-Signature. |
Firmar la petición (§2.1). |
missing_timestamp |
Escritura sin cabecera X-Timestamp. |
Añadir la marca de tiempo y firmarla. |
invalid_timestamp |
X-Timestamp no es una secuencia de cifras — milisegundos, fecha ISO o un signo. |
Enviar una marca de tiempo Unix en segundos. |
timestamp_out_of_range |
Más de 300 segundos de desfase. El mensaje da la cifra exacta. |
Sincronizar el reloj del servidor (NTP). |
signature_mismatch |
La firma no corresponde a la carga esperada. |
Repasar las cuatro trampas del §2.3, por orden. |
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"error": {
"code": "timestamp_out_of_range",
"message": "Marca de tiempo fuera de tolerancia: +412 s de desfase con nuestro servidor (máximo 300 s). El reloj de su servidor está probablemente desincronizado."
}
}
3. Conectar una tienda
Estos dos endpoints permiten a un módulo recuperar una clave sin que el comerciante tenga que copiar nada. El módulo abre una solicitud, muestra un enlace, el comerciante valida en su navegador, y el módulo recibe su clave y su secreto en la lectura siguiente.
Van sin autenticación, por construcción. La seguridad no descansa en una identidad sino en tres cosas: la solicitud no obtiene nada mientras un comerciante conectado no la haya validado, el token de sondeo nunca sale del servidor de la tienda, y el secreto se entrega una sola vez. Lo peor que puede producir una llamada malintencionada es una solicitud pendiente que nadie aprobará — y que caduca en un cuarto de hora.
POST
/api/v1/pairing
Sin autenticación
Abre una solicitud de conexión y devuelve el enlace de validación que hay que presentar al comerciante.
| Campo | Tipo | Obligatorio | Descripción |
shop_domain | cadena | sí |
Dominio de la tienda, p. ej. tienda.ejemplo.es. |
platform | cadena | no |
prestashop, woocommerce, custom… unknown por defecto. |
shop_name | cadena | no |
Nombre legible de la tienda, reutilizado al crear la cuenta. |
platform_version | cadena | no |
Versión de la plataforma, p. ej. 8.1.6. |
plugin_version | cadena | no |
Versión del módulo que llama. |
shop_uid | cadena | no |
Identificador único generado una vez al instalar el módulo. Muy recomendable con varias tiendas: véase §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 — para abrir en una pestaña nueva del navegador del comerciante, fuera de su back office. Ahí es donde inicia sesión o crea su cuenta, y luego valida.
poll_token — para conservar únicamente del lado del servidor. No debe aparecer nunca en una página ni en una URL: es lo que permitirá recuperar el secreto.
code — mostrable al comerciante, para que compruebe que valida la solicitud correcta.
Códigos de error
| Estado | Código | Causa |
| 422 | invalid_request | shop_domain ausente o inutilizable. |
| 429 | rate_limited | Más de 10 aperturas por hora y por IP. |
POST
/api/v1/pairing/{code}
Sin autenticación
Consulta el estado de la solicitud y entrega la clave una vez — y una sola — que el comerciante ha validado.
En POST aunque sea una lectura, porque la llamada tiene un efecto secundario: consume el secreto. En GET, un precargador del navegador o un antivirus que siga los enlaces lo consumiría en lugar del módulo.
| Campo | Tipo | Obligatorio | Descripción |
poll_token | cadena | sí |
El token recibido al abrir la solicitud. |
curl -X POST 'https://louis.guide/api/v1/pairing/4K7M-9QR3' \
-H 'Content-Type: application/json' \
--data-raw '{"poll_token":"pt_8f2c1a7e5b2d48a6c3d9e0f1a2b3c4d5"}'
A la espera de validación:
HTTP/1.1 200 OK
{ "status": "pending" }
Validado — las credenciales se entregan únicamente en esta llamada:
HTTP/1.1 200 OK
{
"status": "approved",
"api_key": "ak_live_5c2f81b0",
"secret": "sk_live_3f9c1a7e5b2d48a6",
"merchant": "tissufiesta"
}
Valores posibles de status
| Valor | Significado | Qué hacer |
pending | El comerciante aún no ha decidido. | Seguir sondeando. |
approved | Validado. La respuesta lleva las credenciales. | Guardarlas, dejar de sondear. |
rejected | El comerciante ha rechazado. | Detenerse y comunicárselo. |
expired | Quince minutos transcurridos sin decisión. | Abrir una nueva solicitud. |
consumed |
El secreto ya se ha entregado, y eso nunca ocurre dos veces. El módulo ha perdido la respuesta. |
Volver a empezar una conexión — es el comportamiento seguro. |
unknown |
Código desconocido o token erróneo. Deliberadamente indistintos: separarlos convertiría este endpoint en un oráculo capaz de revelar qué tiendas se están conectando. |
Comprobar la pareja código / token. |
rate_limited | Demasiados sondeos (estado HTTP 429). | Espaciar las llamadas. |
Guarde el secreto de inmediato. Solo se transmite en esa respuesta. Un módulo que no consiga persistirlo tendrá que hacer que el comerciante repita toda la conexión.
Siempre 200, incluso para un estado de espera. El módulo consulta en bucle: un código HTTP de error ante una situación perfectamente normal haría saltar alertas para nada. Sondee cada cinco segundos; el límite es de 240 llamadas por cuarto de hora y por IP — más allá, la respuesta es { "status": "rate_limited" } con un estado 429.
4. API CMS (firmada)
El canal de las integraciones de servidor: módulos de comercio electrónico, ERP, CRM, herramientas internas. Todas las URL van precedidas de https://louis.guide.
Lecturas: basta la clave. Escrituras: clave + firma. El detalle del cálculo está en §2. Las fichas siguientes recuerdan el régimen de cada una con una insignia.
Lo que la API no permite, y no permitirá
Ningún endpoint modifica ni elimina una reseña. El comerciante puede responder públicamente y denunciar para moderación, nada más — exactamente lo que permite su área. Una API más permisiva que la interfaz sería una puerta trasera en el cumplimiento normativo, y es lo primero que verifica una auditoría.
GET
/api/v1/cms/ping
Clave de API
Comprueba que una clave funciona. Es la primera llamada que hay que escribir, y la que conviene ofrecer al comerciante como botón «Probar la conexión»: más vale que descubra una clave errónea en la configuración que en el primer pedido no transmitido.
Sin firma, deliberadamente. Una lectura no modifica nada y, sobre todo: este endpoint debe seguir siendo utilizable para probar que una clave es buena mientras la implementación HMAC sigue siendo errónea. El ping pasa, la escritura no: el problema está en la firma, no en la clave.
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 se devuelve por una razón precisa: compárelo con el reloj de su servidor. Un desfase superior a 300 segundos hará fallar todas sus escrituras firmadas (§2.3), y es aquí donde se ve antes de perder un día con ello.
GET
/api/v1/cms/me
Clave de API
Estado de la cuenta: identidad del comerciante, capacidades del plan, cuota y direcciones de la plataforma.
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/es/m/tissufiesta"
},
"server_time": "2026-08-13T14:52:07+00:00"
}
Lea las capacidades, no el nombre del plan
El bloque plan expone capacidades (can_…) además del código de plan. Compruebe las primeras: un módulo que codifica en duro if (plan === 'pro') dejará de ser correcto el día en que se añada un plan o cambie de nombre, en todos los comerciantes a la vez y sin que ninguno pueda corregirlo.
| Capacidad | Lo que gobierna |
can_display_product_reviews |
Visualización de las reseñas de producto — las estrellas en las fichas. |
can_use_photos |
Salida de las fotos de clientes por la API. Se recogen ya desde el plan gratuito pero solo se sirven pagando: en una cuenta gratuita la galería responde con una lista vacía, nunca con un error. |
can_use_reviews_api |
Lectura de las reseñas por la API, respuesta a las reseñas y acceso MCP. La transmisión de pedidos no está afectada: va incluida en todos los planes. |
can_remove_branding |
Retirada de la mención de la plataforma en widgets y correos. |
El bloque urls evita adivinar
Su integración solo debe conocer una única dirección: la de la API. Las demás — área del comerciante, script de los widgets, página pública del comerciante — se devuelven aquí. Un módulo que las recompone a partir de una base única supone que todo vive en el mismo host, lo que deja de ser cierto en cuanto un canal pasa a un subdominio, y produce enlaces muertos en todos los comerciantes ya instalados.
quota.remaining merece un lugar en su interfaz: a cero, los pedidos siguen aceptándose pero ya no sale ninguna solicitud. Avisar al 90 % de consumo evita al comerciante descubrirlo en sus estadísticas.
POST
/api/v1/cms/orders
Firma exigida
El endpoint central. Registra un pedido y planifica la petición de reseña. Todo lo demás en la plataforma se deriva de esta llamada: sin ella no hay solicitud, ni reseña, ni puntuación.
Idempotente por external_order_id
Reemitir la misma referencia devuelve el pedido ya registrado con un estado 200 en lugar de 201, sin crear duplicado y sin enviar un segundo correo al cliente. El campo idempotent de la respuesta vale entonces true. Puede por tanto reintentar sin precaución tras un corte de red o un tiempo de espera agotado — es el comportamiento preferible a cualquier lógica de deduplicación casera.
Cuándo llamar
En el momento en que la experiencia se vive, no se pide: en la entrega, en el envío según su oficio, o al pasar al estado que haga las veces. Es experienced_at el que lleva esa fecha, y es ella la que hace arrancar el plazo de solicitud.
Cuerpo de la petición
Raíz
| Campo | Tipo | Oblig. | Descripción |
external_order_id | cadena (100) | sí |
Referencia del pedido en su sistema. Clave de idempotencia y prueba de compra conservada cinco años (AFNOR §6.3). Debe ser estable en el tiempo. |
customer | objeto | sí |
Identidad del cliente al que se va a solicitar — véase la tabla siguiente. |
experienced_at | ISO 8601 | sí |
Fecha de entrega o de consumo, con zona horaria explícita. Véase el recuadro de abajo: no es la fecha del pedido. |
source | objeto | sí |
Contexto técnico de la emisión — véase más abajo. |
items | array (200 máx.) | no |
Artículos. Sin ellos no se pedirá ninguna reseña de producto — solo la del establecimiento. |
amount | cadena decimal | no |
Importe total, p. ej. "129.90". Nunca un número en coma flotante. |
currency | ISO 4217 | no |
"EUR", "CHF"… |
channel | enumeración | no |
ecommerce_order (por defecto), pos_transaction, qr_scan, manual, csv_import, nfc. |
location_id | cadena (100) | no |
Establecimiento afectado, tal como lo ha declarado el comerciante. Un valor desconocido hace fallar la petición con un 422 en lugar de vincular el pedido al punto de venta equivocado. |
solicitation_delay_days | entero 0–365 | no |
Plazo propio de este pedido, que anula el ajuste de la cuenta. Útil cuando un mismo vendedor envía un ramo sobre el que preguntar mañana y un colchón sobre el que preguntar dentro de un mes. Fuera de límites, el valor se ignora y se aplica el ajuste de la cuenta — un valor aberrante no debe hacer perder un pedido. |
order_status_id | cadena (20) | no |
Estado del pedido en su sistema en el momento del envío. Puramente diagnóstico — no lo interpretamos — pero es la única información que permite responder a «por qué este pedido no ha desencadenado nada». |
order_status_label | cadena (120) | no |
Etiqueta legible de ese estado. |
experienced_at: la fecha de entrega, no la del pedido
Un paquete pedido el día 1 y entregado el 6 lleva el 6. No es una sutileza: dos ensayos aleatorizados sobre más de 300 000 consumidores (Jung, Ryu, Han & Cho, Journal of Marketing, 2023) establecen que una solicitud enviada antes de que el cliente haya podido formarse una opinión tiene un efecto negativo sobre la tasa de envío. Anclar el plazo en la fecha del pedido equivale a solicitar sistemáticamente demasiado pronto, todo el plazo de entrega.
Es además una de las tres fechas mostradas públicamente junto a la reseña (AFNOR §6.3).
customer
| Campo | Tipo | Oblig. | Descripción |
email | correo (255) | sí |
El único dato personal en claro que aceptamos. Borrado tras el plazo de envío; solo subsiste su huella. |
country | ISO 3166-1 alpha-2 | no* |
*Muy recomendable. Google calcula sus puntuaciones de comerciante por país y descarta las reseñas cuyo país sea desconocido. Esta información solo existe en el momento del pedido: una vez depurada la dirección, es definitivamente irrecuperable, sin posibilidad de rescate. |
locale | fr, en, nl, de, it, es | no |
Idioma del correo de solicitud. En su defecto, el idioma por defecto del comerciante — solicitar a un cliente neerlandófono en francés hunde la tasa de respuesta. |
phone | cadena (32) | no |
Móvil para la solicitud por SMS. Formato internacional (+33612345678) muy recomendable: es el único sin ambigüedad. Un número nacional se convierte a partir de country; sin país conocido se descarta sin hacer fallar el pedido. Véase la advertencia de abajo. |
first_name, last_name | cadena (100) | no |
Personalización de la solicitud y nombre mostrado del autor. |
company | cadena (255) | no |
Razón social, para un pedido profesional. |
postal_code, city | cadena | no |
Depurados al mismo tiempo que la dirección de correo. |
Envíe el número de móvil solo si el comerciante ha contratado el SMS. Sin esa opción se recibe y se conserva sin que salga mensaje alguno: un dato personal recogido sin finalidad, algo que ninguna de las dos partes puede justificar en caso de inspección.
source — obligatorio
Este bloque no es estadística. Cuando un comerciante escribe «mis reseñas ya no salen desde la actualización», la respuesta ya está dentro: versión de la plataforma, versión del módulo, evento desencadenante. Hacerlo opcional equivaldría a no tenerlo nunca — los integradores rellenan lo que se exige, no lo que se sugiere.
| Campo | Tipo | Oblig. | Descripción |
platform | cadena (50) | sí |
prestashop, woocommerce, shopify, magento, custom… |
platform_version | cadena (30) | no |
P. ej. 8.1.6. |
plugin_version | cadena (30) | no |
Versión de su integración. Hay que incrementarla en cada entrega. |
trigger | cadena (100) | no |
Evento en el origen del envío, p. ej. woocommerce_order_status_completed. Permite comprender por qué un pedido sale demasiado pronto o demasiado tarde. |
shop_uid | cadena (80) | no* |
*Decisivo con varias tiendas. Identificador generado una vez en la instalación y conservado. Véase el recuadro. |
shop_id | cadena (50) | no |
Identificador de tienda en la plataforma. Sirve de reserva cuando falta shop_uid. |
shop_name | cadena (255) | no |
Nombre legible de esta tienda. Sin él, el comerciante descubre en su área un establecimiento llamado «3» y debe adivinar cuál es. |
shop_group_id, lang_id | cadena | no |
Conservados para el diagnóstico, nunca interpretados. lang_id no separa nada: el idioma de la reseña viene de customer.locale. |
Varias tiendas: shop_id no basta
Vale «1» en toda instalación de tienda única. Un comerciante que explota dos sitios bajo la misma cuenta — una marca por dominio, caso corriente — enviaría por tanto «1» desde ambos: las dos tiendas se fundirían en un solo establecimiento, las reseñas de una aparecerían en la página de la otra, y el nombre conservado sería el del último pedido recibido. Defecto constatado en pruebas sobre dos PrestaShop reales.
shop_uid resuelve el problema: genérelo una vez en la instalación y consérvelo. Sobrevive a un cambio de dominio igual que a una renovación de clave — los otros dos discriminantes en los que se piensa primero, y que se mueven ambos.
items[] — opcional, 200 artículos como máximo
| Campo | Tipo | Oblig. | Descripción |
external_product_id | cadena (100) | sí |
Identificador del producto en su catálogo. |
name | cadena (255) | sí |
Nombre del producto tal como se muestra al cliente. |
variant_id | cadena (100) | no* |
*El campo más importante de esta lista. Sin él, la silla roja y la silla azul comparten la misma clave de producto: sus reseñas se mezclan y «se rompió la pata» ya no designa nada. Corresponde a id_product_attribute (PrestaShop), a la variación (WooCommerce), al variant (Shopify). |
variant_label | cadena (255) | no |
Etiqueta legible: «Color: rojo, Talla: L». |
gtin | de 8 a 14 cifras | no* |
EAN-13 o UPC-A convertido. Clave de agregación entre comerciantes, y exigencia de Google para mostrar las estrellas en sus resultados. |
upc, isbn, mpn | cadena | no |
Mantenidos aparte del GTIN porque los catálogos los mantienen en columnas distintas. El ISBN es decisivo en el libro, donde el GTIN suele estar vacío. |
sku, brand | cadena | no |
Referencia interna y marca. |
category_id, category_name | cadena | no |
Categoría principal en su catálogo. |
product_url, image_url | URL (500) | no |
Usadas en el correo de solicitud: una imagen de producto mejora netamente la tasa de envío. |
images | lista de URL (10 máx.) | no |
Imágenes adicionales. |
description | cadena (5000) | no |
Recibida, nunca mostrada de nuevo tal cual: es su texto, no el del autor de la reseña. Sirve para situar el producto en moderación. |
tags | lista (30 máx.) | no |
Palabras clave del producto, 60 caracteres cada una. |
meta_title, meta_description | cadena | no |
Metadatos de la ficha. |
quantity | entero > 0 | no |
1 por defecto. |
unit_price | cadena decimal | no |
P. ej. "19.90". Como cadena, igual que todos los importes. |
Ejemplo completo
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"
}
}
Pedido registrado:
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
}
Misma petición reproducida:
HTTP/1.1 200 OK
{ "…": "…", "idempotent": true }
La dirección de correo nunca se devuelve, aunque acabe de transmitirla: todo dato devuelto es un dato que puede filtrarse en sus propios registros.
Valores posibles de status
| Valor | Significado |
pending | Recibido, a la espera de planificación. |
scheduled | Solicitud programada. |
solicited | Petición de reseña enviada al cliente. |
reviewed | El cliente ha enviado su reseña. |
cancelled | Anulado antes del envío. |
expired | Plazo de envío transcurrido sin reseña. |
Errores
Este endpoint es el único servido por API Platform: sus errores de validación llegan por tanto en forma de lista de violations, y no en el sobre { "error": … } del resto de la API. Su código debe aceptar ambas formas.
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."
}
]
}
| Estado | Causa | Qué hacer |
| 401 |
Clave ausente, no válida, o firma rechazada. |
Véase §2. |
| 422 |
Falta un campo o está mal formado — véase violations. |
Corregir el campo designado por propertyPath. |
| 422 |
experienced_at está en el futuro (más allá de un día de margen). |
Comprobar la zona horaria del servidor: casi siempre es de ahí de donde viene el desfase. |
| 422 |
experienced_at se remonta a más de 90 días. |
Véase el recuadro de abajo. Para retomar un histórico, póngase en contacto con el soporte. |
| 422 |
Ningún establecimiento corresponde a location_id. |
Crear el establecimiento en el área del comerciante, u omitir el campo. |
| 415 |
Cabecera Content-Type ausente o inesperada. |
Enviar Content-Type: application/json. |
Por qué se rechazan los pedidos de más de 90 días
El escenario de siniestro es conocido: se instala un módulo y empuja tres años de histórico de golpe. Miles de invitaciones salen hacia direcciones caducadas, la tasa de rebote se dispara — y como todos los correos salen de nuestro dominio, es la entregabilidad de todos los comerciantes la que se hunde, no solo la del recién llegado.
El rechazo se pronuncia en la entrada, con un mensaje explícito, en lugar de en la planificación: el integrador lo entiende de inmediato en lugar de ver sus pedidos desaparecer en silencio.
GET
/api/v1/cms/reviews
Clave de API
Plan de pago
Lista las reseñas del comerciante, de la más reciente a la más antigua, con la respuesta publicada y la eventual denuncia de cada una. Es este endpoint el que permite volcar las reseñas en un ERP, un CRM o una herramienta de atención al cliente.
| Parámetro | Por defecto | Descripción |
type | merchant |
merchant para las reseñas del establecimiento, product para las reseñas de producto. |
status | todos |
published, pending, awaiting_email, rejected, disputed, withdrawn. Un valor desconocido se ignora — el filtro entonces no se aplica, en lugar de devolver un error. |
page | 1 | Número de página. |
per_page | 25 |
De 1 a 100. Por encima, el valor se reduce a 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
}
Las tres fechas, y por qué son tres
experienced_at (la experiencia vivida), submitted_at (el envío) y published_at (la puesta en línea) son tres cosas distintas, y la AFNOR obliga a poder distinguirlas. Un integrador que las confunde muestra «hace 3 días» sobre una experiencia de hace tres semanas. published_at vale null mientras la reseña no esté publicada.
order_reference recoge su external_order_id: es lo que vincula la reseña al pedido en su sistema. Vale null para una reseña enviada fuera de una solicitud.
Sincronización incremental
Consulte con status=published y compare published_at con la última pasada: volcar todo el histórico en cada ejecución funciona los primeros meses, y luego se convierte en una consulta de varios miles de filas cada hora. La paginación empieza en 1 y el campo total da el número de reseñas que corresponden al filtro, no el número de páginas.
| Estado | Código | Causa |
| 402 | plan_required |
El plan del comerciante no incluye la API de reseñas. Las reseñas siguen siendo legibles sin clave mediante la API pública — que no es lo mismo: esa sirve la visualización pública, no la exportación. |
POST
/api/v1/cms/reviews/{uuid}/response
Firma exigida
Plan de pago
Publica una respuesta pública a una reseña del establecimiento, o actualiza la que ya existe. Una reseña solo lleva una respuesta: reemitir sustituye el texto.
| Campo | Tipo | Oblig. | Descripción |
content | cadena | sí |
Texto de la respuesta. Truncado a 3000 caracteres sin error — compruebe la longitud por su parte si el corte le molesta. |
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
}
}
Lea published antes de anunciar nada
El comerciante ajusta en su área si las respuestas escritas por un programa salen directamente o esperan su relectura. Ese ajuste vive en la cuenta y no es un parámetro de la petición: si quien llama pudiera elegir por sí mismo si debe ser releído, la garantía no valdría nada.
Consecuencia para su interfaz: una respuesta aceptada no es necesariamente visible. published: false significa «guardada como borrador, pendiente de validar en el área del comerciante» — dígalo, en lugar de mostrar un «publicado» que la página pública desmentirá.
201 en la creación, 200 en la actualización; el campo created recoge la misma información en el cuerpo. Una respuesta ya publicada sigue siéndolo: una actualización nunca la devuelve a borrador, lo que la haría desaparecer de la página sin que nadie lo haya decidido.
| Estado | Código | Causa |
| 402 | plan_required | Plan sin respuesta a las reseñas. |
| 404 | review_not_found |
Identificador desconocido, mal formado, o perteneciente a otro comerciante — los tres casos son indistinguibles, y lo impone la compartimentación. |
| 422 | content_required | content ausente o vacío. |
POST
/api/v1/cms/reviews/{uuid}/report
Firma exigida
Denuncia una reseña para moderación. La reseña pasa al estado «impugnada» y el expediente entra en la cola de instrucción.
| Campo | Tipo | Oblig. | Descripción |
reason | enumeración | sí |
Motivo, que hay que elegir en la lista siguiente. |
detail | cadena | no |
Precisiones para el moderador, truncadas a 1000 caracteres. Aquí es donde se escribe «pedido n.º X, jamás entregado en esa dirección» — una denuncia motivada se instruye más deprisa. |
Motivos admisibles
| Valor | Cuándo invocarlo |
inappropriate_content | Insulto, discurso de odio, contenido ilícito. |
spam_or_advertising | Publicidad, enlace comercial, contenido automatizado. |
off_topic | Sin relación con la experiencia vivida — el transportista, el tiempo. |
conflict_of_interest | Competidor, antiguo empleado, reseña remunerada. |
personal_data_disclosure | La reseña expone datos personales. |
Una puntuación baja no es un motivo
Ningún motivo permite impugnar una reseña por su puntuación, y no es un olvido: es esa prohibición la que marca la diferencia entre una plataforma de reseñas y un escaparate. Una denuncia mal motivada se rechaza, y la reseña sigue en línea.
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 vale siempre true, y el campo existe para que ninguna interfaz se diseñe suponiendo lo contrario: la reseña sigue siendo pública durante toda la instrucción. Retirarla ante una simple denuncia equivaldría a dejar que el comerciante devalúe lo que le desagrada — Google lo prohíbe explícitamente, la AFNOR también. La respuesta es un 202: la solicitud queda registrada, no resuelta.
| Estado | Código | Causa |
| 404 | review_not_found | Identificador desconocido, mal formado, o fuera de su cuenta. |
| 409 | already_reported | Ya hay una denuncia abierta sobre esta reseña. |
| 422 | invalid_reason |
Motivo ausente o fuera de lista. El mensaje recuerda los valores admitidos. |
5. API pública
Solo lectura, sin autenticación, en /api/v1/public/. Es lo que consumen los widgets, y lo que puede consumir cualquier visualización a medida.
El {slug} de las rutas es el identificador público del comerciante — el de su página pública, visible en urls.profile que devuelve /cms/me.
Lo que protege una API sin clave
No hay identidad que comprobar: este código se ejecuta en casa de los visitantes de una tienda, ningún secreto puede vivir ahí. La protección descansa por tanto en otras tres cosas, que hay que conocer antes de integrar.
-
De aquí no sale ningún dato sensible. Ni correo, ni huella de correo, ni referencia de pedido, ni identificador interno. Un widget muestra reseñas públicas; todo lo que sale por este canal es legible por cualquiera.
-
Caudal limitado a 60 peticiones por minuto y por dirección IP, en ventana deslizante. Una ficha de producto hace dos llamadas: eso deja 30 cargas por minuto desde una misma dirección — amplio para un visitante, estrecho para un aspirador de contenidos. Véase §9.
-
CORS restringido a los dominios declarados del comerciante. Un comodín
* autorizaría a cualquier sitio — competidor, comparador, falsificador — a mostrar las reseñas de cualquier comerciante como si fueran suyas.
CORS: qué hay que declarar para que el navegador acepte la respuesta
La cabecera Access-Control-Allow-Origin solo se pone si el origen que llama corresponde a un dominio conectado al comerciante indicado en la URL. Los subdominios se aceptan: un dominio declarado como ejemplo.es autoriza www.ejemplo.es y tienda.ejemplo.es.
Síntoma típico de un dominio no declarado: la petición sale, el servidor responde 200, y el navegador bloquea la lectura en la consola. El remedio está en el área del comerciante, no en el código.
Una llamada de servidor a servidor no está afectada: sin cabecera Origin, no hay control CORS. Ese caso lo cubre la limitación de caudal. El CORS protege al navegador de otro sitio, nunca al dato en sí.
Caché
Todas las respuestas son públicas y se guardan en caché: 60 segundos para las reseñas y las puntuaciones, 300 segundos para los ajustes de visualización. Es lo que permite absorber el tráfico de una tienda en promoción sin dimensionar para el pico. No construya una visualización que suponga la aparición instantánea de una reseña publicada.
GET
/api/v1/public/merchants/{slug}/score
Sin autenticación
Puntuación global de la tienda y reparto por puntuación.
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 se devuelve explícitamente en lugar de sobreentenderse: un integrador que programa «sobre 10» porque su anterior proveedor lo era produce una visualización falsa que nadie relee. count solo cuenta las reseñas públicamente visibles.
404 merchant_not_found si el slug es desconocido.
GET
/api/v1/public/merchants/{slug}/reviews
Sin autenticación
Reseñas sobre el establecimiento, de la más reciente a la más antigua.
| Parámetro | Por defecto | Descripción |
page | 1 | Número de página. |
per_page | 10 |
De 1 a 50. Por encima, se reduce a 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
}
El orden cronológico está impuesto, no elegido
No hay parámetro de ordenación, y no lo habrá: la AFNOR (§6.3) exige el orden cronológico inverso como visualización por defecto. Proponer «los mejor puntuados primero» como ordenación inicial sería una presentación sesgada. Una ordenación en JavaScript sobre la página recibida es responsabilidad suya, no nuestra.
Lo que su visualización debe recoger
-
Dos fechas como mínimo — la de la experiencia y la de la publicación. Es una obligación de visualización, y solo la API puede facilitárselas. Las fechas públicas se reducen al día (
2026-08-06): ningún widget muestra la hora.
verified_purchase — la reseña está vinculada a un pedido real. Es lo que distingue una reseña recogida de una publicada espontáneamente.
-
disputed — la reseña está impugnada y su instrucción está en curso. Sigue mostrándose (véase §4.6); señálela en lugar de ocultarla.
reply — la respuesta del comerciante forma parte de la reseña para el lector. published_at lleva ahí la fecha de última modificación cuando la ha habido: mostrar la fecha original bajo un texto reescrito induciría a error.
photos — vacío en un comerciante cuyo plan no las sirve. Las reseñas siguen completas, solo faltan las imágenes.
En este canal no hay campo total: una página vacía significa que no queda nada por cargar. Es lo que hace el botón «ver más» del widget.
GET
/api/v1/public/products/{slug}/{productId}/score
Sin autenticación
Puntuación de un producto. {productId} es su identificador de catálogo, el transmitido en external_product_id — nunca lo reescribimos. Acuérdese de codificarlo si contiene caracteres reservados.
| Parámetro | Por defecto | Descripción |
variant | — |
Restringe la puntuación a una variante. Si falta, la puntuación abarca todas las variantes juntas — que es el comportamiento correcto mientras el visitante no haya elegido su talla. |
with_variants | false |
Añade el detalle por variante. Cuesta una consulta más: no lo active en la ficha de producto, que es la página más vista de la tienda. |
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 vale null cuando no se pide with_variants — es una ausencia de cálculo, no una ausencia de variantes.
GET
/api/v1/public/products/{slug}/{productId}/reviews
Sin autenticación
Reseñas de un producto. Misma estructura de respuesta y mismos parámetros de paginación que las reseñas del establecimiento, más el filtro variant — útil cuando un selector de talla quiere mostrar solo las reseñas de la variante elegida.
curl 'https://louis.guide/api/v1/public/products/tissufiesta/REF-42/reviews?variant=REF-42-ROUGE-L&per_page=5'
En un comerciante cuyo plan no incluya la visualización de reseñas de producto, prevea una visualización que se reduzca limpiamente en lugar de un marco vacío: el widget, por su parte, desaparece de la página.
GET
/api/v1/public/merchants/{slug}/photos
Sin autenticación
Fotos de clientes aprobadas, sin el texto de las reseñas. Es lo que alimenta un carrusel: sin este endpoint habría que cargar cincuenta reseñas completas — texto, fechas, puntuaciones — para quedarse solo con las imágenes, en una ficha de producto que ya carga la plantilla del comerciante.
| Parámetro | Por defecto | Descripción |
produit | — |
Restringe a un producto. Si falta, devuelve las fotos de toda la tienda — lo que alimenta un carrusel de página de inicio. |
limite | 24 |
De 1 a 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
}
]
}
Las URL son absolutas: este JSON lo lee JavaScript ejecutado en el dominio de la tienda, donde una URL relativa apuntaría a la tienda misma. Sírvase de width y height para reservar el espacio antes de cargar — de lo contrario la ficha de producto saltará ante los ojos del visitante.
En un comerciante cuyo plan no sirve las fotos, la respuesta es { "photos": [] } con estado 200, nunca un error: el carrusel desaparece limpiamente en lugar de mostrar un marco fallido.
GET
/api/v1/public/merchants/{slug}/display
Sin autenticación
Ajustes de visualización decididos por el comerciante en su área. Es lo que permite colocar las etiquetas de una vez por todas en una plantilla y luego encender, apagar o mover un elemento sin tocar el código de la tienda.
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/es/m/tissufiesta"
}
| Ajuste | Por defecto | Significado |
badge_flottant | false |
Insignia de puntuación fijada en una esquina de la pantalla. Apagada por defecto: se superpone a la página del comerciante, y nada debe aparecer en su sitio sin que lo haya pedido. |
badge_cote | droite | droite o gauche. |
badge_decalage | 16 | Separación en píxeles respecto al borde. |
seuil_avis | 1 |
Número de reseñas por debajo del cual la visualización desaparece. Véase §6: mostrar «ninguna reseña» es peor que no mostrar nada. |
etoiles_fiche | true | Estrellas en la ficha de producto. |
etoiles_vignettes | true | Estrellas en las miniaturas de los listados. |
onglet_avis | true | Pestaña «Reseñas» de la ficha de producto. |
bloc_accueil | true | Bloque de reseñas en la página de inicio. |
display está siempre completo, valores por defecto incluidos: su código no tiene que conocer nuestros valores por defecto ni copiarlos — el día en que uno cambie, los seguirá.
accent_color vale null cuando el comerciante no ha elegido color o cuando su plan ya no lo permite. Prevea siempre un color de reserva por su parte: es lo que hace el widget, cuyo tono solo existe si está declarado.
GET
/api/v1/public/health
Sin autenticación
Disponibilidad del servicio. Para consultarlo con una sonda o con el control de salud de un módulo.
HTTP/1.1 200 OK
{ "status": "ok" }
Deliberadamente mínimo: ningún acceso a base de datos, ninguna dependencia externa. Una lentitud de la base no debe disparar una falsa alerta de indisponibilidad — y a la inversa, este endpoint no dice nada del estado de la base. Para comprobar que una clave funciona, hay que llamar a /cms/ping.
6. Widgets de visualización
Cuatro elementos HTML que colocar en una plantilla. Un solo script que cargar, ninguna dependencia, ninguna configuración: la dirección de la API se deduce de la URL del propio script.
<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>
El script se carga una vez por página, donde se quiera; los elementos pueden colocarse antes que él. Se sirve bajo /widget/v1/: una evolución incompatible saldría en /v2/ y este seguiría sirviéndose tal cual — vive en plantillas que nadie va a actualizar.
Los cuatro elementos
| Elemento | Lo que muestra | Dónde colocarlo |
<avis-score> |
Puntuación media, estrellas, número de reseñas. |
Ficha de producto, cabecera de la tienda, página «quiénes somos». |
<avis-liste> |
Reseñas paginadas, con fotos y respuestas del comerciante. |
Pestaña «Reseñas» de una ficha de producto, página dedicada. |
<avis-carrousel> |
Solo las fotos de clientes, pulsables. |
Ficha de producto, página de inicio. |
<avis-flottant> |
Insignia de puntuación fijada en una esquina, pulsable. |
La plantilla común, una sola vez para todo el sitio. |
Atributos
| Atributo | Elementos | Por defecto | Función |
marchand | todos | — |
Obligatorio. Identificador público del comerciante, el de su página pública. |
produit |
score, lista, carrusel | — |
Su identificador de catálogo (external_product_id). Si falta, el elemento abarca toda la tienda. |
langue | todos | lang de la página |
Idioma de las etiquetas. En su defecto, el atributo lang del documento — que la plantilla ya rellena — y después el francés. Solo fr y en están realmente traducidos; cualquier otro valor recae en el francés en lugar de mostrar etiquetas traducidas a medias. |
mini | score | 1 |
Número de reseñas por debajo del cual el elemento desaparece. En 3, una ficha que solo tiene dos reseñas no muestra nada en lugar de una puntuación basada en casi nada. |
par-page | liste | 5 |
Reseñas cargadas de una vez; un botón «Ver más» carga el resto. |
max | carrousel | 12 |
Número de fotos, 50 como máximo. |
Ejemplo completo en una ficha de producto
<!-- 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 que no muestra nada no está necesariamente averiado
Tres situaciones hacen desaparecer un elemento, y cada vez es intencionado: ninguna reseña (o menos que el umbral), ninguna foto para el carrusel, y cualquier error de red o de servidor.
Mostrar «Aún no hay reseñas» en una ficha de producto es peor que no mostrar nada: el visitante concluye que nadie ha comprado. Y una franja de error en la tienda de un comerciante porque nuestra API tose sería indefendible — el elemento se retira de la maquetación, la ficha de producto queda intacta.
Consecuencia práctica para el integrador: no construya una maquetación que reserve una altura fija para un widget. Puede no ocupar nada en absoluto.
Lo que el comerciante gobierna sin usted
Los elementos leen /display al cargarse. Dos ajustes vienen de ahí en lugar de un atributo, y es deliberado: el comerciante puede colocar sus etiquetas de una vez por todas y luego cambiar de opinión desde su área sin reabrir la plantilla.
-
La insignia flotante — encendida o apagada, a la derecha o a la izquierda, con su separación.
<avis-flottant> colocado en la plantilla no muestra nada mientras el comerciante no la haya activado. Está apagada por defecto: nada debe aparecer en su sitio sin que lo haya pedido.
-
El color de marca — aplicado a las superficies que lo admiten. Las estrellas conservan su ámbar, igual que el verde de «Compra verificada» y el ámbar de «Impugnada»: esos colores portan un sentido, no decoran, y repintarlos haría ilegible la puntuación en un comerciante cuya marca sea amarillo pálido o blanca.
Los ajustes se piden una sola vez por página, incluso con cuatro elementos: la petición en curso se comparte. Eso es lo que evita que el widget sea el script que ralentiza la ficha de producto — reproche fundado que cabe hacer a la mayoría de los módulos de reseñas.
Aislamiento respecto a la plantilla
Cada elemento renderiza su contenido en un shadow DOM: el CSS de la plantilla no se desborda sobre el widget, y el del widget no se desborda sobre la tienda. Ninguno de los dos sería aceptable en el otro sentido.
Corolario que conviene conocer antes de intentarlo: sus reglas CSS no alcanzarán el interior de los widgets. La única personalización prevista es el color de marca, ajustado en el área del comerciante. Una visualización realmente a medida pasa por la API pública — que es exactamente para lo que está documentada.
Antes de pegar nada
Con PrestaShop y WooCommerce, el módulo coloca él mismo estas etiquetas, en el punto adecuado de la plantilla. El pegado manual se dirige a las demás plataformas y a las plantillas a medida — ver los módulos.
7. Servidor MCP
MCP (Model Context Protocol) expone las mismas capacidades que la API, en una forma que un asistente de IA puede descubrir por sí solo. Donde un desarrollador lee una documentación, escribe la autenticación e interpreta el JSON, el asistente pide la lista de herramientas, lee sus descripciones y las llama.
En concreto: el comerciante conecta su asistente a este servidor y luego escribe «¿qué reseñas no tienen respuesta todavía?» o «responde a esta disculpándote por el retraso». Nadie ha escrito código de integración.
| Valor |
| Dirección | https://louis.guide/api/v1/mcp |
| Transporte | JSON-RPC 2.0 sobre HTTP, en POST |
| Versión del protocolo | 2024-11-05 |
| Servidor anunciado | avis-clients, versión 1.0.0 |
| Capacidades | tools — ni recursos, ni prompts |
| Autenticación |
Token portador (§7.1) o clave de API + firma HMAC (§7.2) |
| Plan | De pago — si no, error JSON-RPC -32001 |
7.1 Conectar un asistente: el token
Es la vía normal, y la única que no requiere nada instalado. El comerciante crea un token en su área — Ajustes · Recogida, sección «Conectar un asistente» — y luego lo pega en la configuración de su asistente junto con la dirección del servidor.
POST https://louis.guide/api/v1/mcp
Authorization: Bearer mcp_live_…
Content-Type: application/json
Forma habitual de los archivos de configuración de un cliente MCP:
{
"mcpServers": {
"avis-clients": {
"url": "https://louis.guide/api/v1/mcp",
"headers": { "Authorization": "Bearer mcp_live_…" }
}
}
}
Verificación en un solo comando, antes de conectar nada:
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"}'
Lo que el token puede y lo que no puede
| Propiedad | Comportamiento |
| Alcance |
Únicamente /api/v1/mcp. Presentado en la API CMS, ni siquiera se examina: ni transmisión de pedidos, ni exportación de reseñas, ni conexión. |
| Escritura |
Prohibida por defecto. El comerciante marca explícitamente «autorizar la redacción de respuestas» al crearlo. Sin ella, la herramienta repondre_a_un_avis ni siquiera aparece en tools/list — el asistente no la propondrá por tanto. |
| Vida útil |
Un año, después deja de valer. Se vuelve a crear en diez segundos. |
| Revocación |
Inmediata y definitiva, token a token, sin tocar las claves de API ni los módulos del comerciante. |
| Conservación |
Mostrado una sola vez. Solo guardamos una huella: nadie puede volver a mostrarlo, nosotros incluidos. |
| Número |
Tres tokens válidos como máximo por cuenta. |
Un token portador viaja: trátelo como una contraseña
A diferencia del secreto HMAC, sale en cada petición y vive en la configuración de un servicio que no controlamos. Es el precio de la conexión directa, y por eso está compartimentado, caduca, es revocable y va mudo en escritura por defecto. No lo ponga nunca en una URL ni en un repositorio de código: las URL acaban en los registros de todos los intermediarios atravesados.
Los intentos están acotados a 20 fallos por cuarto de hora y por dirección IP — más allá, la respuesta es un 429.
7.2 Alternativa: clave de API y firma HMAC
El mismo endpoint acepta la autenticación descrita en §2: clave de API y firma HMAC. Tiene una ventaja real — el secreto nunca sale del servidor del comerciante — y un inconveniente que la reserva a los integradores: ningún cliente MCP sabe recalcular un HMAC en cada llamada, solo ponen cabeceras fijas.
Hace falta por tanto un puente: un pequeño programa lanzado por el asistente, que recibe el JSON-RPC por su entrada estándar, lo firma, lo envía y devuelve la respuesta. Node.js 18 o más reciente, ninguna dependencia. Guárdelo como 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');
}
}
});
Declaración del lado del cliente MCP (forma habitual de los archivos de configuración):
{
"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"
}
}
}
}
El secreto no sale de la máquina. Sirve para firmar localmente; lo que sale a la red es la firma. Una ruta absoluta es indispensable: el asistente no lanza el programa desde la carpeta en la que usted lo escribió.
Para comprobar el puente antes de conectar nada, envíele una línea a mano. Debe volver una lista de herramientas:
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étodos
| Método | Efecto |
initialize |
Anuncia la versión del protocolo, las capacidades y la identidad del servidor. |
tools/list | Catálogo de las herramientas y de sus esquemas de entrada. |
tools/call | Ejecuta una herramienta — params.name y params.arguments. |
notifications/initialized, ping | Confirmados con un resultado vacío. |
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 Las tres herramientas
Dos leen, una escribe. La línea divisoria no es técnica: leer reseñas no presenta riesgo alguno, mientras que publicar una respuesta hace hablar al comerciante en público en una página que alojamos nosotros — una formulación desafortunada en una reseña delicada, y hay una captura de pantalla circulando.
Por eso tools/list devuelve solo dos herramientas cuando quien llama presenta un token de solo lectura. No codifique por tanto la lista en duro: pídala, y anuncie al comerciante únicamente lo que contiene.
herramienta
lister_avis
Reseñas publicadas sobre el establecimiento, de la más reciente a la más antigua. La herramienta que el asistente llama para «muéstrame los clientes descontentos» o «¿qué no tiene respuesta todavía?».
| Argumento | Tipo | Por defecto | Efecto |
note_max | entero 1–5 | — |
Solo devuelve las reseñas cuya puntuación sea menor o igual. |
sans_reponse | booleano | false |
Descarta las reseñas a las que ya se ha publicado una respuesta. |
limite | entero 1–50 | 20 |
Número de reseñas leídas. |
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "lister_avis",
"arguments": { "note_max": 3, "sans_reponse": true, "limite": 10 }
}
}
El resultado es un bloque de texto que contiene JSON — es la forma que el protocolo prevé para un resultado estructurado, y la que los asistentes saben leer:
{
"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 filtra después del límite, no antes. Pedir 20 reseñas sin respuesta lee las 20 últimas reseñas publicadas y luego retira las ya tratadas: el resultado puede contener muchas menos, y total lo dice. Suba limite para ensanchar la ventana de lectura.
Aquí solo salen las reseñas publicadas: ni las pendientes, ni las rechazadas, ni las retiradas. Para esas está GET /cms/reviews con su filtro status.
herramienta
resume_reputation
Visión de conjunto, sin argumento. Lo que el asistente llama para «¿cómo va mi reputación?».
{
"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 cuenta las reseñas sobre el establecimiento; avis_produit cuenta por separado las que se refieren a un artículo. Sumarlas daría un total que no corresponde a ninguna puntuación mostrada.
herramienta
repondre_a_un_avis
Escritura pública
| Argumento | Tipo | Oblig. | Efecto |
avis_id | cadena | sí | Identificador de la reseña, tal como lo devuelve lister_avis. |
contenu | cadena | sí |
Texto de la respuesta, truncado a 3000 caracteres. |
{
"avis_id": "9f1c2b3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
"publiee": false,
"message": "Respuesta guardada como borrador. El comerciante debe validarla en su área antes de que aparezca."
}
Publicada o en borrador: lo decidió el comerciante, no la llamada
El comerciante ajusta en su área si las respuestas redactadas por un asistente salen directamente o esperan su relectura. Ninguno de los dos comportamientos es correcto en absoluto: quien recibe dos reseñas por semana quiere releer, quien recibe doscientas quiere que salgan.
Ese ajuste no es un parámetro de la petición, y eso es esencial: si el asistente pudiera elegir por sí mismo si debe ser releído, la garantía no valdría nada. El campo publiee y el campo message dicen lo que realmente ha ocurrido — un asistente debe comunicarlo tal cual al comerciante.
Una reseña ya respondida ve su respuesta sustituida. Una respuesta ya pública sigue siéndolo: una reescritura nunca la devuelve a borrador, lo que la haría desaparecer de la página sin decisión de nadie.
Lo que un asistente debe saber antes de redactar
-
Responder en el idioma de la reseña — para eso está el campo
langue. Una respuesta en francés bajo una reseña en neerlandés le dice al lector que no se ha leído.
-
No prometer nunca un gesto comercial que no se pueda cumplir: reembolso, reenvío, descuento. Esta respuesta es pública y oponible al comerciante.
-
Ninguna herramienta modifica ni elimina una reseña, y no las habrá. Un asistente al que se le pida «hacer retirar» una reseña solo puede denunciarla, con un motivo admisible (§4.6) — la puntuación no lo es.
7.5 Errores
Siempre un estado HTTP 200, incluso en caso de error: en JSON-RPC el error viaja en el cuerpo. Un 4xx haría creer al cliente que el transporte ha fallado, y la mayoría reintentaría en lugar de mostrar el mensaje.
La única excepción: la autenticación, rechazada antes de alcanzar la capa JSON-RPC. Responde con el sobre de error habitual de la API.
| Estado | Código | Causa |
| 401 | invalid_mcp_token |
Token desconocido, revocado o caducado — indistinguibles a propósito. El comerciante crea uno nuevo en su área. |
| 401 | códigos de §2 |
Vía HMAC: clave ausente, firma o marca de tiempo rechazadas. |
| 429 | too_many_attempts |
Más de 20 fallos de autenticación en quince minutos desde la misma dirección. Espere en lugar de reintentar en bucle. |
| Código | Significado | Qué hacer |
-32001 |
El plan del comerciante no incluye el acceso MCP. |
Pasar a un plan de pago; las reseñas siguen siendo legibles públicamente. |
-32601 | Método JSON-RPC desconocido. | Comprobar method. |
-32602 | Herramienta desconocida. | Llamar a tools/list, no codificar los nombres en duro. |
-32603 |
Error del puente local — red, secreto ausente. |
Este código viene del puente anterior, no del servidor. |
Los errores de negocio de una herramienta no son errores JSON-RPC: la respuesta sigue siendo un resultado, con isError: true y un objeto { "erreur": "…" } en el texto. Es el caso de una reseña no encontrada, de un contenido vacío, o de una respuesta intentada con un token de solo lectura. El asistente puede así explicárselo al comerciante en lugar de anunciar una avería.
8. Webhooks entrantes
No hay webhook saliente
La plataforma no le llama: no emite notificación alguna hacia su servidor cuando se publica una reseña, una respuesta o una decisión de moderación. Para seguir la actividad, consulte GET /api/v1/cms/reviews a su ritmo, filtrando por status=published y comparando published_at con su última pasada.
Una pasada cada hora conviene a la práctica totalidad de los usos: las reseñas no llegan al segundo, y el ritmo de publicación de una tienda se cuenta en unidades por día. Consultar cada minuto no hará aparecer nada más deprisa.
Los dos endpoints siguientes existen para llamantes concretos — nuestro operador de SMS y nuestro proveedor de pagos. Ningún integrador tiene que llamarlos, y ninguno puede hacerlo: ambos están cerrados por un secreto que no se distribuye.
POST
/api/v1/stripe/webhook
Firma de Stripe
Recibe los eventos de suscripción: checkout.session.completed, customer.subscription.created, .updated y .deleted. Es lo que hace pasar una cuenta al plan de pago, y por tanto lo que abre la API de reseñas y el acceso MCP.
La firma de la carga útil es lo único que protege esta ruta: sin ella, cualquiera podría enviar «suscripción activa» y regalarse el plan de pago con una sola petición curl. Se verifica antes de cualquier lectura del contenido, y un secreto ausente hace fallar la petición en lugar de dejarla pasar.
Los eventos no tratados se confirman con un 200 ({ "ignored": … }): Stripe considera toda respuesta que no sea 2xx como un fallo y reintenta durante tres días, con intervalos crecientes. Responder 404 a un tipo de evento que no nos sirve provocaría miles de reenvíos inútiles y luego la desactivación del punto de destino de su lado. A la inversa, un fallo real de tratamiento sí responde 500 — ahí queremos que Stripe reintente en lugar de dejar en plan gratuito a un comerciante que ha pagado.
POST
/api/v1/sms/inbound/{token}
Token compartido
Recibe los SMS entrantes, es decir los «STOP». El operador trata la palabra clave por su lado y deja de entregar — pero sin este endpoint no sabríamos nada: seguiríamos enviándole mensajes facturados y jamás recibidos, la oposición desaparecería el día de un cambio de operador, y no podríamos probar haberla respetado aunque la carga de la prueba nos incumbe.
El token viaja en la ruta, lo cual es más débil que una firma — pero es lo que las interfaces de los operadores franceses saben configurar. De ahí que este endpoint no pueda hacer otra cosa que añadir una oposición: lo peor que produce una llamada fraudulenta es impedir el envío de SMS a un número. Molesto, nunca peligroso, y reversible desde el back office.
La palabra clave se busca como primera palabra del mensaje, no en cualquier lugar dentro de él: quien escribe «que pare esto, esa tienda es pésima» no está pidiendo darse de baja, y darle de baja de oficio le quitaría el canal por el que se le contacta legítimamente. La oposición se registra para todos los comerciantes: el mensaje entrante no dice de qué tienda se trata — la persona responde al número de envío — y adivinarlo sería a la vez falso y peligroso.
Solo corta el canal SMS. El correo sigue saliendo: es el que lleva el enlace de gestión de la reseña y las menciones obligatorias, y una oposición expresada en un canal no vale para el otro.
9. Límites de caudal
Los límites se calculan sobre una ventana deslizante: sin contador que se ponga a cero en la hora en punto, y por tanto sin ráfaga posible al inicio de un periodo.
| Canal | Límite | Clave | Por qué esta cifra |
API pública /api/v1/public/ |
60 / minuto |
Dirección IP |
Una ficha de producto hace dos llamadas: eso deja 30 cargas por minuto desde una misma dirección. Amplio para un visitante, estrecho para un aspirador de contenidos. |
Disponibilidad /public/health |
ninguno |
— |
Excluido deliberadamente: la supervisión lo consulta de continuo, y limitarlo haría saltar falsas alertas de indisponibilidad. |
Apertura de conexión POST /pairing |
10 / hora |
Dirección IP |
Cada llamada crea una fila en la base de datos sin autenticación alguna. Diez bastan con holgura a un integrador que vuelve a empezar. |
Sondeo POST /pairing/{code} |
240 / 15 minutos |
Dirección IP |
Generoso a propósito: el módulo consulta cada cinco segundos mientras el comerciante crea su cuenta, confirma su dirección y valida. |
| Autenticación MCP por token |
20 fallos / 15 minutos |
Dirección IP |
Cuenta únicamente los fallos: una conexión que funciona nunca lo toca. Detiene el barrido de tokens hallados en otra parte y evita que un cliente mal configurado ahogue los registros. |
| Envío de una reseña |
10 / minuto |
Token de la invitación |
Por token y no por IP: varios clientes de una misma empresa comparten a menudo una única dirección de salida, y limitarlos juntos castigaría envíos legítimos. |
| Denuncia pública de una reseña |
5 / hora |
Dirección IP |
Abierta a todo lector (obligación DSA), y por tanto a todo robot. Cada envío crea una fila en la cola de moderación. |
La API CMS no está limitada, lo que no lo autoriza todo
Hoy no se aplica ningún límite de caudal a los endpoints firmados (/api/v1/cms/ y MCP): están autenticados, y el volumen real está acotado por la cuota de solicitudes del comerciante. Trate de todos modos el 429 — podrá añadirse un límite, y una integración que no sepa leerlo se romperá el día en que aparezca.
En la práctica: transmita los pedidos sobre la marcha en lugar de en lotes nocturnos de varios miles, y consulte las reseñas cada hora en lugar de cada minuto (§8). Un volumen anómalo es visible por nuestra parte y provoca un contacto, no un corte silencioso.
Lo que devuelve un exceso
HTTP/1.1 429 Too Many Requests
Retry-After: 37
X-RateLimit-Remaining: 0
{
"error": {
"code": "rate_limit_exceeded",
"message": "Demasiadas peticiones. Inténtelo de nuevo dentro de unos instantes."
}
}
Retry-After da el número de segundos que hay que esperar. Respételo: reintentar de inmediato solo consume la ventana siguiente. Una espera exponencial, con tope de un minuto, basta para todos los casos aquí descritos.
El sondeo de conexión es la excepción y responde { "status": "rate_limited" }: es el mismo suceso, expresado en el vocabulario de un endpoint que el módulo consulta en bucle.
10. Códigos de error comunes
Dos formatos, y solo uno que tratar en la mayoría de los casos
En toda la API, un error lleva el mismo sobre:
{
"error": {
"code": "invalid_api_key",
"message": "Clave de API desconocida, revocada o caducada."
}
}
El code es estable y está destinado a su programa; el message está destinado al humano que depura y puede reformularse sin aviso. No construya jamás su lógica sobre el texto del mensaje.
Una sola excepción: POST /cms/orders, servido por una capa distinta, devuelve sus errores de validación como una lista de violations. Un cliente robusto lee por tanto error.code si existe, y recurre a violations en caso contrario.
Estados HTTP
| Estado | Sentido | ¿Reintentar? |
| 200 | Éxito. En JSON-RPC, el error eventual está en el cuerpo. | — |
| 201 | Creado — pedido registrado, respuesta publicada. | — |
| 202 | Aceptado pero no resuelto: la denuncia entra en cola. | — |
| 400 | Petición ilegible. | No, corrija. |
| 401 | Clave ausente, no válida, o firma rechazada. | No, salvo reloj que resincronizar. |
| 402 | El plan del comerciante no incluye esta función. | No. |
| 404 | Recurso desconocido — o fuera de su cuenta. | No. |
| 409 | Conflicto: la acción ya se ha realizado. | No, es un estado, no una avería. |
| 415 | Content-Type ausente o inesperado. | No, envíe JSON. |
| 422 | Petición bien formada pero rechazada: campo ausente, valor fuera de límites. | No, corrija. |
| 429 | Caudal superado. | Sí, tras Retry-After. |
| 5xx | Incidencia de nuestro lado. | Sí, con espera creciente. |
Resumen de los códigos
| Código | Estado | Dónde | Causa y remedio |
missing_api_key | 401 | CMS, MCP |
Falta la cabecera X-Api-Key. |
invalid_api_key | 401 | CMS, MCP |
Clave desconocida, revocada o caducada — las tres deliberadamente indistinguibles. Compruébela en el área del comerciante. |
missing_signature, missing_timestamp |
401 | CMS, MCP |
Escritura sin firmar. Véase §2.1. |
invalid_timestamp | 401 | CMS, MCP |
X-Timestamp no es una marca de tiempo Unix en segundos — milisegundos o una fecha ISO, las más de las veces. |
timestamp_out_of_range | 401 | CMS, MCP |
Más de 300 s de desfase. El mensaje da la cifra exacta: sincronice el reloj (NTP). |
signature_mismatch | 401 | CMS, MCP |
Repase las cuatro trampas del §2.3, por orden. |
invalid_mcp_token | 401 | MCP |
Token portador desconocido, revocado o caducado — indistinguibles. El comerciante crea uno nuevo desde su área (§7.1). |
too_many_attempts | 429 | MCP |
Demasiados fallos de autenticación desde la misma dirección. |
plan_required | 402 | Reseñas, respuesta |
Función incluida a partir del plan de pago. La visualización pública de las reseñas sigue siendo gratuita. |
merchant_not_found | 404 | API pública |
Identificador público desconocido. Compruebe el slug, no el nombre comercial. |
review_not_found | 404 | Respuesta, denuncia |
Identificador desconocido, mal formado, o perteneciente a otro comerciante: la compartimentación impone no distinguirlos. |
already_reported | 409 | Denuncia |
Ya hay un expediente abierto sobre esta reseña. |
content_required | 422 | Respuesta |
content ausente o vacío tras la limpieza. |
invalid_reason | 422 | Denuncia |
Motivo fuera de lista. Una puntuación baja no es un motivo admisible (§4.6). |
invalid_request | 422 | Conexión |
shop_domain ausente o inutilizable. |
rate_limit_exceeded | 429 | API pública |
Véase §9 y la cabecera Retry-After. |
-32001 | 200 | MCP |
Plan sin acceso MCP (un error JSON-RPC, no HTTP). |
-32601, -32602 | 200 | MCP |
Método o herramienta desconocidos. Pase por tools/list. |
Tres síntomas, y por dónde empezar
| Síntoma | Causa más frecuente |
| «Ayer funcionaba todo, hoy todo da 401.» |
El reloj del servidor se ha desviado. GET /cms/ping devuelve server_time: compárelo con el suyo antes de buscar en otra parte. |
| «El ping pasa, pero todas mis escrituras fallan.» |
La clave es buena, la firma no — que es justamente lo que ese reparto de regímenes permite concluir. El cuerpo casi siempre se ha recodificado después de firmarse (§2.3). |
| «El widget no muestra nada, pero la API responde 200 en la consola.» |
Dominio no declarado del lado del comerciante: el navegador bloquea la lectura por falta de cabecera CORS. O, sencillamente, aún no hay reseñas: un widget vacío se retira de la página (§6). |
Si nada de esto encaja
Escríbanos desde el área del comerciante adjuntando tres cosas: la ruta llamada, la marca de tiempo de la petición y el código de error recibido. Con esos tres elementos la petición se encuentra en los registros; sin ellos, la única respuesta posible es pedírselos.
↑ Volver al principio