The problem this guide solves is specific: you need to instrument a custom learning activity – a simulation, a branching scenario, a mobile performance support tool, or a bespoke web app – and get reliable, queryable xAPI statements flowing into an LRS. Most published walkthroughs stop at “here’s a basic statement.” This one doesn’t. It covers statement architecture, LRS authentication, activity ID taxonomy design, cmi5 launch integration, multi-LRS credential patterns, and the six errors that actually block production deployments.
The current governing specification is IEEE 9274.1.1-2023 (xAPI 2.0), approved by the IEEE on October 10, 2023, which standardizes and extends xAPI 1.0.3. For content authors and authoring tool vendors, 1.0.3 remains the interoperable baseline until cmi5 formally adopts 2.0 launch semantics. This guide covers both, with version-specific call-outs where behavior diverges.
The xAPI Statement: Protocol and Data Structure
xAPI is a RESTful API that accepts JSON payloads (statements) via HTTP POST to an LRS endpoint. The canonical statement structure follows the Actor–Verb–Object triple, extended with optional result, context, timestamp, and attachments properties.
Full Statement Structure (xAPI 1.0.3 / IEEE 9274.1.1-2023)
{
“id”: “7a7e2e4a-3c3f-4c3b-9c3d-1a2b3c4d5e6f”,
“actor”: {
“objectType”: “Agent”,
“name”: “Priya Sharma”,
“account”: {
“homePage”: “https://lms.example.com“,
“name”: “user-00482”
}
},
“verb”: {
“id”: “http://adlnet.gov/expapi/verbs/answered“,
“display”: { “en-US”: “answered” }
},
“object”: {
“objectType”: “Activity”,
“id”: “https://training.example.com/courses/fire-safety/v2/quiz/q-04“,
“definition”: {
“name”: { “en-US”: “Question 4: Extinguisher Selection” },
“description”: { “en-US”: “Identifies correct extinguisher class for electrical fires” },
“type”: “http://adlnet.gov/expapi/activities/cmi.interaction“,
“interactionType”: “choice”,
“correctResponsesPattern”: [“b”],
“choices”: [
{ “id”: “a”, “description”: { “en-US”: “Class A” } },
{ “id”: “b”, “description”: { “en-US”: “Class C” } },
{ “id”: “c”, “description”: { “en-US”: “Class K” } }
]
}
},
“result”: {
“response”: “b”,
“success”: true,
“score”: { “scaled”: 1.0, “raw”: 1, “min”: 0, “max”: 1 },
“duration”: “PT42S”
},
“context”: {
“registration”: “d7a3b1c2-0000-4e4a-8000-aabbccddeeff”,
“contextActivities”: {
“parent”: [
{ “id”: “https://training.example.com/courses/fire-safety/v2/quiz” }
],
“grouping”: [
{ “id”: “https://training.example.com/courses/fire-safety/v2” }
]
},
“platform”: “Custom Web Activity”,
“language”: “en-US”
},
“timestamp”: “2026-04-10T08:34:17.000Z”
}
xAPI 2.0 addition: The contextAgents array (new in IEEE 9274.1.1-2023) allows associating multiple agents with a statement – for example, linking an instructor and a peer to a collaborative activity – without stuffing them into extensions. If your LRS already supports 2.0, use contextAgents for multi-actor scenarios instead of custom extensions.
The Four xAPI RESTful Sub-APIs
| API | Endpoint | Purpose |
|---|---|---|
| Statement API | /xAPI/statements | POST/GET statements |
| State API | /xAPI/activities/state | Persist learner session state |
| Activity Profile API | /xAPI/activities/profile | Store shared activity metadata |
| Agent Profile API | /xAPI/agents/profile | Store per-agent data (preferences, bookmarks) |
Most custom activity implementations only need the Statement API. The State API is critical for resumable experiences – use it to checkpoint progress instead of rewriting completion logic into your app.
LRS certification: The ADL Initiative maintains an LRS conformance test suite (~1,300 tests). Before committing to an LRS vendor in production, verify whether they have passed the ADL LRS Test Suite certificate. Uncertified LRS implementations frequently fail on edge cases: voiding statements, concurrent PUT/POST handling, and signed statement validation via JSON Web Signature (JWS).
cmi5 vs. Raw xAPI Launch – When to Use Which
| Scenario | Use Raw xAPI | Use cmi5 |
|---|---|---|
| External system (IoT, simulator, app) reporting to LRS without LMS involvement | ✅ | ❌ |
| eLearning content launched from an LMS | ❌ | ✅ |
| Multi-AU (assignable unit) course packages | ❌ | ✅ |
| Informal learning, offline sync | ✅ | ❌ |
| Replacing SCORM in an existing LMS workflow | ❌ | ✅ |
Activity ID Taxonomy: The Gap Nobody Talks About
The most underspecified element in every xAPI walkthrough is the object.id (activity IRI) scheme. Inconsistent IRIs are the leading cause of analytics failures – queries return partial or duplicated data because the same activity has been published with three different IDs across environments.
The IRI must be a dereferenceable HTTP/HTTPS URI under a domain your organization controls. It does not need to resolve to a live page, but it must be stable and unique across the activity’s lifetime, including version updates.
Recommended IRI Taxonomy
Base: https://training.{your-domain}.com/
Course: /courses/{course-slug}/v{major}
Module: /courses/{course-slug}/v{major}/modules/{module-slug}
Screen: /courses/{course-slug}/v{major}/modules/{module-slug}/screens/{n}
Question: /courses/{course-slug}/v{major}/quiz/{question-id}
Version policy: Increment only the major version component in the IRI when the learning objective changes. Minor content edits (typos, asset updates) should not produce a new activity IRI. A new IRI creates a new analytics timeline – all historical statements for the old IRI become unreachable in comparative queries.
Implementation Steps for Custom Web Activity (JavaScript + xAPI Wrapper)
This section covers instrumenting a standalone HTML/JS learning activity to send statements to an external LRS using the ADL xAPI Wrapper (v1.10.0+).
Step 1: Configure LRS Credentials
Credentials must never be embedded in client-side code in production. Use a server-side token exchange or JWT-based short-lived credentials instead. For development, Basic Auth is acceptable.
// development-only – move credential issuance server-side in production
import { XAPIWrapper } from ‘xapiwrapper’;
const conf = {
endpoint: “https://lrs.example.com/xAPI/“, // trailing slash is REQUIRED
auth: “Basic ” + btoa(“your-api-key:your-secret”)
};
XAPIWrapper.changeConfig(conf);
Endpoint format rules: The endpoint must end with a trailing slash and must NOT include the word statements. Passing https://lrs.example.com/xAPI/statements as the endpoint will cause a 404 or malformed URL on every request.
Step 2: Design Your Statement Catalog Before Writing Code
Map every trackable event to a verb IRI before touching the JavaScript. Reference the ADL Vocabulary Server before creating custom verb IRIs. A minimal catalog for a quiz-based activity:
| Event | Verb IRI | Notes |
|---|---|---|
| Activity launched | http://adlnet.gov/expapi/verbs/initialized | Fire immediately on load |
| Question answered | http://adlnet.gov/expapi/verbs/answered | Include result.response |
| Section completed | http://adlnet.gov/expapi/verbs/completed | Set result.completion: true |
| Activity passed | http://adlnet.gov/expapi/verbs/passed | Include result.score |
| Activity failed | http://adlnet.gov/expapi/verbs/failed | Include result.score |
| Activity exited early | http://adlnet.gov/expapi/verbs/suspended | Save State API checkpoint |
| Activity terminated | http://adlnet.gov/expapi/verbs/terminated | Fire on beforeunload |
Step 3: Send Statements
function sendStatement(verbId, verbDisplay, activityId, activityName, result = null) {
const statement = {
actor: {
objectType: “Agent”,
account: {
homePage: window.location.origin,
name: getCurrentUserId() // pull from session/LMS launch params
}
},
verb: {
id: verbId,
display: { “en-US”: verbDisplay }
},
object: {
objectType: “Activity”,
id: activityId,
definition: {
name: { “en-US”: activityName }
}
},
timestamp: new Date().toISOString()
};
if (result) {
statement.result = result;
}
XAPIWrapper.sendStatement(statement, (response, body) => {
if (response.status !== 200 && response.status !== 204) {
console.error(“Statement failed:”, response.status, body);
// queue for retry – see offline handling below
}
});
}
// example call: user answered a question
sendStatement(
“http://adlnet.gov/expapi/verbs/answered“,
“answered”,
“https://training.example.com/courses/fire-safety/v2/quiz/q-04“,
“Question 4: Extinguisher Selection”,
{ response: “b”, success: true, score: { scaled: 1.0 } }
);
Step 4: Handle the terminated Statement Reliably
The terminated verb is the one most frequently dropped in production. Fire it on the beforeunload event using navigator.sendBeacon to ensure delivery even when the tab closes abruptly:
window.addEventListener(“beforeunload”, () => {
const terminatedPayload = buildStatement(“http://adlnet.gov/expapi/verbs/terminated”, …);
navigator.sendBeacon(
`${conf.endpoint}statements`,
JSON.stringify(terminatedPayload)
);
});
sendBeacon bypasses the standard XHR abort-on-unload problem but does not support custom Authorization headers. Implement a proxy endpoint on your own server that adds the LRS credentials server-side before forwarding to the LRS.
Step 5: Offline Statement Queuing
For mobile or unreliable-network contexts, persist unsent statements in IndexedDB and flush on reconnect:
async function queueOrSend(statement) {
if (navigator.onLine) {
sendStatement(statement);
} else {
const db = await openStatementQueue();
await db.add(‘pending’, statement);
}
}
window.addEventListener(‘online’, async () => {
const db = await openStatementQueue();
const pending = await db.getAll(‘pending’);
for (const stmt of pending) {
sendStatement(stmt);
}
await db.clear(‘pending’);
});
Common Issues and Fixes
Error 1: 401 Unauthorized on Every Statement POST
Cause: Credentials are being passed incorrectly. The Authorization header value must be Basic followed by the Base64 encoding of key:secret – note the colon separator and the space after Basic.
Fix: Verify the encoded string independently:
echo -n “your-api-key:your-secret” | base64
Paste that output directly into Postman or curl to confirm the endpoint accepts raw credentials before debugging the application layer.
Error 2: CORS Preflight Blocked (HTTP 403 or No Response)
Cause: The LRS is not returning Access-Control-Allow-Origin headers, or the content is served from a domain not whitelisted on the LRS.
Fix: On a self-hosted LRS (Learning Locker, ADL LRS), add CORS headers at the reverse proxy level (nginx/Apache), not in the application. The xAPI spec requires LRS implementations to support CORS for browser-based clients. CORS is an HTTP-header-based mechanism that allows a server to indicate origins other than its own from which a browser should permit loading of resources. If you cannot configure the LRS CORS policy, route all statement POSTs through a same-domain server-side proxy.
Error 3: Statements POST but Never Appear in LRS Queries
Cause: The activity IRI in object.id is inconsistent across sessions (e.g., trailing slashes, case differences, or environment-specific hostnames like dev.example.com vs. training.example.com).
Fix: Centralize the activity IRI as a constant in a shared module. Never construct it dynamically from window.location. All LRS queries filter by exact IRI match – one character difference creates a separate activity record.
Error 4: terminated Statement Never Received
Cause: XHR requests are aborted when the browser tab closes. Standard XAPIWrapper.sendStatement() calls in beforeunload fail silently.
Fix: Use navigator.sendBeacon() through a same-domain proxy (described in Step 4). Verify delivery by checking LRS logs – not just the browser console, which clears on unload.
Error 5: LRS Returns 400 Bad Request with No Diagnostic Body
Cause: Malformed IRI in verb.id or object.id (e.g., using a plain string like “completed” instead of a full IRI), or a missing required field. The conf object must be set and sent successfully – this authorizes your statement with your LRS; if it is not set correctly, statements will not send.
Fix: Validate statements against the ADL Statement Validator (https://adlnet.gov/statement-validator) before deploying. Enable verbose logging in your LRS during development – Veracity LRS, for example, has a “Verbose Logs” toggle that captures full request/response payloads for debugging.
Error 6: Score Values Rejected or Stored Incorrectly
Cause: The result.score.scaled value must be between -1.0 and 1.0. Passing a percentage (e.g., 85) instead of a decimal (0.85) violates the spec and causes LRS validation failures or silent score truncation.
Fix: Always compute scaled = raw / max before sending. Include all four score properties (scaled, raw, min, max) to prevent downstream analytics tools from making invalid assumptions.
💡 Experienced LMS Admin Tip - Multi-Tenant LRS Credential Management
In enterprise deployments with multiple content providers sending statements to a single LRS, the instinct is to use one shared key/secret pair. Don’t. Create a distinct access key per content source (authoring tool output, custom simulation, mobile app, third-party vendor). This gives you per-source audit trails, lets you rotate compromised credentials without disrupting all content simultaneously, and allows you to set scoped permissions (e.g., a vendor key that can only write, never read or aggregate). Veracity, Watershed, and SCORM Cloud all support named keys with per-key permission scopes. Document your key registry in your LRS admin panel notes field – it will save the next admin hours of archaeology.
FAQ
Q1. Do I need cmi5 if I'm already using xAPI in a custom activity?
Not necessarily. cmi5 is an xAPI Profile that adds packaging, launch, and session management rules specifically for LMS-hosted content. If your custom activity is launched outside an LMS – a standalone web app, a mobile app, an IoT device – raw xAPI without cmi5 is the right approach. If it must be imported into an LMS as a course package and launched with proper session registration and credential handshake, implement cmi5. The practical test: does the LMS need to control launch, pass credentials to the activity, and receive structured session data? If yes, use cmi5.
Q2. xAPI 2.0 (IEEE 9274.1.1-2023) was published in October 2023 - should I be building to it now?
For analytics and LRS infrastructure, yes – adopt 2.0 now, as the changes are backwards compatible and the new contextAgents feature is immediately useful for collaborative learning scenarios. For content authors and authoring tool vendors, interoperable launch of xAPI 2.0 content is not yet possible without cmi5 formally adopting 2.0 semantics. Build content to 1.0.3 behavioral compliance, ensure your LRS is 2.0 compatible, and plan a content-side migration when tooling catches up.
Q3. What's the correct way to identify actors if our LMS uses SSO and doesn't expose email addresses?
Use the account object (not mbox) with the LMS’s internal user identifier and the LMS homepage as homePage. This matches the pattern used by most enterprise LMS platforms and avoids PII exposure in statement payloads. If you later need to correlate statements across systems, the Agent Profile API can store cross-system ID mappings without embedding them in individual statements.