Skip to content
Learni
View all tutorials
JavaScript

How to Implement an xAPI Client in TypeScript in 2026

Lire en français

Introduction

xAPI, or Experience API (also known as Tin Can API), is the open standard for capturing and storing granular learning data beyond simple SCORM quizzes. Unlike SCORM, which is limited to closed LMSs, xAPI tracks any experience: mobile apps, VR, games, micro-learning.

Why implement it in 2026? Modern LMSs (Moodle, Cornerstone) and LRSs (Learning Record Stores) like Learning Locker require xAPI for interoperability. This advanced tutorial guides you through creating a complete TypeScript/Node.js client: strict types, sending statements, Basic Auth authentication, batches, JSON Schema validation, and error handling.

By the end, you'll have a reusable module to track events like 'initialized', 'completed', or 'answered'. Ideal for integrating into a React/Next.js app or custom backend. Analogy: xAPI is like a blockchain journal for learning – immutable, decentralized, and rich in metadata.

Prerequisites

  • Node.js 20+ installed
  • Advanced TypeScript knowledge (generics, interfaces)
  • Familiarity with xAPI (statements, actor/verb/object)
  • A free LRS account (e.g., Learning Locker demo or ADL LRS)
  • Tools: npm/yarn, VS Code with ESLint/Prettier

Project Initialization

terminal
mkdir xapi-client-ts && cd xapi-client-ts
npm init -y
npm install typescript @types/node axios json-schema-validator
npm install -D ts-node nodemon @types/json-schema-validator
npx tsc --init

This script sets up a Node.js project with TypeScript, installs Axios for HTTP requests, a JSON Schema validator for xAPI, and ts-node for direct TS execution. Update tsconfig.json to include esModuleInterop: true and strict: true. Run with npx ts-node index.ts.

xAPI Type Definitions

types/xapi.ts
export interface Actor {
  mbox: string;
  name?: string;
  account?: { name: string; homePage: string };
}

export interface Verb {
  id: string;
  display: { [lang: string]: string };
}

export interface ActivityObject {
  id: string;
  definition: {
    name: { [lang: string]: string };
    type: string;
  };
}

export interface Statement {
  actor: Actor;
  verb: Verb;
  object: ActivityObject;
  timestamp?: string;
  id?: string;
  result?: { success: boolean; duration: string };
}

export interface XAPIError {
  error: string;
  message: string;
}

These TypeScript interfaces strictly model a basic xAPI statement (level 1). Use mbox for the actor (email IRI), standard IRIs for verbs (e.g., 'http://adlnet.gov/expapi/verbs/initialized'). Add account for anonymous users. This prevents 80% of LRS formatting errors.

Understanding Statement Structure

An xAPI statement follows the Actor-Verb-Object model: who (actor), does what (verb), to what (object). Concrete example: a learner ('user@example.com') initializes an activity ('http://example.com/quiz1').

  • Actor: Identified by IRI (mbox, account, SHA1-hash for anonymity).
  • Verb: Standard IRI + multilingual translations.
  • Object: Activity with definition (name, type).
Analogy: like a SQL INSERT statement, but for learning. Extensions (result, context) add richness.

xAPI Client with Authentication

client/xapi-client.ts
import axios, { AxiosError } from 'axios';
import type { Statement, XAPIError } from '../types/xapi';

const LRS_URL = 'https://lrs.adlnet.gov/xAPI/'; // Remplacez par votre LRS
const USERNAME = 'your-username';
const PASSWORD = 'your-password';

const auth = Buffer.from(`${USERNAME}:${PASSWORD}`).toString('base64');

export class XAPIClient {
  private baseURL: string;

  constructor(lrsURL: string) {
    this.baseURL = `${lrsURL}data/xAPI/statements`;
  }

