ToldUntold Logo
Analytics

ToldUntold Client API v1 — Guide d'intégration Analytics

Sur cette page

API REST machine-to-machine pour récupérer les analytics d'apprentissage d'une marque ToldUntold — agrégats journaliers marque, métriques par contenu et lignes par utilisateur pseudonymisées — dans un système BI, ERP ou SIRH.

Contrat formel : openapi.yaml (OpenAPI 3.1). Ce guide est le compagnon narratif ; le fichier OpenAPI fait foi pour les formes exactes.

Ceci est le domaine analytics de la Client API ToldUntold. Le domaine catalog (produits, variantes, stock) est documenté à part dans le guide d'intégration catalog. Les deux partagent le même type de clé, l'authentification, l'enveloppe d'erreur et le mécanisme de rate-limit.


1. Vue d'ensemble & audience

L'Analytics Client API (/v1/client/analytics/*) est la surface programmatique en lecture seule qu'utilisent les systèmes data d'une marque pour récupérer ses analytics d'apprentissage ToldUntold. Elle couvre cinq endpoints :

  • GET /status — test de crédentiels + fraîcheur par dataset.
  • GET /metrics/daily — agrégats journaliers marque (optionnellement par marché).
  • GET /metrics/contents — métriques par contenu, à l'échelle de la marque.
  • GET /users/daily — lignes journalières par utilisateur pseudonymisées.
  • GET /users/contents — lignes de synchro par utilisateur × contenu, pseudonymisées.

Elle est en lecture seule et serveur-à-serveur : auth Bearer, clés à scopes, erreurs JSON plates avec codes machine stables, pagination par offset sur le petit dataset journalier et pagination par curseur opaque sur les grands.

Audience : ingénieurs back-end / data intégrant un entrepôt BI, un ERP ou un SIRH avec les données d'apprentissage ToldUntold. Pas de flux navigateur/OAuth, pas de login utilisateur final.

URL de base :

https://api.<environnement>.tolduntold.com

Remplacez <environnement> par la valeur fournie par ToldUntold (staging, production, …). Tous les chemins ci-dessous sont relatifs à cette base.


2. Authentification

2.1 Clés Client API (tuk_)

Chaque requête s'authentifie avec une clé Client API dans l'en-tête Authorization :

Authorization: Bearer tuk_<key_id>.<secret>

Ce sont les mêmes clés tuk_ que la surface catalog — une seule clé peut porter des scopes catalog et analytics. La partie avant le point (tuk_<key_id>) est l'identifiant public (loggable) ; la partie après le point est le secret, montré une seule fois à la création. Les jetons de feed legacy (cfk_) ne sont pas valides ici et sont rejetés en 401 unauthenticated.

Chaque clé porte exactement une marque. La marque n'est jamais envoyée dans la requête — elle est résolue depuis la clé, et chaque ligne y est cantonnée.

2.2 Scopes

