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épendants — analytics.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 /statusdatasets.<clé>.latest_datecomme signal de fraîcheur — ne considérez une journée comme définitive qu'une fois<= latest_dateet 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 inclusivesYYYY-MM-DD(UTC).tovaut par défaut hier (J-1) ;fromvaut par défaut la fenêtre par défaut du dataset avantto.fromdoit être antérieur ou égal àto(sinon422 validation_failed), et la fenêtre inclusive ne peut dépasser le plafond du dataset (sinon422 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_pagedéfaut 50 / plafond 200) avec untotalexact — le volume est petit et borné (jours × marchés). - Granularité (trois modes mutuellement exclusifs) :
- marque (défaut) —
marketvautnullsur chaque ligne ; - marché unique —
market=<tu_id du marché>; inconnu/cross-brand →404; - ventilation par marché —
group_by=market; une ligne par marché par jour (lignes marque exclues).marketetgroup_by=marketensemble →422 validation_failed.
- marque (défaut) —
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 :
marketvaut{ "tu_id": … }pour les lignes marché,nullpour 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_scoreest nullable ; les autres champs de taux/score valent0par défaut.overall_completion_rateest 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 —marketetpostoujoursnull). - Scope :
analytics.metrics:read. - Fenêtre : défaut 30 jours, plafond 92 jours.
- Pagination : curseur (§5), keyset
(content_ref_id, date),per_pagedéfaut 100 / plafond 500. - Filtres :
type(episode|program) restreint à ce type ;content=<tu_id du contenu>inverse vers un contenu unique et requierttype(contentseul →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 :
contentest l'identité résolue{ tu_id, type, name }, ounullquand la référence est orpheline ou cross-brand (la ligne de métriques est tout de même renvoyée).nameest un libellé non contractuel, best-effort (peut êtrenull, peut changer) — indexez vos jointures surtu_id+type, jamais surname.metrics.xp_earnedest 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/postoujoursnull). - Scope :
analytics.users:read. - Fenêtre : défaut 30 jours, plafond 92 jours.
- Pagination : curseur (§5), keyset
(user_id, date),per_pagedéfaut 100 / plafond 500. Pages creuses possibles — voir §6. - Filtres :
user=<tu_id de l'utilisateur>(inconnu/exclu/cross-brand → un404indistinguable).
{
"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_pagedéfaut 100 / plafond 500. Pages creuses possibles — voir §6. - Filtres :
user=<tu_id>(404plat en cas d'échec) ;content=<tu_id>+type(typerequis aveccontent;404plat 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 :
- Appelez l'endpoint (optionnellement avec
per_page, 1–500, défaut 100). - Si
meta.next_cursorn'est pasnull, repassez-le tel quel en?cursor=…(en gardant tous les autres filtres identiques). - Arrêtez quand
meta.next_cursorvautnull.
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
totalsur 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 revenirdata: []avec unnext_cursornon-null(voir §6). Pilotez toujours la boucle surnext_cursor, jamais sur undatavide. - 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_idpublic. 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 revenirdata: []avec unnext_cursornon-null(une page creuse). Continuez à suivrenext_cursorjusqu'ànull. - Un filtre
user=<tu_id>inconnu, exclu, ou appartenant à une autre marque renvoie un404plat 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 sur422 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_largeest un HTTP422mais porte son propre code machine (pasvalidation_failed) et n'a pas de maperrors— 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.