Europa Specials How it works

How the Shoppable Engine works

A plain-language guide to what happens between a visitor seeing a product on Europa Specials and a vendor getting paid for it — including every URL format you'll actually run into.

1. The big picture

The Shoppable Engine embeds clickable product recommendations into Europa Specials video/content pages, tracks what happens after someone clicks one, and calculates what each vendor owes based on their contract terms — automatically, from the same click that sent the visitor to the vendor's site.

Visitor watches content Sees product tiles Clicks one Lands on vendor's site Vendor reports the sale We calculate what's owed

Everything downstream of that first click — attribution, reconciliation, reporting — depends on that click being logged reliably with enough context to trace it back to the right product, the right vendor, and the right contract. That's most of what this page explains.

2. The three tools

Same underlying data, three different audiences.

🛠️ Admin

Full control — catalog, placements, contracts, conversions, users, MCP access. This is where products get added, contracts get set up, and manual conversions get entered. Requires an Access Token or the master key.

📊 Analytics

Read-only reporting — impressions/clicks/CTR, the Monetisation dashboard, individual click/impression logs. Same login system as Admin, but a token can be Analytics-only (view everything, change nothing).

🤝 Vendor Dashboard

A single vendor's own numbers — clicks, GMV, amount due, their own contracts and reconciliation. Meant to be embedded directly on a vendor's own website via an iframe. No login screen; a token in the URL identifies the vendor.

3. How a product gets shown to a visitor

Every Europa Specials content page that has shoppable products embeds an invisible-until-loaded iframe. This is the actual Liquid embed code OKAST uses (also kept as okast-widget-embed_2.html in the repo, the source of truth for this snippet):

