Skip to content

API documentation

Everything the platform exposes: sending orders, reading and replying to reviews, public display, and connecting an AI assistant. Nineteen endpoints, three channels, one key.

A merchant normally has nothing to code. The PrestaShop and WooCommerce modules do everything described here — see the modules. This page is for developers integrating a platform with no module, an ERP, a CRM or an in-house tool.

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.

UseAddress
API (all channels)https://louis.guide
Merchant areahttps://louis.guide/app
Widget scripthttps://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

  1. Create a merchant account on the merchant area.
  2. Confirm the email address, then open the API keys section.
  3. 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 methodRequired headersWhy
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

HeaderContent
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
PieceExact 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.

  1. 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.
  2. 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.
  3. 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".
  4. 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.

CodeCauseWhat 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.

FieldTypeRequiredDescription
shop_domainstringyes Shop domain, e.g. shop.example.com.
platformstringno prestashop, woocommerce, custom… unknown by default.
shop_namestringno Readable name of the shop, reused when the account is created.
platform_versionstringno Platform version, e.g. 8.1.6.
plugin_versionstringno Version of the calling module.
shop_uidstringno 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

StatusCodeCause
422invalid_requestshop_domain missing or unusable.
429rate_limitedMore 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.

FieldTypeRequiredDescription
poll_tokenstringyes 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

ValueMeaningWhat to do
pendingThe merchant has not decided yet.Keep polling.
approvedApproved. The response carries the credentials.Store them, stop polling.
rejectedThe merchant refused.Stop, and tell them so.
expiredFifteen 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_limitedToo 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.

CapabilityWhat 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

