Documentation
xAPI INTEGRATION

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.

javascript
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.

javascript
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.

javascript
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.

javascript
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.

javascript
// 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

javascript
// 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

javascript
// 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

javascript
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

StatusMeaning
200 OKStatement(s) accepted. Body contains array of IDs (already-stored IDs are skipped but still listed).
204 No ContentPUT with an already-stored statement ID — no change.
400 Bad RequestMalformed statement (missing required fields, invalid JSON, etc).
401 UnauthorizedMissing or invalid Authorization header.
413 Payload Too LargeMore than 250 statements in one request.
429 Too Many RequestsRate limit exceeded (120 requests/min per credential).
xAPI OverviewTracking Progress