  async sendStatement(statement: Statement): Promise<void> {
    try {
      const response = await axios.post(this.baseURL, statement, {
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Basic ${auth}`,
          'X-Experience-API-Version': '1.0.3'
        }
      });
      console.log('Statement ID:', response.data.id);
    } catch (error) {
      const err = error as AxiosError<XAPIError>;
      throw new Error(`xAPI Error: ${err.response?.data?.message || err.message}`);
    }
  }
}

This client encapsulates POST requests to /data/xAPI/statements with Basic Auth and xAPI version 1.0.3 header. Axios provides typed error handling. Replace LRS_URL/credentials with your own; test with free ADL LRS. Pitfall: forget the version header → 400 rejection.

Example: 'initialized' Statement

examples/initialized.ts
import { XAPIClient } from '../client/xapi-client';
import type { Statement } from '../types/xapi';

const client = new XAPIClient('https://lrs.adlnet.gov/xAPI/');

const statement: Statement = {
  actor: {
    mbox: 'mailto:learner@example.com',
    name: 'John Doe'
  },
  verb: {
    id: 'http://adlnet.gov/expapi/verbs/initialized',
    display: { 'fr': 'a initialisé', 'en-US': 'initialized' }
  },
  object: {
    id: 'http://example.com/activities/math-quiz',
    definition: {
      name: { 'fr': 'Quiz de mathématiques', 'en-US': 'Math Quiz' },
      type: 'http://adlnet.gov/expapi/activities/cmi-interaction'
    }
  }
};

(async () => {
  await client.sendStatement(statement);
})();

This script sends a complete, valid 'initialized' statement. Copy-paste to test instantly. Use official IRIs (adlnet.gov) for maximum interoperability. Result: LRS returns the stored statement's UUID.

Handling Results and Contexts

Result adds success/duration: { success: true, duration: 'PT5M' } (ISO 8601). Context for registration-ID or instructor. Extend the Statement interface for this.

For 'completed': use verb http://adlnet.gov/expapi/verbs/completed, result with score.

'completed' Statement with Result

examples/completed.ts
import { XAPIClient } from '../client/xapi-client';
import type { Statement } from '../types/xapi';

const client = new XAPIClient('https://lrs.adlnet.gov/xAPI/');

const statement: Statement = {
  actor: {
    mbox: 'mailto:learner@example.com',
    name: 'John Doe'
  },
  verb: {
    id: 'http://adlnet.gov/expapi/verbs/completed',
    display: { 'fr': 'a terminé', 'en-US': 'completed' }
  },
  object: {
    id: 'http://example.com/activities/math-quiz',
    definition: {
      name: { 'fr': 'Quiz de mathématiques' },
      type: 'http://adlnet.gov/expapi/activities/cmi-interaction'
    }
  },
  result: {
    success: true,
    duration: 'PT10M30S',
    score: { scaled: 0.85, raw: 17, min: 0, max: 20 }
  }
};

(async () => {
  await client.sendStatement(statement);
})();

Adds a complete result with scaled score (0-1). Duration in ISO 8601. Perfect for closing a session. Pitfall: score without min/max → LRS validation fails.

Batch Sending (voided)

examples/batch.ts
import { XAPIClient } from '../client/xapi-client';
import type { Statement } from '../types/xapi';

const client = new XAPIClient('https://lrs.adlnet.gov/xAPI/');

const statements: Statement[] = [
  // Statement initialized (du précédent exemple)
  {
    actor: { mbox: 'mailto:learner@example.com', name: 'John Doe' },
    verb: { id: 'http://adlnet.gov/expapi/verbs/initialized', display: { 'fr': 'a initialisé' } },
    object: { id: 'http://example.com/activities/math-quiz', definition: { name: { 'fr': 'Quiz maths' }, type: 'http://adlnet.gov/expapi/activities/cmi-interaction' } }
  },
  // Voided pour annuler
  {
    actor: { mbox: 'mailto:system@example.com' },
    verb: { id: 'http://adlnet.gov/expapi/verbs/voided', display: { 'fr': 'a annulé' } },
    object: { id: '00000000-0000-0000-0000-000000000000', objectType: 'StatementRef' } // Référence UUID du statement à voider
  }
];

(async () => {
  for (const stmt of statements) {
    await client.sendStatement(stmt);
  }
  console.log('Batch envoyé');
})();

Sends a sequential batch: initialized then voided (cancellation via StatementRef). For true batch, POST [{...}] to /statements (extend the method). Use for production corrections.

JSON Schema Validation

utils/validator.ts
import Ajv, { JSONSchemaType } from 'ajv';
import type { Statement } from '../types/xapi';

const ajv = new Ajv({ allErrors: true });

const schema: JSONSchemaType<Statement> = {
  type: 'object',
  properties: {
    actor: { type: 'object', properties: { mbox: { type: 'string', format: 'email' } } },
    verb: { type: 'object', properties: { id: { type: 'string', format: 'uri' } } },
    object: { type: 'object', properties: { id: { type: 'string', format: 'uri' } } }
  },
  required: ['actor', 'verb', 'object'],
  additionalProperties: false
};

const validate = ajv.compile(schema);

export function validateStatement(statement: unknown): statement is Statement {
  return validate(statement);
}

// Usage
if (!validateStatement(myStatement)) {
  console.error(validate.errors);
  throw new Error('Invalid statement');
}

Integrate Ajv to validate before sending against a minimal schema. 'email'/'uri' formats catch common errors. Add to sendStatement: if (!validateStatement(statement)) throw .... Essential in production to avoid LRS 400s.

Best Practices

  • Always validate: Use JSON Schema or Zod before sending.
  • Anonymize: Prefer account or SHA1-mbox for GDPR.
  • Smart batches: POST arrays up to 100 statements for performance.
  • Retry with backoff: Implement exponential retry on 5xx.
  • Structured logs: Store statement IDs for audits.

Common Errors to Avoid

  • Bad IRI: Non-standard verbs → LRS rejection (use adlnet.gov).
  • No auth: 401 Unauthorized; encode Basic Auth correctly.
  • Missing timestamp: LRS adds it, but force ISO for consistency.
  • Object without definition: 400 Bad Request; always include name/type.

Next Steps

Integrate xAPI into React with @xapi/xapiwrapper. Explore cmi5 for packaging. Build a custom LRS with Rust/ScyllaDB.

Check out our Learni trainings on xAPI and eLearning for advanced certifications.