1. Introduction
The platform exposes three distinct channels. They share neither the same audience, nor the same authentication regime, nor the same limits. Picking the right one is an integration's first decision.
| Channel |
Prefix |
For whom |
Authentication |
| API CMS |
/api/v1/cms/ |
E-commerce modules, ERP, CRM, in-house tools |
API key + HMAC signature on writes |
| Public API |
/api/v1/public/ |
Display widgets, shop-side JavaScript |
None — rate limited, restricted CORS |
| MCP |
/api/v1/mcp |
AI assistants (Claude, ChatGPT, others) |
Same key, same signature as the CMS API |
Before writing a line of code: check that a module isn't enough
The PrestaShop and WooCommerce modules do everything this page describes: they send orders at the right moment, place the widget script in the theme, put stars on product pages and the review block, and handle request signing. The merchant pastes nothing and writes nothing.
Download the modules →
This documentation therefore addresses three cases: a platform we do not yet have a module for, a bespoke development, or plugging a third-party tool (ERP, help desk, AI assistant) into reviews already collected.
Base addresses
Every URL on this page is relative to the API address. A module should know only that one: the other addresses are returned to it by GET /api/v1/cms/me, which saves it from guessing them and lets us change them without updating anything on the merchants' side.
| Use | Address |
| API (all channels) | https://louis.guide |
| Merchant area | https://louis.guide/app |
| Widget script | https://louis.guide/widget/v1/avis.js |
Conventions
- Format — JSON in and out, UTF-8 encoded. The
Content-Type: application/json header is expected on any request carrying a body.
- Naming — lower snake case (
external_order_id, experienced_at), the dominant convention among the APIs that PHP and JavaScript integrators consume.
- Dates — ISO 8601 with an explicit time zone on input (
2026-08-01T14:22:00+02:00). On output, full dates use the same format; a review's public dates are reduced to the day (2026-08-01) because no widget displays the time.
- Amounts — sent as a string (
"129.90") and never as a float: a cent lost to rounding on one order becomes an invoicing discrepancy.
- Identifiers — the objects we create carry a permanent UUID. Yours (order, product, variant) stay yours: we never rewrite them.
-
Errors —
always the same envelope
{ "error": { "code": …, "message": … } }. The code is stable and meant for your program, the message for the human debugging. See §10.
-
Versioning —
the
/v1 in the path is a contract. An optional field may be added at any time; no existing field will be renamed, removed or made mandatory. A breaking change would ship as /v2, with the old version still served — modules run on merchants' servers and nobody can update them remotely.
Your code must therefore ignore fields it does not know rather than fail on sight of them.
Getting an API key
-
Create a merchant account on the merchant area.
- Confirm the email address, then open the API keys section.
- Write down the secret: it is shown only once. Once lost it cannot be recovered — you create a new key and revoke the old one.
An installation module does not need this: it opens a connection request itself, which the merchant approves in one click. See §3.
2. Authentication
The CMS API and the MCP server use the same mechanism: a key that says who is calling, and a signature that proves the caller holds the secret. These are two distinct things.
| HTTP method | Required headers | Why |
GET, HEAD |
X-Api-Key |
A read changes nothing: the key alone authorises it. |
POST, PUT, PATCH, DELETE |
X-Api-Key, X-Timestamp, X-Signature |
A write commits the merchant: it must be proven and non-replayable. |
The secret never travels
Only the signature travels. That closes three doors which require no compromise of the shop at all: passive leakage of the secret into an intermediary's logs, replay of an intercepted request, and tampering with the body in transit. It does not, however, protect a shop whose database has been stolen — against that, the defence is key rotation.
Never put the secret in a URL: URLs end up in the logs of every intermediary they pass through.
2.1 Signing, step by step
Step 1 — The three headers
| Header | Content |
X-Api-Key |
Public identifier of the key, as shown in the merchant area. |
X-Timestamp |
Unix timestamp in seconds, digits only. No milliseconds, no ISO date. |
X-Signature |
The literal prefix sha256= followed by the HMAC-SHA256 in lowercase hexadecimal. The prefix is part of the compared value: omitting it produces a rejection. |
Step 2 — Build the payload to sign
Four pieces concatenated with no separator, in this exact order:
charge = X-Timestamp
+ MÉTHODE HTTP en majuscules
+ chemin logique de la requête
+ corps brut de la requête
| Piece | Exact rule |
| Timestamp |
The string identical to the one sent in X-Timestamp. |
| Method |
POST, PUT… always uppercase. |
| Path |
The path without scheme, without host, without query string, starting with / — for example /api/v1/cms/orders. If the API is served from a subdirectory, that installation prefix does not enter the signature: it lives in the base address, not in the logical path. |
| Body |
The byte string exactly as it is sent. Serialise once, sign that string, send that string. Empty body → empty string. |
Step 3 — Compute
X-Signature = "sha256=" + HMAC_SHA256(charge, secret) // hexadécimal minuscule
Step 4 — Check your implementation against this example
These values are fixed and the signature shown really is the one for this data: if your code produces something else, the problem is in your code, not in ours.
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 !"}
Payload to sign (a single line, no added whitespace):
1786000000POST/api/v1/cms/reviews/9f1c2b3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d/response{"content":"Merci pour votre retour !"}
Expected result:
X-Signature: sha256=8a9c0de10516226f965465704902e00c666892b483b45b361f4536a6b2ee36e9
The same computation in one shell line:
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 and not echo: the latter adds a trailing newline, which changes the signature.
Step 5 — A complete call in 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 and not --data: the latter interprets certain characters and can alter the body sent, thereby invalidating the signature.
2.2 PHP example
The minimal client, with no dependency. It is the same mechanism as the PrestaShop and WooCommerce modules, reduced to the essentials.
<?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 The four traps
Four causes account for almost every signature_mismatch. Seen from the outside they all look alike — hence the value of ruling them out in this order.
- The body was re-encoded after signing. The most frequent case, and the hardest to see: an array serialised twice yields two different strings as soon as it contains an accent or a slash (
JSON_UNESCAPED_UNICODE, JSON_UNESCAPED_SLASHES, key order). Sign the string, send that string, never rebuild it.
- The signed path carries a prefix it should not have. The signed path is
/api/v1/cms/orders, even if the API is served from https://example.com/platform/api/v1/cms/orders. The installation prefix belongs to the base address. Conversely, do not sign the full URL with its scheme and host either.
-
The server clock has drifted.
Tolerance: 300 seconds of skew, in either direction. Beyond that the response is
timestamp_out_of_range, and its message states the measured skew in seconds — exactly the information to hand your hosting provider. This case often shows up as an integration that "was working yesterday".
- The method or the prefix is missing. The method enters the payload in uppercase, and the value of
X-Signature starts with sha256=. A bare HMAC, with no prefix, is rejected.
What the query string does not do
URL parameters (?page=2) do not enter the signed payload: only the path does. They are of no practical consequence, since the signed endpoints are all writes that carry their parameters in the body — but an implementation that added them to the payload would fail.
Replay and validity window
The signed payload covers the timestamp, the method, the path and the body. Omitting any one of them would open a hole: without the path, a signature valid for POST /orders would be replayable on DELETE /orders; without the timestamp, the request would be replayable indefinitely.
The 300-second window is what bounds replay: an intercepted request cannot be re-emitted beyond it. There is no dictionary of signatures already seen — within that window an identical request is therefore accepted twice. This has no effect on order transmission, which is idempotent by external_order_id: the second call receives the order already recorded and does not send a second email.
Authentication responses
All of these responses carry status 401.
| Code | Cause | What to do |
missing_api_key |
Header X-Api-Key missing. |
Add the header. |
invalid_api_key |
Key unknown, revoked or expired. The message is deliberately identical in all three cases: distinguishing them would allow bulk testing of which identifiers exist. |
Check the key in the merchant area, or create a new one. |
missing_signature |
Write with no X-Signature header. |
Sign the request (§2.1). |
missing_timestamp |
Write with no X-Timestamp header. |
Add the timestamp, and sign it. |
invalid_timestamp |
X-Timestamp is not a sequence of digits — milliseconds, ISO date or a sign. |
Send a Unix timestamp in seconds. |
timestamp_out_of_range |
More than 300 seconds of skew. The message gives the exact figure. |
Synchronise the server clock (NTP). |
signature_mismatch |
The signature does not match the expected payload. |
Work through the four traps in §2.3, in order. |
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"error": {
"code": "timestamp_out_of_range",
"message": "Timestamp out of tolerance: +412 s of skew with our server (maximum 300 s). Your server clock is probably out of sync."
}
}
3. Connecting a shop
These two endpoints let a module obtain a key without the merchant having to copy anything at all. The module opens a request, shows a link, the merchant approves in their browser, and the module receives its key and secret on the next read.
They are unauthenticated, by design. Security rests not on an identity but on three things: the request obtains nothing until a logged-in merchant has approved it, the polling token never leaves the shop's server, and the secret is handed over only once. The worst a malicious call can produce is a pending request nobody will approve — and which expires in fifteen minutes.
POST
/api/v1/pairing
No authentication
Opens a connection request and returns the approval link to present to the merchant.
| Field | Type | Required | Description |
shop_domain | string | yes |
Shop domain, e.g. shop.example.com. |
platform | string | no |
prestashop, woocommerce, custom… unknown by default. |
shop_name | string | no |
Readable name of the shop, reused when the account is created. |
platform_version | string | no |
Platform version, e.g. 8.1.6. |
plugin_version | string | no |
Version of the calling module. |
shop_uid | string | no |
Unique identifier drawn once when the module is installed. Strongly recommended in multi-shop setups: see §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 — to be opened in a new tab of the merchant's browser, outside their back office. That is where they sign in or create their account, then approve.
poll_token — to be kept server-side only. It must never appear in a page or in a URL: it is what will retrieve the secret.
code — displayable to the merchant, so they can check they are approving the right request.
Error codes
| Status | Code | Cause |
| 422 | invalid_request | shop_domain missing or unusable. |
| 429 | rate_limited | More than 10 openings per hour per IP. |
POST
/api/v1/pairing/{code}
No authentication
Queries the state of the request, and hands over the key once — and only once — the merchant has approved.
POST even though this is a read, because the call has a side effect: it consumes the secret. Over GET, a browser prefetcher or an antivirus following links would consume it in the module's place.
| Field | Type | Required | Description |
poll_token | string | yes |
The token received when the request was opened. |
curl -X POST 'https://louis.guide/api/v1/pairing/4K7M-9QR3' \
-H 'Content-Type: application/json' \
--data-raw '{"poll_token":"pt_8f2c1a7e5b2d48a6c3d9e0f1a2b3c4d5"}'
Awaiting approval:
HTTP/1.1 200 OK
{ "status": "pending" }
Approved — the credentials are handed over only on this call:
HTTP/1.1 200 OK
{
"status": "approved",
"api_key": "ak_live_5c2f81b0",
"secret": "sk_live_3f9c1a7e5b2d48a6",
"merchant": "tissufiesta"
}
Possible values of status
| Value | Meaning | What to do |
pending | The merchant has not decided yet. | Keep polling. |
approved | Approved. The response carries the credentials. | Store them, stop polling. |
rejected | The merchant refused. | Stop, and tell them so. |
expired | Fifteen minutes elapsed without a decision. | Open a new request. |
consumed |
The secret has already been handed over, and it never is twice. The module lost the response. |
Start the connection over — that is the safe behaviour. |
unknown |
Unknown code or wrong token. Deliberately indistinct: separating them would turn this endpoint into an oracle telling you which shops are connecting. |
Check the code / token pair. |
rate_limited | Too much polling (HTTP status 429). | Space out the calls. |
Store the secret immediately. It is transmitted only in that one response. A module that fails to persist it will have to make the merchant start the whole connection over.
Always 200, including for a waiting state. The module polls in a loop: an HTTP error code on a perfectly normal situation would raise alerts for nothing. Poll every five seconds; the limit is 240 calls per quarter-hour per IP — beyond that the response is { "status": "rate_limited" } with a 429 status.
4. CMS API (signed)
The channel for server-side integrations: e-commerce modules, ERP, CRM, in-house tools. All URLs are prefixed by https://louis.guide.
Reads: the key is enough. Writes: key + signature. The computation is detailed in §2. The entries below recall each one's regime with a pill.
What the API does not allow, and never will
No endpoint modifies or deletes a review. The merchant can reply publicly and report for moderation, nothing else — exactly what their own area allows. An API more permissive than the interface would be a back door in compliance, and it is the first thing an audit checks.
GET
/api/v1/cms/ping
API key
Checks that a key works. It is the first call to write, and the one to offer the merchant as a "Test the connection" button: better they discover a faulty key at configuration time than at the first order that fails to go out.
Unsigned, deliberately. A read changes nothing, and above all: this endpoint must stay usable to prove a key is good even while the HMAC implementation is still faulty. The ping passes, the write does not: the problem is in the signature, not in the key.
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 is returned for a precise reason: compare it with your server's clock. A skew greater than 300 seconds will make all your signed writes fail (§2.3), and this is where you see it before losing a day to it.
GET
/api/v1/cms/me
API key
Account status: merchant identity, plan capabilities, quota, and platform addresses.
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/en/m/tissufiesta"
},
"server_time": "2026-08-13T14:52:07+00:00"
}
Read the capabilities, not the plan name
The plan block exposes capabilities (can_…) alongside the plan code. Test the former: a module hard-coding if (plan === 'pro') will stop being correct the day a plan is added or renamed, for every merchant at once and with none of them able to fix it.
| Capability | What it governs |
can_display_product_reviews |
Display of product reviews — the stars on product pages. |
can_use_photos |
Serving customer photos through the API. They are collected from the free plan onwards but are only served on a paid one: on a free account the gallery answers an empty list, never an error. |
can_use_reviews_api |
Reading reviews through the API, replying to reviews, and MCP access. Order transmission is not concerned: it is included in every plan. |
can_remove_branding |
Removal of the platform mention on widgets and emails. |
The urls block saves you from guessing
Your integration should know only one address: the API's. The others — merchant area, widget script, merchant's public page — are returned here. A module that recomposes them from a single base assumes everything lives on the same host, which stops being true as soon as a channel moves to a subdomain, and produces dead links for every merchant already installed.
quota.remaining deserves a place in your interface: at zero, orders keep being accepted but no solicitation goes out. Warning at 90% consumption saves the merchant from discovering it in their statistics.
POST
/api/v1/cms/orders
Signature required
The central endpoint. It records an order and schedules the review request. Everything else on the platform flows from this call: without it there is no solicitation, no review, no rating.
Idempotent by external_order_id
Re-sending the same reference returns the already recorded order with a 200 status instead of 201, creating no duplicate and sending no second email to the customer. The response's idempotent field is then true. You may therefore retry freely after a network drop or a timeout — that behaviour is preferable to any home-made deduplication logic.
When to call
At the moment the experience is lived, not ordered: at delivery, at dispatch depending on your trade, or when the status that stands for it is reached. experienced_at carries that date, and it is what starts the solicitation delay.
Request body
Root
| Field | Type | Req. | Description |
external_order_id | string (100) | yes |
Reference of the order on your side. Idempotency key and proof of purchase retained for five years (AFNOR §6.3). Must be stable over time. |
customer | object | yes |
Identity of the customer to solicit — see the next table. |
experienced_at | ISO 8601 | yes |
Date of delivery or consumption, with an explicit time zone. See the box below: this is not the order date. |
source | object | yes |
Technical context of the emission — see further down. |
items | array (200 max) | no |
Items. Without them, no product review will be requested — only the shop review. |
amount | decimal string | no |
Total amount, e.g. "129.90". Never a float. |
currency | ISO 4217 | no |
"EUR", "CHF"… |
channel | enum | no |
ecommerce_order (default), pos_transaction, qr_scan, manual, csv_import, nfc. |
location_id | string (100) | no |
The location concerned, as declared by the merchant. An unknown value makes the request fail with a 422 rather than attaching the order to the wrong point of sale. |
solicitation_delay_days | integer 0–365 | no |
A delay specific to this order, overriding the account setting. Useful when the same seller ships a bouquet to be asked about tomorrow and a mattress to be asked about in a month. Out of bounds, the value is ignored and the account setting applies — an aberrant value must not lose an order. |
order_status_id | string (20) | no |
Status of the order on your side at the time of sending. Purely diagnostic — we do not interpret it — but it is the only information that lets us answer "why did this order trigger nothing?". |
order_status_label | string (120) | no |
Readable label of that status. |
experienced_at: the delivery date, not the order date
A parcel ordered on the 1st and delivered on the 6th carries the 6th. This is not a subtlety: two randomised trials covering more than 300,000 consumers (Jung, Ryu, Han & Cho, Journal of Marketing, 2023) establish that a solicitation sent before the customer could form an opinion has a negative effect on the submission rate. Anchoring the delay on the order date means soliciting systematically too early, by the whole delivery time.
It is also one of the three dates displayed publicly beside the review (AFNOR §6.3).
customer
| Field | Type | Req. | Description |
email | email (255) | yes |
The only personal data in the clear that we accept. Erased after the submission window; only its fingerprint remains. |
country | ISO 3166-1 alpha-2 | no* |
*Strongly recommended. Google computes its merchant ratings per country and discards reviews whose country is unknown. This information exists only at order time: once the address is purged, it is definitively unrecoverable, with no way to make it up. |
locale | fr, en, nl, de, it, es | no |
Language of the solicitation email. Failing that, the merchant's default language — soliciting a Dutch-speaking customer in French makes the response rate collapse. |
phone | string (32) | no |
Mobile number for SMS solicitation. International format (+33612345678) strongly recommended: it is the only unambiguous one. A national number is converted using country; with no known country it is discarded without failing the order. See the warning below. |
first_name, last_name | string (100) | no |
Personalisation of the solicitation and displayed name of the author. |
company | string (255) | no |
Company name, for a business order. |
postal_code, city | string | no |
Purged at the same time as the email address. |
Only send the mobile number if the merchant has subscribed to SMS. Without the option it is received and stored without any message going out: personal data collected with no purpose, which neither party could justify under inspection.
source — required
This block is not statistics. When a merchant writes "my reviews have stopped going out since the update", the answer is already in it: platform version, module version, triggering event. Making it optional would amount to never having it — integrators fill in what is required, not what is suggested.
| Field | Type | Req. | Description |
platform | string (50) | yes |
prestashop, woocommerce, shopify, magento, custom… |
platform_version | string (30) | no |
E.g. 8.1.6. |
plugin_version | string (30) | no |
Version of your integration. To be incremented on every release. |
trigger | string (100) | no |
Event behind the send, e.g. woocommerce_order_status_completed. Lets us understand why an order goes out too early or too late. |
shop_uid | string (80) | no* |
*Decisive in multi-shop setups. Identifier drawn once at installation and kept. See the box. |
shop_id | string (50) | no |
Shop identifier on the platform. Serves as a fallback when shop_uid is absent. |
shop_name | string (255) | no |
Readable name of this shop. Without it, the merchant finds a location called "3" in their area and has to guess which one it is. |
shop_group_id, lang_id | string | no |
Kept for diagnosis, never interpreted. lang_id separates nothing: the review's language comes from customer.locale. |
Multi-shop: shop_id is not enough
It equals "1" on any single-shop installation. A merchant running two sites under the same account — one brand per domain, a common case — would therefore send "1" from both: the two shops would merge into one location, the reviews of one would show on the page of the other, and the name kept would be that of the last order received. A defect observed in testing on two real PrestaShop installs.
shop_uid solves it: draw it once at installation, keep it. It survives a domain change as well as a key renewal — the two other discriminators one thinks of first, and which both move.
items[] — optional, 200 items at most
| Field | Type | Req. | Description |
external_product_id | string (100) | yes |
Identifier of the product in your catalogue. |
name | string (255) | yes |
Name of the product as shown to the customer. |
variant_id | string (100) | no* |
*The most important field in this list. Without it, the red chair and the blue chair share the same product key: their reviews mix and "the leg broke" no longer designates anything. Corresponds to id_product_attribute (PrestaShop), the variation (WooCommerce), the variant (Shopify). |
variant_label | string (255) | no |
Readable label: "Colour: red, Size: L". |
gtin | 8 to 14 digits | no* |
EAN-13 or converted UPC-A. Aggregation key across merchants, and a Google requirement for showing stars in its results. |
upc, isbn, mpn | string | no |
Kept separate from the GTIN because catalogues keep them in distinct columns. The ISBN is decisive for books, where the GTIN is often empty. |
sku, brand | string | no |
Internal reference and brand. |
category_id, category_name | string | no |
Main category in your catalogue. |
product_url, image_url | URL (500) | no |
Used in the solicitation email: a product image markedly improves the submission rate. |
images | list of URLs (10 max) | no |
Additional images. |
description | string (5000) | no |
Received, never redisplayed as such: it is your text, not the review author's. It serves to situate the product during moderation. |
tags | list (30 max) | no |
Product keywords, 60 characters each. |
meta_title, meta_description | string | no |
Metadata of the product page. |
quantity | integer > 0 | no |
1 by default. |
unit_price | decimal string | no |
E.g. "19.90". As a string, like every amount. |
Complete example
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"
}
}
Order recorded:
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
}
Same request replayed:
HTTP/1.1 200 OK
{ "…": "…", "idempotent": true }
The email address is never returned, even if you have just sent it: any data returned is data that can leak into your own logs.
Possible values of status
| Value | Meaning |
pending | Received, awaiting scheduling. |
scheduled | Solicitation scheduled. |
solicited | Review request sent to the customer. |
reviewed | The customer submitted their review. |
cancelled | Cancelled before sending. |
expired | Submission window elapsed with no review. |
Errors
This endpoint is the only one served by API Platform: its validation errors therefore arrive as a list of violations, and not in the { "error": … } envelope of the rest of the API. Your code must accept both shapes.
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."
}
]
}
| Status | Cause | What to do |
| 401 |
Key missing, invalid, or signature refused. |
See §2. |
| 422 |
A field is missing or malformed — see violations. |
Fix the field named by propertyPath. |
| 422 |
experienced_at is in the future (beyond one day of margin). |
Check the server time zone: that is almost always where the gap comes from. |
| 422 |
experienced_at is more than 90 days old. |
See the box below. To import a back catalogue, contact support. |
| 422 |
No location matches location_id. |
Create the location in the merchant area, or omit the field. |
| 415 |
Header Content-Type missing or unexpected. |
Send Content-Type: application/json. |
Why orders older than 90 days are refused
The disaster scenario is well known: a module gets installed and pushes three years of history at once. Thousands of invitations go out to stale addresses, the bounce rate explodes — and since every email goes out from our domain, it is the deliverability of all merchants that collapses, not just the newcomer's.
The refusal is pronounced at the gate, with an explicit message, rather than at scheduling time: the integrator understands immediately instead of watching their orders vanish in silence.
GET
/api/v1/cms/reviews
API key
Paid plan
Lists the merchant's reviews, newest first, with each one's published reply and possible report. This is the endpoint that lets you pull reviews into an ERP, a CRM or a help-desk tool.
| Parameter | Default | Description |
type | merchant |
merchant for shop reviews, product for product reviews. |
status | all |
published, pending, awaiting_email, rejected, disputed, withdrawn. An unknown value is ignored — the filter then does not apply, rather than returning an error. |
page | 1 | Page number. |
per_page | 25 |
From 1 to 100. Above that, the value is capped at 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
}
The three dates, and why there are three
experienced_at (the experience lived), submitted_at (the submission) and published_at (going live) are three different things, and AFNOR requires that they be distinguishable. An integrator who conflates them shows "3 days ago" on an experience three weeks old. published_at is null for as long as the review is not published.
order_reference carries your external_order_id: it is what ties the review to the order in your system. It is null for a review submitted outside any solicitation.
Incremental synchronisation
Query with status=published and compare published_at with the last pass: pulling the whole history on every run works for the first few months, then becomes a query of several thousand rows every hour. Pagination starts at 1 and the total field gives the number of reviews matching the filter, not the number of pages.
| Status | Code | Cause |
| 402 | plan_required |
The merchant's plan does not include the reviews API. The reviews remain readable without a key through the Public API — which is not the same thing: that one serves public display, not export. |
POST
/api/v1/cms/reviews/{uuid}/response
Signature required
Paid plan
Publishes a public reply to a shop review, or updates the existing one. A review carries only one reply: re-sending replaces the text.
| Field | Type | Req. | Description |
content | string | yes |
Text of the reply. Truncated at 3000 characters without error — check the length on your side if the cut bothers you. |
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
}
}
Read published before announcing anything
The merchant sets in their area whether replies written by a program go out directly or wait for their proofreading. That setting lives on the account and is not a request parameter: if the caller could choose for itself whether it must be proofread, the guarantee would be worth nothing.
Consequence for your interface: an accepted reply is not necessarily visible. published: false means "saved as a draft, to be approved in the merchant area" — say so, rather than showing a "published" that the public page will contradict.
201 on creation, 200 on update; the created field carries the same information in the body. A reply already published stays published: an update never sends it back to draft, which would make it vanish from the page with nobody having decided so.
| Status | Code | Cause |
| 402 | plan_required | Plan without replies to reviews. |
| 404 | review_not_found |
Identifier unknown, malformed, or belonging to another merchant — all three cases indistinguishable, and isolation requires it. |
| 422 | content_required | content missing or empty. |
POST
/api/v1/cms/reviews/{uuid}/report
Signature required
Reports a review for moderation. The review moves to the "disputed" status and the case enters the review queue.
| Field | Type | Req. | Description |
reason | enum | yes |
Reason, to be chosen from the list below. |
detail | string | no |
Details for the moderator, truncated at 1000 characters. This is where one writes "order no. X, never delivered to that address" — a motivated report is handled faster. |
Admissible reasons
| Value | When to invoke it |
inappropriate_content | Insult, hate speech, illegal content. |
spam_or_advertising | Advertising, commercial link, automated content. |
off_topic | Unrelated to the experience lived — the carrier, the weather. |
conflict_of_interest | Competitor, former employee, paid review. |
personal_data_disclosure | The review exposes personal data. |
A low rating is not a reason
No reason allows a review to be contested because of its rating, and that is not an oversight: it is the prohibition that makes the difference between a review platform and a shop window. A poorly motivated report is rejected, and the review stays online.
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 is always true, and the field exists so that no interface is designed assuming otherwise: the review stays public throughout the case. Taking it down on a mere report would amount to letting the merchant demote whatever displeases them — Google explicitly forbids it, AFNOR too. The response is a 202: the request is recorded, not decided.
| Status | Code | Cause |
| 404 | review_not_found | Identifier unknown, malformed, or outside your account. |
| 409 | already_reported | A report is already open on this review. |
| 422 | invalid_reason |
Reason missing or outside the list. The message recalls the accepted values. |
5. Public API
Read-only, unauthenticated, under /api/v1/public/. This is what the widgets consume, and what any bespoke display can consume.
The {slug} in the paths is the merchant's public identifier — the one from their public page, visible in urls.profile returned by /cms/me.
What protects an API with no key
There is no identity to check: this code runs on the machines of a shop's visitors, no secret can live there. Protection therefore rests on three other things, which you need to know before integrating.
-
No sensitive data comes out of here. No email, no email fingerprint, no order reference, no internal identifier. A widget displays public reviews; everything coming out of this channel is readable by anyone.
-
Rate limited to 60 requests per minute per IP address, over a sliding window. A product page makes two calls: that leaves 30 page loads per minute from a single address — ample for a visitor, tight for a content scraper. See §9.
-
CORS restricted to the merchant's declared domains. A
* wildcard would allow any site — competitor, comparison engine, counterfeiter — to display any merchant's reviews as if they were its own.
CORS: what to declare so the browser accepts the response
The Access-Control-Allow-Origin header is only set if the calling origin matches a domain connected to the merchant named in the URL. Subdomains are accepted: a domain declared as example.com authorises www.example.com and shop.example.com.
The typical symptom of an undeclared domain: the request goes out, the server answers 200, and the browser blocks the read in the console. The remedy is in the merchant area, not in the code.
A server-to-server call is not concerned: with no Origin header, there is no CORS check. Rate limiting covers that case. CORS protects the browser from another site, never the data itself.
Cache
All responses are public and cached: 60 seconds for reviews and ratings, 300 seconds for display settings. That is what absorbs the traffic of a shop running a promotion without sizing for the peak. Do not build a display that assumes a published review appears instantly.
GET
/api/v1/public/merchants/{slug}/score
No authentication
Overall rating of the shop and breakdown by rating.
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 }
}
}
Rather than being implied, scale is returned explicitly: an integrator who codes "out of 10" because their previous provider was produces a wrong display nobody proofreads. count counts only publicly visible reviews.
404 merchant_not_found if the slug is unknown.
GET
/api/v1/public/merchants/{slug}/reviews
No authentication
Reviews of the shop, newest first.
| Parameter | Default | Description |
page | 1 | Page number. |
per_page | 10 |
From 1 to 50. Above that, capped at 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
}
Chronological order is imposed, not chosen
There is no sort parameter, and there will not be one: AFNOR (§6.3) requires reverse chronological order as the default display. Offering "best rated first" as the initial sort would be a slanted presentation. Sorting in JavaScript on the page you received is your responsibility, not ours.
What your display must carry over
-
Two dates at minimum — that of the experience and that of publication. It is a display obligation, and only the API can give them to you. Public dates are reduced to the day (
2026-08-06): no widget displays the time.
verified_purchase — the review is tied to a real order. That is what distinguishes a collected review from a spontaneously posted one.
-
disputed — the review is contested and its case is under way. It stays displayed (see §4.6); flag it rather than hide it.
reply — the merchant's reply is part of the review for the reader. published_at there carries the last modification date when there has been one: showing the original date under a rewritten text would mislead.
photos — empty for a merchant whose plan does not serve them. The reviews remain complete, only the images are missing.
There is no total field on this channel: an empty page means there is nothing left to load. That is what the widget's "show more" button does.
GET
/api/v1/public/products/{slug}/{productId}/score
No authentication
Rating of a product. {productId} is your catalogue identifier, the one sent as external_product_id — we never rewrite it. Remember to encode it if it contains reserved characters.
| Parameter | Default | Description |
variant | — |
Restricts the rating to one variant. Absent, the rating covers all variants together — which is the right behaviour as long as the visitor has not chosen their size. |
with_variants | false |
Adds the per-variant breakdown. Costs one extra query: do not enable it on the product page, which is the most viewed page of the shop. |
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 is null when with_variants is not requested — that is an absence of computation, not an absence of variants.
GET
/api/v1/public/products/{slug}/{productId}/reviews
No authentication
Reviews of a product. Same response structure and same pagination parameters as shop reviews, plus the variant filter — useful when a size selector wants to show only the reviews of the chosen variant.
curl 'https://louis.guide/api/v1/public/products/tissufiesta/REF-42/reviews?variant=REF-42-ROUGE-L&per_page=5'
For a merchant whose plan does not include product review display, provide a display that degrades cleanly rather than an empty frame: the widget, for its part, disappears from the page.
GET
/api/v1/public/merchants/{slug}/photos
No authentication
Approved customer photos, without the review text. This is what feeds a carousel: without this endpoint you would have to load fifty complete reviews — text, dates, ratings — only to keep the images, on a product page already loading the merchant's theme.
| Parameter | Default | Description |
produit | — |
Restricts to one product. Absent, returns photos from the whole shop — which feeds a home-page carousel. |
limite | 24 |
From 1 to 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
}
]
}
The URLs are absolute: this JSON is read by JavaScript running on the shop's domain, where a relative URL would point at the shop itself. Use width and height to reserve the space before loading — otherwise the product page will jump before the visitor's eyes.
For a merchant whose plan does not serve photos, the response is { "photos": [] } with a 200 status, never an error: the carousel disappears cleanly instead of showing a failed frame.
GET
/api/v1/public/merchants/{slug}/display
No authentication
Display settings decided by the merchant in their area. This is what allows tags to be placed once and for all in a theme, then an element to be switched on, off or moved without touching the shop's code.
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/en/m/tissufiesta"
}
| Setting | Default | Meaning |
badge_flottant | false |
Rating badge pinned in a corner of the screen. Off by default: it overlays the merchant's page, and nothing should appear on their site unless they asked for it. |
badge_cote | droite | droite or gauche. |
badge_decalage | 16 | Offset in pixels from the edge. |
seuil_avis | 1 |
Review count below which the display disappears. See §6: showing "no reviews" is worse than showing nothing. |
etoiles_fiche | true | Stars on the product page. |
etoiles_vignettes | true | Stars on listing thumbnails. |
onglet_avis | true | "Reviews" tab of the product page. |
bloc_accueil | true | Review block on the home page. |
display is always complete, defaults included: your code does not have to know our default values, nor copy them — the day one changes, it follows.
accent_color is null when the merchant has not chosen a colour or when their plan no longer allows it. Always provide a fallback colour on your side: that is what the widget does, its tint existing only if declared.
GET
/api/v1/public/health
No authentication
Service availability. To be polled by a probe or by a module's health check.
HTTP/1.1 200 OK
{ "status": "ok" }
Deliberately minimal: no database access, no external dependency. Database slowness must not trigger a false outage alert — and conversely, this endpoint says nothing about the state of the database. To check that a key works, /cms/ping is the one to call.
6. Display widgets
Four HTML elements to place in a theme. One script to load, no dependency, no configuration: the API address is derived from the script's own URL.
<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>
The script is loaded once per page, wherever you like; the elements may be placed before it. It is served under /widget/v1/: a breaking change would ship as /v2/ and this one would still be served as is — it lives in themes nobody will update.
The four elements
| Element | What it shows | Where to place it |
<avis-score> |
Average rating, stars, review count. |
Product page, shop header, "about" page. |
<avis-liste> |
Paginated reviews, with photos and merchant replies. |
"Reviews" tab of a product page, dedicated page. |
<avis-carrousel> |
Customer photos alone, clickable. |
Product page, home page. |
<avis-flottant> |
Rating badge pinned in a corner, clickable. |
The shared layout, once for the whole site. |
Attributes
| Attribute | Elements | Default | Role |
marchand | all | — |
Required. Public identifier of the merchant, the one from their public page. |
produit |
score, list, carousel | — |
Your catalogue identifier (external_product_id). Absent, the element covers the whole shop. |
langue | all | page lang |
Language of the labels. Failing that, the document's lang attribute — which the theme already fills in — then French. Only fr and en are actually translated; any other value falls back to French rather than showing half-translated labels. |
mini | score | 1 |
Review count below which the element disappears. At 3, a page with only two reviews shows nothing rather than a rating based on almost nothing. |
par-page | liste | 5 |
Reviews loaded at a time; a "Show more" button loads the rest. |
max | carrousel | 12 |
Number of photos, 50 at most. |
Complete example on a product page
<!-- 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>
A widget showing nothing is not necessarily broken
Three situations make an element disappear, and each time it is intentional: no reviews (or fewer than the threshold), no photos for the carousel, and any network or server error.
Showing "No reviews yet" on a product page is worse than showing nothing: the visitor concludes nobody has ordered. And an error banner on a merchant's shop because our API is coughing would be indefensible — the element removes itself from the layout, the product page stays intact.
Practical consequence for the integrator: do not build a layout that reserves a fixed height for a widget. It may occupy nothing at all.
What the merchant controls without you
The elements read /display on load. Two settings come from there rather than from an attribute, and that is deliberate: the merchant can place their tags once and for all, then change their mind from their area without reopening the theme.
-
The floating badge — on or off, right or left, with its offset.
<avis-flottant> placed in the layout shows nothing until the merchant has enabled it. It is off by default: nothing should appear on their site unless they asked for it.
-
The brand colour — applied to the surfaces that can take it. The stars keep their amber, as do the green of "Verified purchase" and the amber of "Disputed": those colours carry meaning, they do not decorate, and repainting them would make the rating unreadable for a merchant whose brand is pale yellow or white.
The settings are requested only once per page, even with four elements: the in-flight request is shared. That is what keeps the widget from being the script that slows the product page down — a fair criticism of most review modules.
Isolation from the theme
Each element renders its content in a shadow DOM: the theme's CSS does not spill onto the widget, and the widget's does not spill onto the shop. Neither would be acceptable in the other direction.
A corollary worth knowing before you try: your CSS rules will not reach inside the widgets. The only customisation provided is the brand colour, set in the merchant area. A genuinely bespoke display goes through the Public API — which is exactly what it is documented for.
Before pasting anything
On PrestaShop and WooCommerce, the module places these tags itself, in the right spot of the theme. Manual pasting is for other platforms and bespoke themes — see the modules.
7. MCP server
MCP (Model Context Protocol) exposes the same capabilities as the API, in a form an AI assistant can discover on its own. Where a developer reads documentation, writes the authentication and interprets the JSON, the assistant asks for the list of tools, reads their descriptions and calls them.
In practice: the merchant connects their assistant to this server, then writes "which reviews have no reply yet?" or "reply to this one apologising for the delay". Nobody wrote any integration code.
| Value |
| Address | https://louis.guide/api/v1/mcp |
| Transport | JSON-RPC 2.0 over HTTP, via POST |
| Protocol version | 2024-11-05 |
| Announced server | avis-clients, version 1.0.0 |
| Capabilities | tools — no resources, no prompts |
| Authentication |
Bearer token (§7.1) or API key + HMAC signature (§7.2) |
| Plan | Paid — otherwise JSON-RPC error -32001 |
7.1 Connecting an assistant: the token
This is the normal route, and the only one requiring nothing installed. The merchant creates a token in their area — Settings · Collection, "Connect an assistant" section — then pastes it into their assistant's configuration along with the server address.
POST https://louis.guide/api/v1/mcp
Authorization: Bearer mcp_live_…
Content-Type: application/json
Usual shape of an MCP client's configuration file:
{
"mcpServers": {
"avis-clients": {
"url": "https://louis.guide/api/v1/mcp",
"headers": { "Authorization": "Bearer mcp_live_…" }
}
}
}
Check in one command, before connecting anything:
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"}'
What the token can and cannot do
| Property | Behaviour |
| Scope |
Only /api/v1/mcp. Presented on the CMS API, it is not even examined: no order transmission, no review export, no connection. |
| Writing |
Forbidden by default. The merchant explicitly ticks "allow replies to be drafted" at creation time. Without it, the repondre_a_un_avis tool does not even appear in tools/list — so the assistant will not offer it. |
| Lifetime |
One year, then it stops being valid. It takes ten seconds to recreate. |
| Revocation |
Immediate and final, token by token, without touching the API keys or the merchant's modules. |
| Storage |
Shown once only. We keep nothing but a fingerprint: nobody can display it again, ourselves included. |
| Number |
Three valid tokens at most per account. |
A bearer token travels: treat it like a password
Unlike the HMAC secret, it goes out with every request and lives in the configuration of a service we do not control. That is the price of a direct connection, and it is why it is scoped, expiring, revocable and mute on writes by default. Never put it in a URL nor in a code repository: URLs end up in the logs of every intermediary they pass through.
Attempts are capped at 20 failures per quarter-hour per IP address — beyond that, the response is a 429.
7.2 Alternative: API key and HMAC signature
The same endpoint accepts the authentication described in §2: API key and HMAC signature. It has a real advantage — the secret never leaves the merchant's server — and a drawback that reserves it for integrators: no MCP client knows how to recompute an HMAC on every call, they only set fixed headers.
A bridge is therefore needed: a small program launched by the assistant, which receives the JSON-RPC on its standard input, signs it, sends it and returns the response. Node.js 18 or newer, no dependency. Save it as 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');
}
}
});
Declaration on the MCP client side (usual shape of configuration files):
{
"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"
}
}
}
}
The secret does not leave the machine. It is used to sign locally; what goes out on the network is the signature. An absolute path is essential: the assistant does not launch the program from the folder where you wrote it.
To check the bridge before connecting anything, feed it one line by hand. A list of tools should come back:
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 Methods
| Method | Effect |
initialize |
Announces the protocol version, the capabilities and the server identity. |
tools/list | Catalogue of tools and their input schemas. |
tools/call | Runs a tool — params.name and params.arguments. |
notifications/initialized, ping | Acknowledged with an empty result. |
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 The three tools
Two read, one writes. The dividing line is not technical: reading reviews carries no risk, whereas publishing a reply makes the merchant speak in public on a page we host — one unfortunate turn of phrase on a sensitive review, and a screenshot goes round.
That is why tools/list returns only two tools when the caller presents a read-only token. So do not hard-code the list: ask for it, and announce to the merchant only what it contains.
tool
lister_avis
Published reviews of the shop, newest first. The tool the assistant calls for "show me the unhappy customers" or "what has no reply yet?".
| Argument | Type | Default | Effect |
note_max | integer 1–5 | — |
Returns only reviews whose rating is less than or equal to this. |
sans_reponse | boolean | false |
Excludes reviews to which a reply has already been published. |
limite | integer 1–50 | 20 |
Number of reviews read. |
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "lister_avis",
"arguments": { "note_max": 3, "sans_reponse": true, "limite": 10 }
}
}
The result is a text block containing JSON — that is the shape the protocol provides for a structured result, and the one assistants know how to read:
{
"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 filters after the limit, not before. Asking for 20 reviews without a reply reads the last 20 published reviews then removes those already handled: the result may contain far fewer, and total says so. Raise limite to widen the reading window.
Only published reviews come back here: neither pending, nor rejected, nor withdrawn ones. For those, use GET /cms/reviews and its status filter.
tool
resume_reputation
An overview, with no argument. What the assistant calls for "how is my reputation doing?".
{
"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 counts reviews of the shop; avis_produit counts separately those about an item. Adding them together would give a total matching no displayed rating.
tool
repondre_a_un_avis
Public write
| Argument | Type | Req. | Effect |
avis_id | string | yes | Identifier of the review, as returned by lister_avis. |
contenu | string | yes |
Text of the reply, truncated at 3000 characters. |
{
"avis_id": "9f1c2b3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
"publiee": false,
"message": "Reply saved as a draft. The merchant must approve it in their area before it appears."
}
Published or draft: the merchant decided that, not the call
The merchant sets in their area whether replies drafted by an assistant go out directly or wait for their review. Neither behaviour is right in the absolute: someone receiving two reviews a week wants to proofread, someone receiving two hundred wants them to go out.
This setting is not a request parameter, and that is essential: if the assistant could choose for itself whether it must be proofread, the guarantee would be worth nothing. The publiee field and the message field say what actually happened — an assistant must report that as it stands to the merchant.
A review that already has a reply sees it replaced. A reply that is already public stays public: a rewrite never sends it back to draft, which would make it vanish from the page with nobody having decided so.
What an assistant must know before drafting
-
Reply in the language of the review — the
langue field is there for that. A reply in French under a Dutch review tells the reader it was not read.
-
Never promise a commercial gesture that cannot be honoured: refund, replacement, discount. This reply is public and binding on the merchant.
-
No tool modifies or deletes a review, and none will. An assistant asked to "get a review taken down" can only report it, with an admissible reason (§4.6) — the rating is not one.
7.5 Errors
Always an HTTP 200 status, including on error: in JSON-RPC, the error travels in the body. A 4xx would make the client believe the transport had failed, and most would retry instead of showing the message.
The one exception: authentication, refused before reaching the JSON-RPC layer. It answers with the API's usual error envelope.
| Status | Code | Cause |
| 401 | invalid_mcp_token |
Token unknown, revoked or expired — indistinguishable by design. The merchant creates a new one in their area. |
| 401 | codes from §2 |
HMAC route: key missing, signature or timestamp refused. |
| 429 | too_many_attempts |
More than 20 authentication failures in fifteen minutes from the same address. Wait rather than retry in a loop. |
| Code | Meaning | What to do |
-32001 |
The merchant's plan does not include MCP access. |
Move to a paid plan; the reviews remain publicly readable. |
-32601 | Unknown JSON-RPC method. | Check method. |
-32602 | Unknown tool. | Call tools/list, do not hard-code the names. |
-32603 |
Local bridge error — network, missing secret. |
This code comes from the bridge above, not from the server. |
A tool's business errors are not JSON-RPC errors: the response is still a result, with isError: true and a { "erreur": "…" } object in the text. That covers a review not found, empty content, or a reply attempted with a read-only token. The assistant can then explain it to the merchant instead of announcing a breakdown.
8. Inbound webhooks
There is no outbound webhook
The platform does not call you: it emits no notification to your server when a review, a reply or a moderation decision is published. To follow activity, poll GET /api/v1/cms/reviews at your own pace, filtering on status=published and comparing published_at with your last pass.
An hourly pass suits almost every use: reviews do not arrive by the second, and a shop's publication rate is counted in units per day. Polling every minute will not make anything appear sooner.
The two endpoints below exist for specific callers — our SMS operator and our payment provider. No integrator has to call them, and none can: both are closed by a secret that is not distributed.
POST
/api/v1/stripe/webhook
Stripe signature
Receives subscription events: checkout.session.completed, customer.subscription.created, .updated and .deleted. This is what switches an account to a paid plan, and therefore what opens the reviews API and MCP access.
The payload signature is the only thing protecting this route: without it, anyone could post "subscription active" and give themselves the paid plan with a single curl request. It is checked before any reading of the content, and a missing secret makes the request fail rather than letting it through.
Unhandled events are acknowledged with a 200 ({ "ignored": … }): Stripe treats any non-2xx response as a failure and replays for three days with increasing intervals. Answering 404 to an event type we have no use for would cause thousands of pointless retries, then the disabling of the endpoint on their side. Conversely, a genuine processing failure does answer 500 — there we want Stripe to replay rather than leave a merchant who has paid on the free plan.
POST
/api/v1/sms/inbound/{token}
Shared token
Receives inbound SMS, that is to say the "STOP" messages. The operator handles the keyword on its side and stops delivering — but without this endpoint we would know nothing about it: we would keep sending it messages that are billed and never received, the opt-out would vanish the day we changed operator, and we could not prove we had honoured it even though the burden of proof falls on us.
The token travels in the path, which is weaker than a signature — but it is what French operators' interfaces know how to configure. Hence the fact that this endpoint can do nothing other than add an opt-out: the worst a fraudulent call produces is preventing SMS from being sent to a number. Annoying, never dangerous, and reversible from the back office.
The keyword is looked for as the first word of the message, not anywhere within it: someone writing "this needs to stop, that shop is awful" is not asking to unsubscribe, and unsubscribing them anyway would remove the channel through which they are legitimately contacted. The opt-out is recorded for every merchant: the inbound message does not say which shop it concerns — the person is replying to the sending number — and guessing would be both wrong and dangerous.
It cuts the SMS channel only. Email keeps going out: it is what carries the review management link and the mandatory notices, and an opt-out expressed on one channel does not hold for the other.
9. Rate limits
Limits are computed over a sliding window: no counter resetting on the hour, hence no burst possible at the start of a period.
| Channel | Limit | Key | Why this figure |
Public API /api/v1/public/ |
60 / minute |
IP address |
A product page makes two calls: that leaves 30 page loads per minute from a single address. Ample for a visitor, tight for a content scraper. |
Availability /public/health |
none |
— |
Deliberately excluded: it is polled continuously by monitoring, and throttling it would raise false outage alerts. |
Opening a connection POST /pairing |
10 / hour |
IP address |
Each call creates a database row with no authentication whatsoever. Ten is plenty for an integrator starting over. |
Polling POST /pairing/{code} |
240 / 15 minutes |
IP address |
Generous by design: the module polls every five seconds while the merchant creates an account, confirms the address and approves. |
| MCP token authentication |
20 failures / 15 minutes |
IP address |
Counts failures only: a working connection never touches it. Stops sweeps of tokens found elsewhere, and keeps a misconfigured client from drowning the logs. |
| Submitting a review |
10 / minute |
Invitation token |
By token rather than by IP: several customers of the same company often share one outbound address, and throttling them together would punish legitimate submissions. |
| Public report of a review |
5 / hour |
IP address |
Open to any reader (a DSA obligation), hence to any bot. Each submission creates a row in the moderation queue. |
The CMS API is not throttled, which does not permit everything
No rate limit is applied today to the signed endpoints (/api/v1/cms/ and MCP): they are authenticated, and the real volume is bounded by the merchant's solicitation quota. Handle the 429 anyway — a limit may be added, and an integration that cannot read one will break the day it appears.
In practice: send orders as they happen rather than in nightly batches of several thousand, and poll reviews hourly rather than by the minute (§8). Abnormal volume is visible on our side and triggers a conversation, not a silent cut-off.
What an overrun returns
HTTP/1.1 429 Too Many Requests
Retry-After: 37
X-RateLimit-Remaining: 0
{
"error": {
"code": "rate_limit_exceeded",
"message": "Too many requests. Try again in a few moments."
}
}
Retry-After gives the number of seconds to wait. Respect it: retrying immediately only consumes the next window. Exponential backoff, capped at one minute, is enough for every case described here.
Connection polling is the exception and answers { "status": "rate_limited" }: it is the same event, expressed in the vocabulary of an endpoint the module polls in a loop.
10. Common error codes
Two formats, and only one to handle in most cases
Everywhere in the API, an error carries the same envelope:
{
"error": {
"code": "invalid_api_key",
"message": "API key unknown, revoked or expired."
}
}
The code is stable and meant for your program; the message is meant for the human debugging and may be reworded without notice. Never build your logic on the message text.
One single exception: POST /cms/orders, served by a different layer, returns its validation errors as a list of violations. A robust client therefore reads error.code if present, and falls back to violations otherwise.
HTTP statuses
| Status | Meaning | Retry? |
| 200 | Success. In JSON-RPC, any error is in the body. | — |
| 201 | Created — order recorded, reply published. | — |
| 202 | Accepted but not decided: the report enters the queue. | — |
| 400 | Unreadable request. | No, fix it. |
| 401 | Key missing, invalid, or signature refused. | No, unless a clock needs resyncing. |
| 402 | The merchant's plan does not include this feature. | No. |
| 404 | Unknown resource — or outside your account. | No. |
| 409 | Conflict: the action has already been performed. | No, it is a state, not a failure. |
| 415 | Content-Type missing or unexpected. | No, send JSON. |
| 422 | Well-formed request but refused: missing field, value out of bounds. | No, fix it. |
| 429 | Rate limit exceeded. | Yes, after Retry-After. |
| 5xx | Incident on our side. | Yes, with increasing backoff. |
Code summary
| Code | Status | Where | Cause and remedy |
missing_api_key | 401 | CMS, MCP |
Header X-Api-Key missing. |
invalid_api_key | 401 | CMS, MCP |
Key unknown, revoked or expired — all three deliberately indistinguishable. Check it in the merchant area. |
missing_signature, missing_timestamp |
401 | CMS, MCP |
Unsigned write. See §2.1. |
invalid_timestamp | 401 | CMS, MCP |
X-Timestamp is not a Unix timestamp in seconds — milliseconds or an ISO date, most often. |
timestamp_out_of_range | 401 | CMS, MCP |
More than 300 s of skew. The message gives the exact figure: synchronise the clock (NTP). |
signature_mismatch | 401 | CMS, MCP |
Work through the four traps in §2.3, in order. |
invalid_mcp_token | 401 | MCP |
Bearer token unknown, revoked or expired — indistinguishable. The merchant creates a new one from their area (§7.1). |
too_many_attempts | 429 | MCP |
Too many authentication failures from the same address. |
plan_required | 402 | Reviews, reply |
Feature included from the paid plan onwards. Public display of reviews remains free. |
merchant_not_found | 404 | Public API |
Unknown public identifier. Check the slug, not the trading name. |
review_not_found | 404 | Reply, report |
Identifier unknown, malformed, or belonging to another merchant: isolation requires that these not be distinguished. |
already_reported | 409 | Report |
A case is already open on this review. |
content_required | 422 | Reply |
content missing or empty after cleanup. |
invalid_reason | 422 | Report |
Reason outside the list. A low rating is not an admissible reason (§4.6). |
invalid_request | 422 | Connection |
shop_domain missing or unusable. |
rate_limit_exceeded | 429 | Public API |
See §9 and the Retry-After header. |
-32001 | 200 | MCP |
Plan without MCP access (a JSON-RPC error, not HTTP). |
-32601, -32602 | 200 | MCP |
Unknown method or tool. Go through tools/list. |
Three symptoms, and where to start
| Symptom | Most frequent cause |
| "Everything worked yesterday, everything is 401 today." |
The server clock has drifted. GET /cms/ping returns server_time: compare it with yours before looking anywhere else. |
| "The ping works, but all my writes fail." |
The key is good, the signature is not — which is exactly what that split regime lets you conclude. The body has almost always been re-encoded after being signed (§2.3). |
| "The widget shows nothing, but the API answers 200 in the console." |
Domain not declared on the merchant side: the browser blocks the read for want of a CORS header. Or, quite simply, there are no reviews yet: an empty widget removes itself from the page (§6). |
If none of this fits
Write to us from the merchant area, attaching three things: the path called, the timestamp of the request, and the error code received. With those three, the request can be found in the logs; without them, the only possible answer is to ask you for them.
↑ Back to the top