{% if custom_params.shoppable == "true" %}
<style>
  #es-shoppable-frame { width: 100%; border: 0; display: block; height: 660px; }
  @media (max-width: 980px) { #es-shoppable-frame { height: 1150px; } }
  @media (max-width: 540px) { #es-shoppable-frame { height: 2150px; } }
</style>
<iframe
  id="es-shoppable-frame"
  src="https://es-shoppable-widget.cedric-monnier.workers.dev/shoppable?uuid={{ uuid }}&language={{ user.language }}"
  loading="lazy"
  title="Shoppable recommendations"
></iframe>
<script>
  // Progressive enhancement — harmless if OKAST strips it; fine-tunes
  // the fixed heights above to an exact value if it survives.
  window.addEventListener('message', function (event) {
    if (!event.data || event.data.type !== 'es-shoppable-resize') return;
    var frame = document.getElementById('es-shoppable-frame');
    if (frame) frame.style.height = event.data.height + 'px';
  });
</script>
{% endif %}

Two things worth knowing about this exact snippet: the content page still has to opt in via the shoppable custom variable (PLATEFORME → Disposition → Page Contenu → Variables) — the whole block is wrapped in that {% raw %}{% if %}{% endraw %} for exactly that reason. And the fixed heights per breakpoint exist because OKAST's widget field strips <script> tags (common CMS sanitization), so the iframe can't reliably auto-size itself across origins — the <script> block above is kept as a harmless progressive enhancement in case that ever changes, not as something currently doing anything.

language={{ user.language }} is confirmed to be the actual variable OKAST exposes — the Worker also accepts the shorter lang= (handy for manually testing one language by URL), but language is what the real integration sends and takes priority when both are present.

When that loads, the system:

1. Find every Placement for this content 2. Keep the ones matching language + country + active + in date range 3. If too many, keep the highest-VIP vendors 4. Render the tiles

The result is cached for a short time (so the same content page doesn't hit Airtable on every single visitor), and re-fetched automatically once that cache expires or a placement changes.

4. How a click is tracked — two mechanisms

This is the part that trips people up, because there are genuinely two different paths, chosen per-vendor, and they produce click data slightly differently. Every vendor is on exactly one of these two — never both for the same tile.

Legacy Direct link + client-side beacon

The tile links straight to the vendor's own product page. When the visitor's browser registers the click, a small script fires a "beacon" — a fire-and-forget background request — to our own /track endpoint to log it, then the browser navigates to the vendor's site.

Tile → https://vendor-site.com/product?affiliate=xyz  (direct link, legacy affiliate param)
        + a background beacon to /track logs the click

Weakness: the beacon is JavaScript running in the visitor's browser — an ad blocker, a privacy extension, or the visitor closing the tab a split second too early can all cause it to silently not fire. The click still happens; our record of it might not.

Go Redirect Server-side redirect (recommended, opt-in per vendor)

The tile links to our own redirect endpoint first. The click is logged on our server, before the visitor ever leaves — nothing in the visitor's browser has to successfully run for the click to count.

Tile → /go/plc_9f3a...?country=FR
        ↓ (server-side, instant)
        1. Log the click (D1 + audit trail)
        2. Resolve the vendor's active Affiliate Contract for this product
        3. Build the correct affiliate link for this vendor's program
        4. Mint a unique affiliateClickId — this is what a later conversion gets matched against
        ↓
        302 redirect → https://vendor-site.com/product?ref=xyz&subid=clk_8e21...

This is why Go Redirect matters for monetised vendors specifically: the affiliateClickId minted in step 4 is the join key that lets a conversion reported days later get matched back to this exact click, this exact product, and this exact contract — see the next section. The legacy path has no equivalent identifier to hand a vendor.

5. How a sale becomes a confirmed commission

A vendor's affiliate program eventually reports back that a sale happened — automatically (a postback call to our API), manually (someone on our side enters it after a vendor calls/emails), or self-service (the vendor reports it themselves through their own widget, picking from their own recent clicks — see /vendor-widget/search-clicks and /vendor-widget/report-conversion below). All three go through the exact same pipeline:

Find the original click by affiliateClickId Check it's within the contract's attribution window Calculate what we'd expect (CPA / Revenue Share, per the contract) Compare to what the vendor reported Reconciliation status

Reconciliation status is never a guess — it's one of a fixed set of outcomes (matched, amount mismatch, unknown click, outside the attribution window, duplicate, missing an amount the calculation needed, etc.), so an amount owed is always traceable back to exactly why it's confirmed, or exactly what's unresolved about it.

A conversion still sitting in PENDING (not yet confirmed one way or the other) can also be approved or rejected by the vendor directly, from their own widget's "Your to-do list" — same effect as an admin doing it in admin.html, just a different door in. Every conversion records both Source (how it was created: admin_manual, mcp_vendor, vendor_widget, or vendor_postback) and Confirmed Via (how its status was last changed: admin or vendor_widget) — both decided server-side from the authenticated route a request came through, never taken from the request body itself.

6. URL format reference

Every URL pattern you'll actually see in this system, what it's for, and a worked example.

PatternWho uses itExample
/shoppable?uuid=&language=&type=&country=OKAST embeds this in an iframe on every content page. Not meant to be opened directly. language is what the real integration sends (see §3) — lang is also accepted, handy for manual testing. type is content (default), smartlist, or widget (an OKAST embed widget's own uuid, not a Content/Smartlist page)./shoppable?uuid=abc-123&language=fr&type=content&country=FR
/go/{placementId}?country=Auto-generated on tiles for vendors with Go Redirect enabled. Never built by hand./go/plc_9f3a7c...&country=FR
/admin.htmlYou, with an Access Token (Admin or Analytics scope) or the master key.shop.europaspecials.eu/admin.html
/analytics.htmlSame login, read-only reporting.shop.europaspecials.eu/analytics.html
/vendor-widget.html?token=&days=&lang=Given to a vendor to embed on their own site. token is the only required part — it's what identifies which vendor./vendor-widget.html?token=vwt_8e21...&days=30&lang=en
/postback/vendor-conversionA vendor's own server, reporting a sale automatically. Authenticated with their own Postback Key — never the same secret as their Widget Token.POST request, not a link you visit
{mcp-server}/mcpAn AI agent (Claude, ChatGPT, MCP Inspector) connecting via the Model Context Protocol — either through the native connector's OAuth flow, or authenticated directly with a personal MCP Token.es-shoppable-widget.../mcp + Authorization: Bearer <token>
/status.htmlAnyone — public, no login. Live status of the infrastructure everything else depends on.shop.europaspecials.eu/status.html

A Postback Key, a Widget Token, an Access Token, and an MCP Token are four different secrets, each scoped to exactly one purpose — none of them can be used in place of another, by design.

7. Is everything running?

Check the status page — it shows live uptime for the three things everything else depends on (the data source, the event database, and the cache), checked every few minutes, with a short history. If something's actually down, that's where it'll show first.

8. Bulk-importing products via JSON

The Products tab in admin.html has an "⇪ Import from JSON" button — paste an array, or upload a .json file. Each item in the array can have:

{
  "title": "Vase en céramique artisanal",   // required — anything without one is silently skipped
  "category": "Taste",
  "vendor": "Fnac",                          // matched by exact name, case-insensitive
  "vendorId": "recXXXXXXXXXXXXXX",           // …or a precise Airtable record id
  "vendorExternalId": "ext_abc123",          // …or the vendor's own stable External ID
  "description": "…", "descriptionForAi": "…",
  "productUrl": "https://…", "price": "34.90", "priceType": "Fixed", "currency": "EUR",
  "inStock": true,
  "availabilityType": "online",
  "locationCity": "", "locationCountry": "",
  "imageUrl": "https://…",
  "language": "fr",
  "ctaType": "Discover",
  "externalId": "ext_existing_product"       // include to UPDATE that product instead of creating a new one
}

Resolving the vendor — three ways, in order of precision: vendorId (an exact Airtable record) beats vendorExternalId (an exact stable id) beats vendor (a name match). A name that doesn't match any existing vendor gets a new one auto-created on the spot (name only, no affiliate config — reviewed and filled in afterward in the Vendors tab). A vendorExternalId that doesn't match anything is left unassigned instead — a typo there is far more likely than "please create one", so this path never auto-creates.

The batch vendor picker — the import panel also has a "Vendor for this whole batch" dropdown, above the paste/upload fields. Left on its default ("determine per item, from the JSON"), everything above applies exactly as described. Pick a vendor there instead, and it applies to every item in the file, full stop — any vendor/vendorId/vendorExternalId the JSON itself contains is ignored for that run. Built for the common case: a product export that's already entirely one vendor's own catalog, and never bothered including its own vendor name per line.

Re-running the same file is safe once every item carries its own externalId — Products already in the catalog get updated in place rather than duplicated. Omit externalId (e.g. the very first import of a new file) and every run creates fresh products instead.

Comment fonctionne le Shoppable Engine

Un guide en langage simple sur ce qui se passe entre le moment où un visiteur voit un produit sur Europa Specials et celui où un vendor est payé — avec chaque format d'URL que vous rencontrerez réellement.

1. Vue d'ensemble

Le Shoppable Engine intègre des recommandations produit cliquables dans les pages vidéo/contenu d'Europa Specials, suit ce qui se passe après qu'on ait cliqué dessus, et calcule ce que chaque vendor doit selon les termes de son contrat — automatiquement, à partir de ce même clic qui a envoyé le visiteur vers le site du vendor.

Le visiteur regarde le contenu Voit les tuiles produit Clique sur une Arrive sur le site du vendor Le vendor signale la vente On calcule ce qui est dû

Tout ce qui suit ce premier clic — attribution, réconciliation, reporting — dépend du fait que ce clic soit enregistré de façon fiable, avec assez de contexte pour le retracer jusqu'au bon produit, au bon vendor, et au bon contrat. C'est l'essentiel de ce que cette page explique.

2. Les trois outils

Mêmes données sous-jacentes, trois audiences différentes.

🛠️ Admin

Contrôle complet — catalogue, placements, contrats, conversions, utilisateurs, accès MCP. C'est ici que les produits sont ajoutés, les contrats configurés, et les conversions manuelles saisies. Nécessite un Access Token ou la clé maître.

📊 Analytics

Reporting en lecture seule — impressions/clics/CTR, le dashboard Monetisation, les logs individuels de clic/impression. Même système de connexion qu'Admin, mais un token peut être limité à Analytics (tout voir, rien modifier).

🤝 Tableau de bord vendor

Les propres chiffres d'un vendor — clics, GMV, montant dû, ses propres contrats et réconciliation. Pensé pour être intégré directement sur le site du vendor via un iframe. Pas d'écran de connexion ; un token dans l'URL identifie le vendor.

3. Comment un produit s'affiche pour un visiteur

Chaque page de contenu Europa Specials qui a des produits shoppable intègre un iframe invisible jusqu'au chargement. Voici le vrai code Liquid utilisé par OKAST (conservé aussi comme okast-widget-embed_2.html dans le repo, la source de vérité pour ce snippet) :

{% if custom_params.shoppable == "true" %}
<style>
  #es-shoppable-frame { width: 100%; border: 0; display: block; height: 660px; }
  @media (max-width: 980px) { #es-shoppable-frame { height: 1150px; } }
  @media (max-width: 540px) { #es-shoppable-frame { height: 2150px; } }
</style>
<iframe
  id="es-shoppable-frame"
  src="https://es-shoppable-widget.cedric-monnier.workers.dev/shoppable?uuid={{ uuid }}&language={{ user.language }}"
  loading="lazy"
  title="Shoppable recommendations"
></iframe>
<script>
  // Amélioration progressive — sans effet si OKAST le retire ; ajuste
  // les hauteurs fixes ci-dessus à une valeur exacte si ça survit.
  window.addEventListener('message', function (event) {
    if (!event.data || event.data.type !== 'es-shoppable-resize') return;
    var frame = document.getElementById('es-shoppable-frame');
    if (frame) frame.style.height = event.data.height + 'px';
  });
</script>
{% endif %}

Deux points importants sur ce snippet précis : la page de contenu doit toujours activer l'affichage via la variable custom shoppable (PLATEFORME → Disposition → Page Contenu → Variables) — tout le bloc est enveloppé dans ce {% raw %}{% if %}{% endraw %} exactement pour cette raison. Et les hauteurs fixes par palier existent parce que le champ widget d'OKAST retire les balises <script> (nettoyage CMS classique) — l'iframe ne peut donc pas s'auto-dimensionner de façon fiable entre origines différentes. Le bloc <script> ci-dessus est conservé comme amélioration progressive inoffensive au cas où ça change un jour, mais ne fait actuellement rien.

language={{ user.language }} est confirmé comme étant la vraie variable exposée par OKAST — le Worker accepte aussi le plus court lang= (pratique pour tester une langue manuellement par URL), mais language est ce que la vraie intégration envoie et prend le dessus quand les deux sont présents.

Au chargement, le système :

1. Trouve chaque Placement pour ce contenu 2. Garde ceux qui correspondent à langue + pays + actif + dans la période 3. S'il y en a trop, garde les vendors au VIP le plus élevé 4. Affiche les tuiles

Le résultat est mis en cache brièvement (pour que la même page de contenu ne sollicite pas Airtable à chaque visiteur), et re-récupéré automatiquement une fois ce cache expiré ou un placement modifié.

4. Comment un clic est suivi — deux mécanismes

C'est la partie qui prête le plus à confusion, car il existe réellement deux chemins différents, choisis par vendor, qui produisent des données de clic légèrement différentes. Chaque vendor est sur exactement l'un des deux — jamais les deux pour la même tuile.

Legacy Lien direct + balise côté client

La tuile lie directement vers la page produit du vendor. Quand le navigateur du visiteur enregistre le clic, un petit script déclenche une "balise" (beacon) — une requête en arrière-plan sans attente de réponse — vers notre propre point d'entrée /track pour l'enregistrer, puis le navigateur navigue vers le site du vendor.

Tuile → https://vendor-site.com/product?affiliate=xyz  (lien direct, paramètre affiliate historique)
        + une balise en arrière-plan vers /track enregistre le clic

Faiblesse : la balise est du JavaScript qui s'exécute dans le navigateur du visiteur — un bloqueur de pub, une extension de confidentialité, ou le visiteur qui ferme l'onglet une fraction de seconde trop tôt peuvent tous faire qu'elle ne se déclenche pas silencieusement. Le clic a bien lieu ; notre enregistrement de celui-ci, peut-être pas.

Go Redirect Redirection côté serveur (recommandé, activable par vendor)

La tuile lie d'abord vers notre propre point de redirection. Le clic est enregistré sur notre serveur, avant même que le visiteur ne parte — rien dans le navigateur du visiteur n'a besoin de s'exécuter avec succès pour que le clic compte.

Tuile → /go/plc_9f3a...?country=FR
        ↓ (côté serveur, instantané)
        1. Enregistre le clic (D1 + piste d'audit)
        2. Résout le contrat d'affiliation actif du vendor pour ce produit
        3. Construit le bon lien d'affiliation pour le programme de ce vendor
        4. Génère un affiliateClickId unique — c'est ce à quoi une conversion ultérieure sera rapprochée
        ↓
        Redirection 302 → https://vendor-site.com/product?ref=xyz&subid=clk_8e21...

C'est pour ça que Go Redirect compte spécifiquement pour les vendors monétisés : l'affiliateClickId généré à l'étape 4 est la clé de jointure qui permet à une conversion signalée des jours plus tard d'être rapprochée exactement de ce clic, ce produit, et ce contrat — voir la section suivante. Le chemin legacy n'a pas d'identifiant équivalent à donner à un vendor.

5. Comment une vente devient une commission confirmée

Le programme d'affiliation d'un vendor finit par signaler qu'une vente a eu lieu — automatiquement (un appel postback à notre API), manuellement (quelqu'un de notre côté la saisit après un appel/email du vendor), ou en self-service (le vendor la signale lui-même depuis son propre widget, en choisissant parmi ses propres clics récents — voir /vendor-widget/search-clicks et /vendor-widget/report-conversion ci-dessous). Les trois passent par exactement le même pipeline :

Trouve le clic d'origine via affiliateClickId Vérifie qu'il est dans la fenêtre d'attribution du contrat Calcule ce qu'on attend (CPA / Revenue Share, selon le contrat) Compare à ce que le vendor a signalé Statut de réconciliation

Le statut de réconciliation n'est jamais une supposition — c'est l'un d'un ensemble fixe de résultats possibles (réconcilié, écart de montant, clic inconnu, hors fenêtre d'attribution, doublon, montant manquant nécessaire au calcul, etc.), donc un montant dû est toujours traçable jusqu'à la raison exacte pour laquelle il est confirmé, ou ce qui reste non résolu.

Une conversion encore en PENDING (jamais confirmée dans un sens ou l'autre) peut aussi être approuvée ou rejetée directement par le vendor, depuis son propre widget, dans "Your to-do list" — même effet qu'un admin le faisant dans admin.html, juste une porte d'entrée différente. Chaque conversion enregistre à la fois Source (comment elle a été créée : admin_manual, mcp_vendor, vendor_widget, ou vendor_postback) et Confirmed Via (comment son statut a été dernièrement changé : admin ou vendor_widget) — les deux décidés côté serveur d'après la route authentifiée empruntée, jamais pris depuis le corps de la requête lui-même.

6. Référence des formats d'URL

Chaque format d'URL que vous rencontrerez réellement dans ce système, à quoi il sert, et un exemple concret.

FormatQui l'utiliseExemple
/shoppable?uuid=&language=&type=&country=OKAST l'intègre dans un iframe sur chaque page de contenu. Pas destiné à être ouvert directement. language est ce que la vraie intégration envoie (voir §3) — lang est aussi accepté, pratique pour tester manuellement. type vaut content (défaut), smartlist, ou widget (l'uuid propre d'un embed widget OKAST, pas une page Content/Smartlist)./shoppable?uuid=abc-123&language=fr&type=content&country=FR
/go/{placementId}?country=Généré automatiquement sur les tuiles pour les vendors avec Go Redirect activé. Jamais construit à la main./go/plc_9f3a7c...&country=FR
/admin.htmlVous, avec un Access Token (scope Admin ou Analytics) ou la clé maître.shop.europaspecials.eu/admin.html
/analytics.htmlMême connexion, reporting en lecture seule.shop.europaspecials.eu/analytics.html
/vendor-widget.html?token=&days=&lang=Donné à un vendor pour intégration sur son propre site. token est la seule partie obligatoire — c'est ce qui identifie le vendor./vendor-widget.html?token=vwt_8e21...&days=30&lang=fr
/postback/vendor-conversionLe propre serveur d'un vendor, signalant une vente automatiquement. Authentifié avec sa propre Postback Key — jamais le même secret que son Widget Token.Requête POST, pas un lien qu'on visite
{serveur-mcp}/mcpUn agent IA (Claude, ChatGPT, MCP Inspector) se connectant via le Model Context Protocol — soit via le flux OAuth natif du connecteur, soit authentifié directement avec un MCP Token personnel.es-shoppable-widget.../mcp + Authorization: Bearer <token>
/status.htmlTout le monde — public, sans connexion. État en direct de l'infrastructure dont tout le reste dépend.shop.europaspecials.eu/status.html

Une Postback Key, un Widget Token, un Access Token et un MCP Token sont quatre secrets différents, chacun limité à un seul usage précis — aucun ne peut remplacer un autre, par conception.

7. Le système fonctionne-t-il ?

Consultez la page de statut — elle affiche la disponibilité en direct des trois éléments dont tout le reste dépend (la source de données, la base d'événements, et le cache), vérifiés toutes les quelques minutes, avec un bref historique. Si quelque chose est réellement en panne, c'est là que ça apparaîtra en premier.

8. Importer des produits en masse via JSON

L'onglet Products dans admin.html a un bouton "⇪ Import from JSON" — collez un tableau, ou uploadez un fichier .json. Chaque élément du tableau peut avoir :

{
  "title": "Vase en céramique artisanal",   // obligatoire — tout élément sans ça est ignoré silencieusement
  "category": "Taste",
  "vendor": "Fnac",                          // trouvé par nom exact, insensible à la casse
  "vendorId": "recXXXXXXXXXXXXXX",           // …ou un identifiant Airtable précis
  "vendorExternalId": "ext_abc123",          // …ou le External ID stable du vendeur
  "description": "…", "descriptionForAi": "…",
  "productUrl": "https://…", "price": "34.90", "priceType": "Fixed", "currency": "EUR",
  "inStock": true,
  "availabilityType": "online",
  "locationCity": "", "locationCountry": "",
  "imageUrl": "https://…",
  "language": "fr",
  "ctaType": "Discover",
  "externalId": "ext_existing_product"       // inclure pour METTRE À JOUR ce produit plutôt qu'en créer un nouveau
}

Résolution du vendeur — trois façons, par ordre de précision : vendorId (un enregistrement Airtable exact) l'emporte sur vendorExternalId (un identifiant stable exact), qui l'emporte sur vendor (correspondance par nom). Un nom qui ne correspond à aucun vendeur existant en crée un nouveau à la volée (nom seul, sans configuration d'affiliation — à revoir et compléter ensuite dans l'onglet Vendors). Un vendorExternalId qui ne correspond à rien reste non assigné à la place — une faute de frappe y est bien plus probable qu'un "merci d'en créer un", donc ce chemin ne crée jamais automatiquement.

Le sélecteur de vendeur pour le lot — le panel d'import a aussi un menu déroulant "Vendor for this whole batch", au-dessus des champs de collage/upload. Laissé sur son défaut ("determine per item, from the JSON"), tout ce qui précède s'applique tel quel. Choisir un vendeur ici à la place l'applique à tous les éléments du fichier, sans exception — tout vendor/vendorId/vendorExternalId présent dans le JSON lui-même est ignoré pour cet import. Construit pour le cas courant : un export de produits qui appartient déjà entièrement au catalogue d'un seul vendeur, et qui n'a jamais pris la peine d'inclure son propre nom de vendeur ligne par ligne.

Relancer le même fichier est sans risque une fois que chaque élément porte son propre externalId — les produits déjà dans le catalogue sont mis à jour sur place plutôt que dupliqués. Omettre externalId (par exemple lors du tout premier import d'un nouveau fichier) fait que chaque exécution crée de nouveaux produits.