FieldTypeReq.Description
external_order_idstring (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.
customerobjectyes Identity of the customer to solicit — see the next table.
experienced_atISO 8601yes Date of delivery or consumption, with an explicit time zone. See the box below: this is not the order date.
sourceobjectyes Technical context of the emission — see further down.
itemsarray (200 max)no Items. Without them, no product review will be requested — only the shop review.
amountdecimal stringno Total amount, e.g. "129.90". Never a float.
currencyISO 4217no "EUR", "CHF"…
channelenumno ecommerce_order (default), pos_transaction, qr_scan, manual, csv_import, nfc.
location_idstring (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_daysinteger 0–365no 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_idstring (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_labelstring (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

FieldTypeReq.Description
emailemail (255)yes The only personal data in the clear that we accept. Erased after the submission window; only its fingerprint remains.
countryISO 3166-1 alpha-2no* *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.
localefr, en, nl, de, it, esno Language of the solicitation email. Failing that, the merchant's default language — soliciting a Dutch-speaking customer in French makes the response rate collapse.
phonestring (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_namestring (100)no Personalisation of the solicitation and displayed name of the author.
companystring (255)no Company name, for a business order.
postal_code, citystringno 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.

FieldTypeReq.Description
platformstring (50)yes prestashop, woocommerce, shopify, magento, custom…
platform_versionstring (30)no E.g. 8.1.6.
plugin_versionstring (30)no Version of your integration. To be incremented on every release.
triggerstring (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_uidstring (80)no* *Decisive in multi-shop setups. Identifier drawn once at installation and kept. See the box.
shop_idstring (50)no Shop identifier on the platform. Serves as a fallback when shop_uid is absent.
shop_namestring (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_idstringno 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

FieldTypeReq.Description
external_product_idstring (100)yes Identifier of the product in your catalogue.
namestring (255)yes Name of the product as shown to the customer.
variant_idstring (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_labelstring (255)no Readable label: "Colour: red, Size: L".
gtin8 to 14 digitsno* EAN-13 or converted UPC-A. Aggregation key across merchants, and a Google requirement for showing stars in its results.
upc, isbn, mpnstringno 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, brandstringno Internal reference and brand.
category_id, category_namestringno Main category in your catalogue.
product_url, image_urlURL (500)no Used in the solicitation email: a product image markedly improves the submission rate.
imageslist of URLs (10 max)no Additional images.
descriptionstring (5000)no Received, never redisplayed as such: it is your text, not the review author's. It serves to situate the product during moderation.
tagslist (30 max)no Product keywords, 60 characters each.
meta_title, meta_descriptionstringno Metadata of the product page.
quantityinteger > 0no 1 by default.
unit_pricedecimal stringno 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

ValueMeaning
pendingReceived, awaiting scheduling.
scheduledSolicitation scheduled.
solicitedReview request sent to the customer.
reviewedThe customer submitted their review.
cancelledCancelled before sending.
expiredSubmission 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."
    }
  ]
}
StatusCauseWhat 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.

ParameterDefaultDescription
typemerchant merchant for shop reviews, product for product reviews.
statusall published, pending, awaiting_email, rejected, disputed, withdrawn. An unknown value is ignored — the filter then does not apply, rather than returning an error.
page1Page number.
per_page25 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.

StatusCodeCause
402plan_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.

FieldTypeReq.Description
contentstringyes 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.

StatusCodeCause
402plan_requiredPlan without replies to reviews.
404review_not_found Identifier unknown, malformed, or belonging to another merchant — all three cases indistinguishable, and isolation requires it.
422content_requiredcontent 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.

FieldTypeReq.Description
reasonenumyes Reason, to be chosen from the list below.
detailstringno 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

ValueWhen to invoke it
inappropriate_contentInsult, hate speech, illegal content.
spam_or_advertisingAdvertising, commercial link, automated content.
off_topicUnrelated to the experience lived — the carrier, the weather.
conflict_of_interestCompetitor, former employee, paid review.
personal_data_disclosureThe 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.

StatusCodeCause
404review_not_foundIdentifier unknown, malformed, or outside your account.
409already_reportedA report is already open on this review.
422invalid_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.

ParameterDefaultDescription
page1Page number.
per_page10 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.

ParameterDefaultDescription
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_variantsfalse 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.

ParameterDefaultDescription
produit— Restricts to one product. Absent, returns photos from the whole shop — which feeds a home-page carousel.
limite24 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"
}
SettingDefaultMeaning
badge_flottantfalse 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_cotedroitedroite or gauche.
badge_decalage16Offset in pixels from the edge.
seuil_avis1 Review count below which the display disappears. See §6: showing "no reviews" is worse than showing nothing.
etoiles_fichetrueStars on the product page.
etoiles_vignettestrueStars on listing thumbnails.
onglet_avistrue"Reviews" tab of the product page.
bloc_accueiltrueReview 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

ElementWhat it showsWhere 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

AttributeElementsDefaultRole
marchandall— 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.
langueallpage 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.
miniscore1 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-pageliste5 Reviews loaded at a time; a "Show more" button loads the rest.
maxcarrousel12 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
Addresshttps://louis.guide/api/v1/mcp
TransportJSON-RPC 2.0 over HTTP, via POST
Protocol version2024-11-05
Announced serveravis-clients, version 1.0.0
Capabilitiestools — no resources, no prompts
Authentication Bearer token (§7.1) or API key + HMAC signature (§7.2)
PlanPaid — 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

PropertyBehaviour
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

MethodEffect
initialize Announces the protocol version, the capabilities and the server identity.
tools/listCatalogue of tools and their input schemas.
tools/callRuns a tool — params.name and params.arguments.
notifications/initialized, pingAcknowledged 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?".

ArgumentTypeDefaultEffect
note_maxinteger 1–5— Returns only reviews whose rating is less than or equal to this.
sans_reponsebooleanfalse Excludes reviews to which a reply has already been published.
limiteinteger 1–5020 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
ArgumentTypeReq.Effect
avis_idstringyesIdentifier of the review, as returned by lister_avis.
contenustringyes 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.

StatusCodeCause
401invalid_mcp_token Token unknown, revoked or expired — indistinguishable by design. The merchant creates a new one in their area.
401codes from §2 HMAC route: key missing, signature or timestamp refused.
429too_many_attempts More than 20 authentication failures in fifteen minutes from the same address. Wait rather than retry in a loop.
CodeMeaningWhat to do
-32001 The merchant's plan does not include MCP access. Move to a paid plan; the reviews remain publicly readable.
-32601Unknown JSON-RPC method.Check method.
-32602Unknown 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.

ChannelLimitKeyWhy 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

StatusMeaningRetry?
200Success. In JSON-RPC, any error is in the body.—
201Created — order recorded, reply published.—
202Accepted but not decided: the report enters the queue.—
400Unreadable request.No, fix it.
401Key missing, invalid, or signature refused.No, unless a clock needs resyncing.
402The merchant's plan does not include this feature.No.
404Unknown resource — or outside your account.No.
409Conflict: the action has already been performed.No, it is a state, not a failure.
415Content-Type missing or unexpected.No, send JSON.
422Well-formed request but refused: missing field, value out of bounds.No, fix it.
429Rate limit exceeded.Yes, after Retry-After.
5xxIncident on our side.Yes, with increasing backoff.

Code summary

CodeStatusWhereCause and remedy
missing_api_key401CMS, MCP Header X-Api-Key missing.
invalid_api_key401CMS, MCP Key unknown, revoked or expired — all three deliberately indistinguishable. Check it in the merchant area.
missing_signature, missing_timestamp 401CMS, MCP Unsigned write. See §2.1.
invalid_timestamp401CMS, MCP X-Timestamp is not a Unix timestamp in seconds — milliseconds or an ISO date, most often.
timestamp_out_of_range401CMS, MCP More than 300 s of skew. The message gives the exact figure: synchronise the clock (NTP).
signature_mismatch401CMS, MCP Work through the four traps in §2.3, in order.
invalid_mcp_token401MCP Bearer token unknown, revoked or expired — indistinguishable. The merchant creates a new one from their area (§7.1).
too_many_attempts429MCP Too many authentication failures from the same address.
plan_required402Reviews, reply Feature included from the paid plan onwards. Public display of reviews remains free.
merchant_not_found404Public API Unknown public identifier. Check the slug, not the trading name.
review_not_found404Reply, report Identifier unknown, malformed, or belonging to another merchant: isolation requires that these not be distinguished.
already_reported409Report A case is already open on this review.
content_required422Reply content missing or empty after cleanup.
invalid_reason422Report Reason outside the list. A low rating is not an admissible reason (§4.6).
invalid_request422Connection shop_domain missing or unusable.
rate_limit_exceeded429Public API See §9 and the Retry-After header.
-32001200MCP Plan without MCP access (a JSON-RPC error, not HTTP).
-32601, -32602200MCP Unknown method or tool. Go through tools/list.

Three symptoms, and where to start

SymptomMost 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