Chapitre 7.1 — Envoyer un statement
⏱️ TL;DR — Envoyer un statement, c’est un appel
HTTPà la ressourcestatementsdu LRS. Trois choses sont non négociables : le bon verbe HTTP, une authentification (Basic ou OAuth), et l’en-tête obligatoireX-Experience-API-Version. L’oublier renvoie400, pas401— le piège qui fait perdre une heure. Ensuite, un choix :PUTavec unstatementIdque tu génères (idempotent, la bonne option pour du code résilient) ouPOSTen laissant le LRS générer l’id. Un statement est immuable : une fois posé, il ne se modifie plus ; le re-poster à l’identique ne fait rien, le re-poster différent sous le même id renvoie409. Ce chapitre donne les requêtes brutes, lefetchréel, et le patron d’envoi résilient.
🎯 Objectifs
- Construire un appel valide à
statements: URL, en-têtes, corpsJSON. - Poser l’en-tête
X-Experience-API-Versionet comprendre pourquoi il est obligatoire. - Choisir entre
PUT(id fourni) etPOST(id généré par le LRS), et savoir lequel prendre. - Rendre un envoi idempotent pour survivre à un renvoi réseau sans créer de doublon.
- Gérer les codes de réponse (
204,200,400,401,409,5xx) proprement.
L’anatomie d’un envoi
Un LRS expose une API REST. La ressource qui reçoit les statements s’appelle statements, à la racine de l’endpoint xAPI. Si l’endpoint de ton LRS est https://lrs.formacampus.fr/xapi/, tu écris sur https://lrs.formacampus.fr/xapi/statements.
Trois en-têtes comptent :
| En-tête | Rôle | Obligatoire ? |
|---|---|---|
Authorization | Prouve qui tu es (Basic ou OAuth). | Oui |
X-Experience-API-Version | Annonce la version xAPI que tu parles. | Oui, toujours |
Content-Type | application/json pour un statement simple. | Oui (en écriture) |
⚠️ Piège — Oublier
X-Experience-API-Version. Le LRS doit rejeter toute requête sans cet en-tête avec un400 Bad Request. Le piège : tu vois « 400 », tu soupçonnes tonJSON, tu passes une heure à valider ton statement… alors qu’il manque une ligne d’en-tête. Réflexe : devant un400inexpliqué sur le LRS, vérifie la présence et l’orthographe de l’en-tête de version en premier.
L’authentification
En développement et sur beaucoup de LRS, c’est du Basic : Authorization: Basic <base64(cle:secret)>. Le couple cle:secret t’est fourni par le LRS quand tu déclares un « client ». En production côté serveur, on trouve aussi OAuth. Le principe reste le même côté requête : un en-tête Authorization valide, sinon 401 Unauthorized.
⚠️ Piège — Mettre le secret Basic dans du JavaScript navigateur.
btoa("cle:secret")dans le front, c’est ton secret LRS exposé à tout le monde (onglet réseau du navigateur). Depuis un navigateur, on passe par un proxy côté serveur (une route Next.js, par exemple) qui détient le secret et relaie vers le LRS. On y revient au chapitre 7.4 pour le mobile.
PUT ou POST : qui génère l’id ?
Chaque statement a un id (un UUID). La seule question est : qui le fabrique ?
PUT statements | POST statements | |
|---|---|---|
| Qui génère l’id ? | Toi, tu le passes en statementId (query) | Le LRS |
| Réponse en succès | 204 No Content | 200 OK + tableau des id générés |
| Idempotent ? | Oui (rejouer le même PUT est sûr) | Non (rejouer crée un doublon) |
| Quand ? | Code résilient, retry, sync hors-ligne | Envoi simple « tire et oublie » |
Le PUT avec id auto-généré côté client est le choix pour du code sérieux : si le réseau coupe et que tu renvoies, tu réutilises le même statementId, donc pas de doublon. On y revient dans « Idempotence » plus bas.
PUT — tu fournis l’id
PUT https://lrs.formacampus.fr/xapi/statements?statementId=fd41c918-b88b-4b20-a0a5-a4c32391aaa0 HTTP/1.1
Host: lrs.formacampus.fr
Authorization: Basic Zm9ybWFjYW1wdXM6czNjcjN0
X-Experience-API-Version: 1.0.3
Content-Type: application/json
{
"actor": {
"objectType": "Agent",
"name": "Awa Diop",
"mbox": "mailto:awa.diop@ecole.fr"
},
"verb": {
"id": "http://adlnet.gov/expapi/verbs/completed",
"display": { "en-US": "completed", "fr-FR": "a terminé" }
},
"object": {
"objectType": "Activity",
"id": "https://formacampus.fr/xapi/activities/module-securite-numerique",
"definition": {
"name": { "fr-FR": "Module Sécurité numérique" },
"type": "http://adlnet.gov/expapi/activities/module"
}
},
"result": {
"completion": true,
"success": true,
"score": { "scaled": 0.85 }
},
"timestamp": "2026-03-14T09:32:11Z"
}La réponse en succès ne contient rien dans le corps :
HTTP/1.1 204 No Content
X-Experience-API-Version: 1.0.3💡 Réflexe — Tu peux mettre l’
idaussi dans le corps du statement ("id": "fd41c918-..."). S’il est présent, il doit être identique austatementIdde l’URL, sinon le LRS renvoie400. Le plus simple : générer l’UUID une fois, le mettre aux deux endroits.
POST — le LRS génère l’id
POST https://lrs.formacampus.fr/xapi/statements HTTP/1.1
Host: lrs.formacampus.fr
Authorization: Basic Zm9ybWFjYW1wdXM6czNjcjN0
X-Experience-API-Version: 1.0.3
Content-Type: application/json
{
"actor": { "mbox": "mailto:awa.diop@ecole.fr" },
"verb": {
"id": "http://adlnet.gov/expapi/verbs/experienced",
"display": { "fr-FR": "a consulté" }
},
"object": {
"id": "https://formacampus.fr/xapi/activities/fiche-mot-de-passe",
"definition": { "name": { "fr-FR": "Fiche : choisir un mot de passe" } }
}
}Le LRS répond avec le tableau des id qu’il a créés (un seul ici) :
HTTP/1.1 200 OK
Content-Type: application/json
X-Experience-API-Version: 1.0.3
["7f3d2c10-9a4e-4b8c-8f2a-1c9e6b0d5a44"]💡 Réflexe —
POSTaccepte aussi un tableau de statements pour en envoyer plusieurs d’un coup (le corps est[ {...}, {...} ]). Utile pour vider une file d’attente hors-ligne (chapitre 7.4). La réponse est alors le tableau des id dans le même ordre.
En JavaScript : un envoi résilient
Voici le patron à réutiliser. Il génère l’UUID côté client, envoie en PUT, et traite 409 comme un succès idempotent (le statement était déjà là).
// À exécuter CÔTÉ SERVEUR (le secret ne doit pas fuir dans le navigateur).
const LRS = "https://lrs.formacampus.fr/xapi";
const AUTH = "Basic " + btoa("formacampus:s3cr3t");
const XAPI_VERSION = "1.0.3";
function newId() {
return crypto.randomUUID(); // UUID v4
}
async function sendStatement(statement) {
// On fige l'id une fois pour toutes : le renvoi éventuel sera idempotent.
const statementId = statement.id ?? newId();
const body = JSON.stringify({ ...statement, id: statementId });
const res = await fetch(`${LRS}/statements?statementId=${statementId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"Authorization": AUTH,
"X-Experience-API-Version": XAPI_VERSION,
},
body,
});
if (res.status === 204) return { id: statementId, stored: true };
if (res.status === 409) return { id: statementId, stored: false }; // déjà présent : OK
// 400 (statement invalide ou en-tête manquant), 401 (auth), 5xx (LRS) :
const detail = await res.text().catch(() => "");
throw new Error(`LRS ${res.status} : ${detail}`);
}Construire le statement est du simple objet JS :
await sendStatement({
actor: { name: "Awa Diop", mbox: "mailto:awa.diop@ecole.fr" },
verb: {
id: "http://adlnet.gov/expapi/verbs/completed",
display: { "fr-FR": "a terminé" },
},
object: {
id: "https://formacampus.fr/xapi/activities/module-securite-numerique",
definition: { name: { "fr-FR": "Module Sécurité numérique" } },
},
result: { completion: true, success: true, score: { scaled: 0.85 } },
timestamp: new Date().toISOString(),
});💡 Réflexe — Pose toi-même le
timestamp(au format ISO 8601, ex.2026-03-14T09:32:11Z) au moment de l’événement. Si tu le laisses vide, le LRS met l’heure de réception (stored), qui peut être bien plus tard — désastreux pour un envoi hors-ligne synchronisé le lendemain. Distingue toujours quand ça s’est passé (timestamp) de quand le LRS l’a reçu (stored, posé par le LRS).
Idempotence : le statement est immuable
Règle d’or xAPI : un statement, une fois accepté, ne change plus. Il n’y a pas de « update ». Cette immuabilité te donne l’idempotence gratuitement — à condition de maîtriser l’id.
Concrètement :
- Renvoyer le même
PUTà l’identique (même id, même corps) après une coupure réseau est sûr : soit il n’était pas passé (il est créé), soit il l’était (rien de neuf). Pas de doublon. - Tenter d’écraser un statement existant avec un contenu différent sous le même id échoue en
409 Conflict. C’est voulu : l’historique d’apprentissage ne se réécrit pas. Pour « corriger » un statement, on ne le modifie pas — on l’annule (voiding, chapitre 7.2) et on en émet un nouveau.
⚠️ Piège — Utiliser
POSTdans une boucle de renvoi. CommePOSTfait générer un nouvel id à chaque appel, un renvoi après timeout crée un doublon : le même « a terminé » compté deux fois. Pour tout ce qui peut être rejoué (mobile, hors-ligne, retry), utilisePUTavec un id stable généré à la capture.
Gérer les réponses proprement
| Code | Sens | Ce que tu fais |
|---|---|---|
204 | Créé (PUT) | Marque comme envoyé. |
200 | Créé (POST) | Lis le tableau d’id renvoyé. |
400 | Statement invalide ou en-tête de version manquant | Ne rejoue pas : corrige (vérifie l’en-tête, puis le JSON). |
401 | Auth invalide | Vérifie Authorization ; ne rejoue pas tel quel. |
409 | Conflit d’id (contenu différent) / doublon | En idempotent, considère comme déjà stocké. |
5xx | Le LRS a un souci | Rejoue avec un backoff ; mets en file si ça persiste. |
Distinguer erreurs client (4xx : ne rejoue pas bêtement, corrige) et erreurs serveur/réseau (5xx, timeout : rejoue avec temporisation) est la base d’un connecteur LRS robuste. La file d’attente qui encaisse les pannes réseau est détaillée au chapitre 7.4.
🧭 Sur FormaCampus — L’appli mobile de FormaCampus trace même dans le métro. Chaque événement génère son
statementId(UUID) à la capture, avec letimestampréel, et part enPUT. Si le réseau manque, le statement attend en file locale et sera rejoué à l’identique plus tard : grâce à l’id stable, aucun doublon même si l’appli n’était pas sûre que le premier envoi avait abouti. C’est exactement la raison pour laquelle on préfèrePUTàPOSTcôté client.
🔌 Côté intégration — Un LRS conforme se teste. Les suites de conformité (côté ADL) vérifient précisément ces comportements : rejet d’une requête sans
X-Experience-API-Version, réponse204auPUT,200+ tableau d’id auPOST,409sur conflit d’id, immuabilité. Quand tu intègres un LRS tiers ou que tu exposes le tien, ce sont ces cas que l’autre partie vérifiera avant de te faire confiance.
📚 La spec — La ressource
statements(méthodesPUT,POST,GET), l’en-tête obligatoireX-Experience-API-Version, les codes de réponse et l’immuabilité des statements sont définis dans la spec xAPI d’ADL (adlnet.gov). Pour la valeur exacte de l’en-tête selon la version que sert ton LRS (par ex. la ligne 2.0 issue d’IEEE 9274.1.1), interroge sa ressourceabout, qui liste les versions supportées.
✏️ Exercices
Exercice 1 — Le 400 mystère. Un dev envoie ce statement et reçoit systématiquement 400 Bad Request. Le JSON est valide. Que manque-t-il ?
POST https://lrs.formacampus.fr/xapi/statements HTTP/1.1
Host: lrs.formacampus.fr
Authorization: Basic Zm9ybWFjYW1wdXM6czNjcjN0
Content-Type: application/json
{ "actor": { "mbox": "mailto:x@y.fr" },
"verb": { "id": "http://adlnet.gov/expapi/verbs/attempted", "display": { "fr-FR": "a tenté" } },
"object": { "id": "https://formacampus.fr/xapi/activities/quiz-1" } }✅ Solution
Il manque l’en-tête X-Experience-API-Version. Le LRS doit rejeter toute requête sans lui, avec un 400 — indépendamment de la validité du JSON. Ce n’est pas un 401 (l’auth est présente), et le corps est bon. Ajoute X-Experience-API-Version: 1.0.3 (ou la version que sert le LRS) et l’appel passe. C’est le premier réflexe devant un 400 inexpliqué sur un LRS.
Exercice 2 — PUT ou POST ? Tu écris le connecteur d’une appli mobile qui trace hors-ligne et synchronise plus tard, parfois en rejouant des envois dont elle n’est pas sûre qu’ils ont abouti. Quel verbe HTTP, et pourquoi ?
✅ Solution
PUT, avec un statementId (UUID) généré à la capture de l’événement et conservé dans la file locale. Raison : le PUT est idempotent. Rejouer le même PUT après une coupure ne crée pas de doublon (le LRS voit l’id déjà connu). Avec POST, chaque renvoi ferait générer un nouvel id par le LRS, donc un doublon à chaque incertitude réseau — le même « a terminé » compté deux fois dans les stats.
Exercice 3 — Corriger un statement. On a envoyé par erreur un statement « a réussi » avec score.scaled = 1.0 alors que c’était 0.4. On tente de le ré-PUT sous le même id avec la bonne valeur. Que se passe-t-il, et quelle est la bonne méthode ?
✅ Solution
Le ré-PUT avec un contenu différent sous le même id échoue en 409 Conflict : un statement est immuable, on ne le réécrit pas. La bonne méthode : annuler le statement fautif (émettre un statement de voiding avec le verbe http://adlnet.gov/expapi/verbs/voided visant l’id fautif — voir chapitre 7.2), puis émettre un nouveau statement avec score.scaled = 0.4 et un nouvel id. L’historique garde une trace de la correction plutôt que de mentir sur le passé.
🧠 Quiz de révision
1. Quel en-tête est obligatoire sur tout appel au LRS, et que se passe-t-il s’il manque ?
X-Experience-API-Version. Sans lui, un LRS conforme renvoie 400 Bad Request — pas 401. C’est le premier point à vérifier devant un 400 inexpliqué, avant même de suspecter le JSON.
2. Quelle est la différence entre PUT statements et POST statements ?
PUT statements et POST statements ?PUT : tu fournis le statementId (query), succès en 204 No Content, opération idempotente. POST : le LRS génère l’id, succès en 200 OK avec le tableau des id créés, non idempotent (rejouer crée un doublon). Pour du code résilient (retry, hors-ligne), on préfère PUT.
3. Peut-on modifier un statement déjà envoyé ?
Non. Un statement est immuable : pas d’« update ». Ré-PUT un contenu différent sous le même id renvoie 409 Conflict. Pour corriger, on annule (voiding) l’ancien et on en émet un nouveau.
4. Pourquoi poser soi-même le timestamp plutôt que laisser le LRS le faire ?
timestamp plutôt que laisser le LRS le faire ?Parce que le LRS, à défaut, horodate à la réception (stored). Pour un événement capturé hors-ligne et synchronisé le lendemain, ça fausse tout. On pose le timestamp (ISO 8601) au moment de l’événement ; le stored du LRS reste, lui, l’heure de réception.
5. Pourquoi ne jamais mettre le secret Basic dans du JavaScript navigateur ?
Parce qu’il serait exposé à quiconque ouvre l’onglet réseau : n’importe qui pourrait écrire dans ton LRS. Depuis un navigateur (ou une appli), on passe par un proxy côté serveur qui détient le secret et relaie les envois. On ne met jamais de credential LRS en clair côté client.
Chapitre suivant : 7.2 — Requêter le LRS — écrire, c’est fait ; voyons maintenant comment relire ces statements avec des filtres et de la pagination.