Sending Statements
Send xAPI statements to the edu.games LRS via HTTP POST. All requests require Basic auth and the X-Experience-API-Version: 1.0.3 header.
Building a game hosted on edu.games? The player injects your endpoint, auth, actor, and registration at launch, and the statement shapes are governed by the Emission Profile — read that first. This page covers the raw HTTP mechanics, which apply to external clients and platform games alike.
Minimal Statement
Only actor, verb, and object are required. The LRS assigns a UUID and stored timestamp automatically.
const response = await fetch('https://lrs.edu.games/xapi/statements', {
method: 'POST',
headers: {
'Authorization': 'Basic ' + btoa('YOUR_KEY:YOUR_SECRET'),
'Content-Type': 'application/json',
'X-Experience-API-Version': '1.0.3',
},
body: JSON.stringify({
actor: {
objectType: 'Agent',
mbox: 'mailto:student@school.edu',
name: 'Jane Smith',
},
verb: {
id: 'http://adlnet.gov/expapi/verbs/completed',
display: { 'en-US': 'completed' },
},
object: {
objectType: 'Activity',
id: 'https://your-game.com/levels/1',
definition: {
name: { 'en-US': 'Level 1: Introduction' },
description: { 'en-US': 'First level of the game' },
type: 'http://adlnet.gov/expapi/activities/lesson',
},
},
}),
});
const ids = await response.json(); // ["550e8400-e29b-41d4-a716-446655440000"]
console.log('Statement ID:', ids[0]);Adding a Result
Include a result object to record scores, completion, and duration. The scaled score must be between -1.0 and 1.0.
result: {
score: {
raw: 85, // the player's actual score
min: 0, // lowest possible score
max: 100, // highest possible score
scaled: 0.85, // normalised to -1.0..1.0 (raw/max here)
},
success: true, // did they pass?
completion: true, // did they finish?
duration: 'PT4M30S', // ISO 8601 duration (4 min 30 sec)
response: 'B', // for answered statements: learner's response
}Adding Context
Context links the statement to a broader learning session or course hierarchy. A registration UUID groups all statements from a single play session together — generate it once when the session starts and reuse it.
const sessionId = crypto.randomUUID(); // generate once per play session
context: {
registration: sessionId, // groups statements from this session
contextActivities: {
parent: [{ objectType: 'Activity', id: 'https://your-game.com' }],
grouping: [{ objectType: 'Activity', id: 'https://your-game.com/world-1' }],
},
extensions: {
'https://your-game.com/ext/difficulty': 'medium',
'https://your-game.com/ext/lives-remaining': 2,
},
}Sending Multiple Statements
POST an array to submit multiple statements in a single request — the LRS returns an array of IDs in the same order. Limits: 250 statements per request and 120 requests per minute per credential, so batch on an interval (every 5–10 seconds) rather than firing per event. For final flushes on pagehide, use fetch(..., { keepalive: true }) and keep the batch under ~20 statements / 60 KB — browsers cap keepalive bodies at ~64 KB.
const response = await fetch('https://lrs.edu.games/xapi/statements', {
method: 'POST',
headers: {
'Authorization': 'Basic ' + btoa('YOUR_KEY:YOUR_SECRET'),
'Content-Type': 'application/json',
'X-Experience-API-Version': '1.0.3',
},
body: JSON.stringify([
{
actor: { objectType: 'Agent', mbox: 'mailto:student@school.edu' },
verb: { id: 'http://adlnet.gov/expapi/verbs/attempted', display: { 'en-US': 'attempted' } },
object: { objectType: 'Activity', id: 'https://your-game.com/levels/2' },
timestamp: '2026-06-27T10:00:00.000Z',
},
{
actor: { objectType: 'Agent', mbox: 'mailto:student@school.edu' },
verb: { id: 'http://adlnet.gov/expapi/verbs/completed', display: { 'en-US': 'completed' } },
object: { objectType: 'Activity', id: 'https://your-game.com/levels/2' },
result: { score: { raw: 92, min: 0, max: 100, scaled: 0.92 }, completion: true },
timestamp: '2026-06-27T10:04:30.000Z',
},
]),
});
const [attemptedId, completedId] = await response.json();Idempotent Submission
Include a id UUID in your statement to make it idempotent. The LRS deduplicates by statement ID: already-stored statements in a POST batch are skipped (their IDs still appear in the response), and a PUT for an existing ID returns 204 No Content. Resubmitting a batch never conflicts, so retry the identical batch — IDs included — and your retry loop is guaranteed to drain.
// Generate the ID before the attempt, so you can safely retry on network failure
const statementId = crypto.randomUUID();
async function sendWithRetry(statement, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const res = await fetch('https://lrs.edu.games/xapi/statements', {
method: 'POST',
headers: {
'Authorization': 'Basic ' + btoa('YOUR_KEY:YOUR_SECRET'),
'Content-Type': 'application/json',
'X-Experience-API-Version': '1.0.3',
},
body: JSON.stringify({ id: statementId, ...statement }),
});
if (res.ok || res.status === 204) return; // success or duplicate — both fine
throw new Error(`LRS error: ${res.status}`);
} catch (err) {
if (i === retries - 1) throw err;
await new Promise(r => setTimeout(r, 500 * (i + 1)));
}
}
}Common Patterns
Level Completion
// Send when a learner finishes a level
await sendStatement({
actor: { objectType: 'Agent', mbox: `mailto:${learner.email}` },
verb: { id: 'http://adlnet.gov/expapi/verbs/completed', display: { 'en-US': 'completed' } },
object: {
objectType: 'Activity',
id: `https://your-game.com/levels/${levelId}`,
definition: { name: { 'en-US': `Level ${levelId}` }, type: 'http://adlnet.gov/expapi/activities/lesson' },
},
result: {
score: { raw: score, min: 0, max: maxScore, scaled: score / maxScore },
success: score >= passMark,
completion: true,
duration: isoDuration(elapsedSeconds),
},
context: { registration: sessionId },
timestamp: new Date().toISOString(),
});Question Answer
// Send when a learner answers a question
await sendStatement({
actor: { objectType: 'Agent', mbox: `mailto:${learner.email}` },
verb: { id: 'http://adlnet.gov/expapi/verbs/answered', display: { 'en-US': 'answered' } },
object: {
objectType: 'Activity',
id: `https://your-game.com/questions/${questionId}`,
definition: {
name: { 'en-US': question.text },
type: 'http://adlnet.gov/expapi/activities/question',
interactionType: 'choice',
correctResponsesPattern: [question.correctAnswer],
choices: question.options.map((o, i) => ({ id: String(i), description: { 'en-US': o } })),
},
},
result: {
response: String(learnerAnswer),
success: learnerAnswer === question.correctAnswer,
completion: true,
},
context: { registration: sessionId },
});ISO 8601 Duration Helper
function isoDuration(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return `PT${h ? h + 'H' : ''}${m ? m + 'M' : ''}${s}S`;
}
// isoDuration(270) → "PT4M30S"
// isoDuration(3661) → "PT1H1M1S"HTTP Status Codes
| Status | Meaning |
|---|---|
| 200 OK | Statement(s) accepted. Body contains array of IDs (already-stored IDs are skipped but still listed). |
| 204 No Content | PUT with an already-stored statement ID — no change. |
| 400 Bad Request | Malformed statement (missing required fields, invalid JSON, etc). |
| 401 Unauthorized | Missing or invalid Authorization header. |
| 413 Payload Too Large | More than 250 statements in one request. |
| 429 Too Many Requests | Rate limit exceeded (120 requests/min per credential). |