Deux scopes analytics gardent les endpoints de données (status ne requiert qu'une clé valide) :

Scope Autorise
analytics.metrics:read GET /metrics/daily, GET /metrics/contents
analytics.users:read GET /users/daily, GET /users/contents

Les deux sont indépendantsanalytics.users:read n'est PAS impliqué par analytics.metrics:read. Une clé qui n'a pas le scope requis reçoit 403 insufficient_scope. Demandez à votre super-admin ToldUntold d'accorder les scopes nécessaires à l'émission de la clé.

2.3 Test de crédentiels

GET /v1/client/analytics/status
Authorization: Bearer tuk_9f2c1a4b6d8e0f2a4c6e8b0d.<secret>
{
  "brand_id": 8347,
  "key_id": "tuk_9f2c1a4b6d8e0f2a4c6e8b0d",
  "name": "BI production",
  "scopes": ["analytics.metrics:read", "analytics.users:read"],
  "expires_at": null,
  "datasets": {
    "daily": { "latest_date": "2026-07-08" },
    "contents": { "latest_date": "2026-07-07" },
    "users": { "latest_date": "2026-07-08" },
    "content_engagement": { "latest_date": "2026-07-06" }
  }
}

status ne renvoie jamais 503 : si le store analytics est injoignable, le bloc datasets dégrade à null tandis que le bloc d'identité reste intact. Les clés de datasets correspondent aux endpoints ainsi :

Clé datasets Endpoint Table source
daily metrics/daily analytics_daily
contents metrics/contents learning_snapshots
users users/daily learning_users
content_engagement users/contents learning_content_users

Un latest_date à null signifie qu'aucune donnée n'a encore été ingérée pour la marque.


3. Fraîcheur des données & fenêtre nocturne

Tous les datasets sont produits par des jobs d'agrégation nocturnes et reflètent les journées complètes jusqu'à J-1 (hier, UTC). La journée partielle du jour n'est pas servie.

L'agrégation tourne environ 00:05–06:00 UTC. Pendant cette fenêtre, la journée la plus récente (et, brièvement, toute journée en cours de réagrégation) peut être en mouvement : les lignes sont réécrites et, comme les exclusions sont appliquées par une suppression puis réinsertion, une ligne peut transitoirement apparaître ou disparaître. Pour une lecture stable :

  • Tirez les données J-1 après 06:00 UTC, une fois la fenêtre nocturne close.
  • Utilisez GET /status datasets.<clé>.latest_date comme signal de fraîcheur — ne considérez une journée comme définitive qu'une fois <= latest_date et la fenêtre passée.

C'est le même modèle de fraîcheur que celui des dashboards internes ToldUntold.


4. Datasets

Paramètres de requête communs à chaque endpoint de données :

  • from, to — bornes de jour inclusives YYYY-MM-DD (UTC). to vaut par défaut hier (J-1) ; from vaut par défaut la fenêtre par défaut du dataset avant to. from doit être antérieur ou égal à to (sinon 422 validation_failed), et la fenêtre inclusive ne peut dépasser le plafond du dataset (sinon 422 date_range_too_large).

Conventions utilisées partout : les taux et scores sont des flottants sur une échelle 0–100 ; les durées sont des entiers en secondes (champs suffixés _seconds) ; les compteurs sont des entiers. content.type est un enum ouvert (aujourd'hui episode / program) — ne rejetez pas en dur une valeur inconnue.

4.1 GET /metrics/daily — agrégats journaliers marque

  • Source : analytics_daily. Grain : une ligne par jour.
  • Scope : analytics.metrics:read.
  • Fenêtre : défaut 30 jours, plafond 366 jours.
  • Pagination : offset (page, per_page défaut 50 / plafond 200) avec un total exact — le volume est petit et borné (jours × marchés).
  • Granularité (trois modes mutuellement exclusifs) :
    • marque (défaut) — market vaut null sur chaque ligne ;
    • marché uniquemarket=<tu_id du marché> ; inconnu/cross-brand → 404 ;
    • ventilation par marchégroup_by=market ; une ligne par marché par jour (lignes marque exclues). market et group_by=market ensemble → 422 validation_failed.
GET /v1/client/analytics/metrics/daily?from=2026-06-01&to=2026-06-30
Authorization: Bearer tuk_….<secret>
{
  "data": [
    {
      "date": "2026-07-08",
      "market": null,
      "live_content": { "episodes": 5, "programs": 3, "quests": 2, "products": 1 },
      "active_users": 42,
      "totals": { "views": 100, "completions": 40, "successes": 20, "xp_earned": 1000, "time_spent_seconds": 3600 },
      "episodes": { "views": 60, "completions": 25, "successes": 12, "time_spent_seconds": 1800, "watch_rate": 75, "completion_rate": 41, "success_rate": 20, "quiz_average_score": 80 },
      "programs": { "views": 30, "completions": 12, "successes": 6, "time_spent_seconds": 1200, "watch_rate": 60, "completion_rate": 40, "success_rate": 20, "quiz_average_score": 70 },
      "quests": { "views": 10, "completions": 3, "successes": 2, "time_spent_seconds": 600, "watch_rate": 50, "completion_rate": 30, "success_rate": 20, "quiz_average_score": 90 },
      "overall_completion_rate": 63,
      "cumulative": { "views": 5000, "completions": 2000, "successes": 1000, "xp_earned": 50000 }
    }
  ],
  "meta": { "current_page": 1, "per_page": 50, "total": 1, "last_page": 1 }
}

Notes :

  • market vaut { "tu_id": … } pour les lignes marché, null pour les lignes marque.
  • live_content = compte live de chaque type de contenu ce jour-là.
  • totals = totaux du jour ; les blocs par type (episodes/programs/quests) les détaillent avec les taux. quests.quiz_average_score est nullable ; les autres champs de taux/score valent 0 par défaut.
  • overall_completion_rate est une métrique métier stockée (flottant 0–100), nullable.
  • cumulative = totaux cumulés de la marque à cette date.

4.2 GET /metrics/contents — métriques par contenu

  • Source : learning_snapshots. Grain : une ligne par contenu par jour, à l'échelle de la marque (agrégats marché/POS exclus — market et pos toujours null).
  • Scope : analytics.metrics:read.
  • Fenêtre : défaut 30 jours, plafond 92 jours.
  • Pagination : curseur (§5), keyset (content_ref_id, date), per_page défaut 100 / plafond 500.
  • Filtres : type (episode|program) restreint à ce type ; content=<tu_id du contenu> inverse vers un contenu unique et requiert type (content seul → 422 ; inconnu/cross-brand → 404).
{
  "data": [
    {
      "date": "2026-07-08",
      "content": { "tu_id": "epi_7c1a9f", "type": "episode", "name": "Onboarding — module 1" },
      "market": null,
      "pos": null,
      "metrics": { "views": 15, "unique_views": 12, "completions": 8, "successes": 5, "active_users": 10, "total_users": 20, "engagement_rate": 55, "xp_earned": 300, "time_spent_seconds": 900, "questions_count": 4, "quiz_average_score": 77 },
      "cumulative": { "views": 12345, "completions": 400, "successes": 250, "watch_rate": 88, "completion_rate": 66, "success_rate": 44, "quiz_average_score": 72 }
    }
  ],
  "meta": { "per_page": 100, "next_cursor": "…opaque…" }
}

Notes :

  • content est l'identité résolue { tu_id, type, name }, ou null quand la référence est orpheline ou cross-brand (la ligne de métriques est tout de même renvoyée). name est un libellé non contractuel, best-effort (peut être null, peut changer) — indexez vos jointures sur tu_id + type, jamais sur name.
  • metrics.xp_earned est l'incrément d'XP du jour (pas le total cumulé).

4.3 GET /users/daily — lignes journalières par utilisateur pseudonymisées

  • Source : learning_users. Grain : une ligne par utilisateur par jour, à l'échelle marque (market/pos toujours null).
  • Scope : analytics.users:read.
  • Fenêtre : défaut 30 jours, plafond 92 jours.
  • Pagination : curseur (§5), keyset (user_id, date), per_page défaut 100 / plafond 500. Pages creuses possibles — voir §6.
  • Filtres : user=<tu_id de l'utilisateur> (inconnu/exclu/cross-brand → un 404 indistinguable).
{
  "data": [
    {
      "date": "2026-01-15",
      "user": { "tu_id": "usr_3a9c1f" },
      "market": null,
      "pos": null,
      "metrics": {
        "views": 10, "completions": 8, "successes": 6,
        "xp_earned": 1500, "time_spent_seconds": 3600, "overall_score": 87.5,
        "episodes": { "views": 5, "completions": 4, "successes": 3 },
        "programs": { "views": 3, "completions": 2, "successes": 1 },
        "quests": { "views": 2, "completions": 1, "successes": 0 }
      },
      "cumulative": {
        "views": 100, "completions": 80, "successes": 60,
        "episodes": { "views": 50, "completions": 40, "successes": 30 },
        "programs": { "views": 30, "completions": 20, "successes": 10 },
        "quests": { "views": 20, "completions": 10, "successes": 5 }
      }
    }
  ],
  "meta": { "per_page": 100, "next_cursor": null }
}

Le seul identifiant utilisateur est user.tu_id (§6). overall_score est un flottant 0–100.

4.4 GET /users/contents — lignes de synchro utilisateur × contenu pseudonymisées

  • Source : learning_content_users. Grain : une ligne par utilisateur × contenu par jour. C'est un dataset de synchro à fort volume — sa fenêtre par défaut est courte.
  • Scope : analytics.users:read.
  • Fenêtre : défaut 7 jours, plafond 92 jours.
  • Pagination : curseur (§5), keyset (content_ref_id, date, user_id), per_page défaut 100 / plafond 500. Pages creuses possibles — voir §6.
  • Filtres : user=<tu_id> (404 plat en cas d'échec) ; content=<tu_id> + type (type requis avec content ; 404 plat en cas d'échec) ; completed (booléen, appliqué côté store pour que les pages restent pleines).
{
  "data": [
    {
      "date": "2026-01-15",
      "user": { "tu_id": "usr_3a9c1f" },
      "content": { "tu_id": "epi_7c1a9f", "type": "episode" },
      "metrics": { "views": 7, "xp_earned": 210, "time_spent_seconds": 900, "is_completed": true, "is_successful": false, "questions_count": 12, "quiz_average_score": 74.5, "quiz_completion_rate": 66 },
      "cumulative": { "views": 40, "xp_earned": 1200, "time_spent_seconds": 5400, "quiz_average_score": 80 }
    }
  ],
  "meta": { "per_page": 100, "next_cursor": null }
}

Ici le bloc content porte { tu_id, type } uniquement (pas de libellé name — c'est un dataset de volume), ou null pour une référence orpheline/cross-brand (la ligne est conservée). is_completed / is_successful sont des booléens.


5. Pagination par curseur

metrics/contents, users/daily et users/contents utilisent la pagination par curseur. Le meta de la réponse est :

{ "per_page": 100, "next_cursor": "…opaque…" }

Pour paginer :

  1. Appelez l'endpoint (optionnellement avec per_page, 1–500, défaut 100).
  2. Si meta.next_cursor n'est pas null, repassez-le tel quel en ?cursor=… (en gardant tous les autres filtres identiques).
  3. Arrêtez quand meta.next_cursor vaut null.

Règles :

  • Le curseur est opaque et chiffré (chiffrement authentifié). Ne le décodez pas, ne le construisez pas, ne le modifiez pas. Un curseur altéré, étranger ou malformé est rejeté en 400 invalid_cursor.
  • Il n'y a pas de total sur les datasets à curseur (traités comme non bornés) ; ne calculez pas de nombre de pages.
  • Des pages creuses peuvent survenir sur users/* — une page peut revenir data: [] avec un next_cursor non-null (voir §6). Pilotez toujours la boucle sur next_cursor, jamais sur un data vide.
  • Gardez la fenêtre (from/to) et tous les filtres constants pendant une boucle de curseur ; les changer invalide la séquence.

6. Confidentialité & pseudonymisation

Les datasets par utilisateur (users/daily, users/contents) sont pseudonymisés :

  • Le seul identifiant utilisateur jamais émis est le tu_id public. Les identifiants internes, noms, e-mails et tout autre attribut PII ne sont jamais renvoyés — ni récupérables depuis le curseur (chiffré).
  • Les exclusions analytics (utilisateurs que la marque a retirés de l'analytics) sont appliquées à la fois à l'écriture des données nocturnes et de façon défensive à nouveau à la lecture : chaque page revérifie l'éligibilité analytics + l'appartenance à la marque, et une ligne dont l'utilisateur ne qualifie plus est droppée de data. Comme le curseur avance sur la dernière ligne brute lue, une page peut légalement revenir data: [] avec un next_cursor non-null (une page creuse). Continuez à suivre next_cursor jusqu'à null.
  • Un filtre user=<tu_id> inconnu, exclu, ou appartenant à une autre marque renvoie un 404 plat indistinguable — l'API ne révèle jamais si un utilisateur existe mais est filtré.

6.1 Limitation documentée — exclusions sur les agrégats

Les datasets par utilisateur reflètent les exclusions immédiatement (un utilisateur nouvellement exclu disparaît de users/* à la lecture suivante, de façon rétroactive sur la fenêtre).

Les datasets agrégés (metrics/daily, metrics/contents) sont différents : ils reflètent les exclusions en vigueur au moment où chaque journée a été agrégée. Si un utilisateur est exclu après l'agrégation d'une journée, les totaux metrics/* de cette journée incluent toujours sa contribution historique jusqu'à la réagrégation de la journée — le retrait n'est pas rétroactif sur les agrégats. C'est le comportement des dashboards internes ToldUntold (les agrégats sont nets des exclusions au moment de l'agrégation). Le différentiel n'identifie aucun individu et reflète des données que la marque voit déjà en interne. La réagrégation des journées passées après une exclusion est un runbook opérationnel côté ToldUntold.


7. Limites de débit (rate limits)

Deux limiteurs par minute s'appliquent sur la surface analytics :

Bucket Limite Notes
Par clé 120 / min Surface en lecture seule (que des GET).
Agrégat par marque 240 / min Toutes clés de la marque confondues.

Ces compteurs sont indépendants des limites de la surface catalog — épuiser votre budget analytics n'entame pas votre budget catalog, et réciproquement.

Chaque réponse porte les en-têtes X-RateLimit-* du bucket le plus contraint s'appliquant à l'appel :

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1751965200

X-RateLimit-Reset est un epoch Unix (secondes). Au dépassement, vous obtenez 429 rate_limited avec le même trio X-RateLimit-* (remaining 0) plus Retry-After (secondes). Patientez jusque-là.


8. Erreurs

Toutes les erreurs utilisent l'enveloppe plate :

{ "code": "date_range_too_large", "message": "The requested date range is too large; the maximum window is 92 days." }
  • code — chaîne stable, lisible par machine. Branchez votre logique dessus.
  • message — lisible par un humain ; peut changer, ne le parsez pas.
  • errors — présent uniquement sur 422 validation_failed : une map champ → messages.

8.1 Catalogue des codes d'erreur

Uniquement les codes réellement émis par la surface analytics :

HTTP code Signification
400 invalid_cursor Le cursor est malformé, altéré ou non supporté.
401 unauthenticated Clé absente, malformée, expirée ou révoquée — ou un jeton de feed cfk_ présenté ici.
403 insufficient_scope Clé valide, mais sans analytics.metrics:read / analytics.users:read.
404 not_found Le marché / contenu / utilisateur adressé n'existe pas ou appartient à une autre marque.
405 method_not_allowed Méthode HTTP non autorisée sur ce chemin (surface en lecture seule — utilisez GET).
422 validation_failed Validation de la requête échouée (mauvais format de date, from après to, content sans type, …). Porte errors.
422 date_range_too_large La fenêtre inclusive dépasse le plafond du dataset (366 jours pour metrics/daily, sinon 92). Pas de map errors — le message indique le plafond.
429 rate_limited Une limite par clé ou par marque a été atteinte. Porte Retry-After et X-RateLimit-*.
500 server_error Erreur serveur inattendue (opaque ; ne fuit jamais d'internes).
503 analytics_unavailable Le store analytics est momentanément injoignable (§9). Porte Retry-After.

date_range_too_large est un HTTP 422 mais porte son propre code machine (pas validation_failed) et n'a pas de map errors — c'est une pré-condition sémantique, pas une validation de champ.


9. Disponibilité

Les données analytics vivent dans un store dédié. S'il est momentanément injoignable, un endpoint de données répond :

HTTP/1.1 503 Service Unavailable
Retry-After: 60
{ "code": "analytics_unavailable", "message": "Analytics store is temporarily unavailable." }

Ce n'est jamais une page vide silencieuse — un 200 avec data: [] signifie réellement « aucune ligne dans cette fenêtre », pas une panne. Réessayez après Retry-After secondes.

L'endpoint status est l'exception : c'est une sonde de santé qui ne renvoie jamais 503. En cas de panne, il dégrade en 200 avec datasets: null (le bloc d'identité reste intact), pour que vous puissiez toujours confirmer vos crédentiels.


10. Hors périmètre v1 & roadmap

Non disponible en v1 — documenté pour vous permettre de planifier. Comme les erreurs sont un contrat {code} stable et les lectures des supersets additifs, les ajouts futurs ne casseront pas une intégration v1 qui ignore les champs inconnus.

Domaine Statut en v1
metrics/products Chemin réservé. L'analytics par produit nécessite une matérialisation nocturne qui n'existe pas encore (le calcul à la lecture serait non borné). Roadmap.
Exports asynchrones en masse Réservé. Les gros back-fills utilisent la pagination par curseur aujourd'hui ; un export par job est en roadmap.
group_by étendu Réservé. Seul group_by=market sur metrics/daily aujourd'hui ; d'autres dimensions sont en roadmap.
Écritures Hors périmètre — la surface analytics est en lecture seule.
Webhooks / push Réservé ; l'analytics est en pull uniquement aujourd'hui.

11. Changelog

v1.0

  • Domaine analytics initial de la Client API ToldUntold.
  • Endpoints : GET /status, GET /metrics/daily, GET /metrics/contents, GET /users/daily, GET /users/contents.
  • Scopes : analytics.metrics:read, analytics.users:read.
  • Pagination par offset sur metrics/daily ; pagination par curseur chiffré opaque sur les trois autres datasets.
  • Limites de débit : 120/min par clé, 240/min par marque.
Formation. Intelligence. Production de contenu. Visual merchandising. IA. Une seule plateforme, au service de la performance retail.