Building an xAPI-Enabled Custom Learning Activity: The Definitive Implementation Guide for LMS Admins, Developers & IT Directors (2026)

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, …

xapi implementation guide

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.

James Smith

Written by James Smith

James is a veteran technical contributor at LMSpedia with a focus on LMS infrastructure and interoperability. He Specializes in breaking down the mechanics of SCORM, xAPI, and LTI. With a background in systems administration, James