Open-source infrastructure for SCORM, xAPI, and cmi5 — with built-in LRS, webhooks, and AI analytics. Headless, API-first runtime engine. Embed it in a Node app, run it as a Docker container, or pair it with
@scormflow/react. Vendor-neutral REST API, end-to-end typed.
Existing open-source SCORM projects are either runtime libraries (no persistence) or full LMS forks (everything you don't want). ScormFlow is the missing middle: a small, embeddable engine that handles parsing, runtime APIs, tracking, and analytics — and stops there. You compose the LMS on top.
- Pluggable everywhere — storage (local / S3 / R2 / MinIO), auth (API key / JWT), database (Postgres / SQLite), AI provider (BYO key).
- Standards-first — SCORM 1.2, SCORM 2004, xAPI, and cmi5 share one normalized event model.
- Headless — every UI feature in the ecosystem is built on the public REST API. No private endpoints.
- Zero third-party SCORM libraries — clean-room implementation of the SCORM JS APIs. Any spec-compliant course works.
git clone https://github.com/scormflow/scorm-engine.git
cd scorm-engine
cp .env.example .env
docker compose -f docker/docker-compose.yml upThe engine is now live on http://localhost:3000. Liveness: GET /healthz. Readiness (database + storage reachable, 503 otherwise): GET /readyz. Both are also served inside the API as /api/v1/health and /api/v1/ready.
npm install @scormflow/serverimport { buildApp, loadEnv } from '@scormflow/server';
const env = loadEnv();
const app = await buildApp({ env });
await app.listen({ port: 3000 });Full spec: openapi/openapi.yaml.
POST /api/v1/courses Upload + register a SCORM package
POST /api/v1/courses/validate Validate a package without registering
GET /api/v1/courses List courses for the tenant
GET /api/v1/courses/:id Course metadata + SCO tree
GET /api/v1/courses/:id/content/* Stream an extracted package file
POST /api/v1/courses/:id/attempts Start or resume a learner attempt
GET /api/v1/attempts/:id Attempt state (objectives, interactions)
GET /api/v1/attempts/:id/runtime Rehydrate CMI for a (re)launched SCO
POST /api/v1/attempts/:id/commit Batched SetValue commit (+ terminate; optional idempotent `seq`)
POST /api/v1/attempts/:id/sync Replay an offline commit queue (applied / duplicate / conflict)
POST /api/v1/auth/attempt-token Mint a short-lived, attempt-scoped JWT
GET /api/v1/runtime.js Drop-in SCORM 1.2 / 2004 browser bridge
GET /api/v1/play/:attemptId Hydrated iframe player page
GET /api/v1/analytics/overview
GET /api/v1/analytics/courses/:id
GET /api/v1/analytics/learners/:id
GET /api/v1/xapi/about LRS version discovery (unauthenticated)
POST /api/v1/xapi/statements Store one statement or a batch
PUT /api/v1/xapi/statements?statementId= Store a statement under a known id
GET /api/v1/xapi/statements Query statements (filters + paging)
GET /api/v1/xapi/activities/state Fetch a state doc, or list state ids
PUT /api/v1/xapi/activities/state Replace a state document
POST /api/v1/xapi/activities/state Merge into a state document
DELETE /api/v1/xapi/activities/state Delete one or all state documents
GET /api/v1/xapi/activities/profile Fetch an activity profile, or list ids
PUT /api/v1/xapi/activities/profile Replace an activity profile document
POST /api/v1/xapi/activities/profile Merge into an activity profile document
DELETE /api/v1/xapi/activities/profile Delete an activity profile document
GET /api/v1/xapi/agents/profile Fetch an agent profile, or list ids
PUT /api/v1/xapi/agents/profile Replace an agent profile document
POST /api/v1/xapi/agents/profile Merge into an agent profile document
DELETE /api/v1/xapi/agents/profile Delete an agent profile document
GET /api/v1/xapi/activities Canonical Activity for an IRI
GET /api/v1/xapi/agents Person view of one agent
POST /api/v1/cmi5/courses Import a cmi5 course (ZIP or bare cmi5.xml)
POST /api/v1/cmi5/courses/validate Validate a course structure without importing
GET /api/v1/cmi5/courses List cmi5 courses
GET /api/v1/cmi5/courses/:id Course, AUs, and block structure
DELETE /api/v1/cmi5/courses/:id Delete a course and its registrations
POST /api/v1/cmi5/registrations Register a learner (actor or learnerId)
GET /api/v1/cmi5/registrations/:id Per-AU progress + course satisfaction
POST /api/v1/cmi5/registrations/:id/launch Launch an AU → launch URL
POST /api/v1/cmi5/registrations/:id/waive Waive an AU's moveOn requirement
GET /api/v1/cmi5/learner-preferences Read a learner's cmi5LearnerPreferences
PUT /api/v1/cmi5/learner-preferences Set language / audio preferences
POST /api/v1/cmi5/fetch One-time fetch URL → LRS auth token (AU-facing)
GET /api/v1/cmi5/content/:token/* Engine-hosted AU files (AU-facing)
POST /api/v1/webhooks Register an endpoint (returns its secret)
GET /api/v1/webhooks List endpoints
GET /api/v1/webhooks/event-types Event types you can subscribe to
PATCH /api/v1/webhooks/:id Change url / events / enabled
DELETE /api/v1/webhooks/:id Delete an endpoint and its delivery log
POST /api/v1/webhooks/:id/rotate-secret Replace the signing secret
POST /api/v1/webhooks/:id/test Send a signed webhook.test event
GET /api/v1/webhooks/:id/deliveries Delivery log (filter by status / event)
POST /api/v1/webhooks/deliveries/:id/retry Re-send a delivery, incl. dead-lettered ones
Auth: X-API-Key: <api-key> for tenant calls (the LRS included); a short-lived,
attempt-scoped JWT for the browser player's runtime calls. Every LRS request
except /xapi/about must also send X-Experience-API-Version: 1.0.3.
Send statements with attachment bodies as multipart/mixed: the statement JSON
first, then one part per body with Content-Transfer-Encoding: binary and
X-Experience-API-Hash: <sha2>. The LRS checks each body against its hash and
against the statements' attachments list (bodies may be omitted only for
attachments that carry a fileUrl), then stores it content-addressed under
tenants/<id>/xapi-attachments/. GET /xapi/statements?attachments=true
returns the same multipart/mixed shape. Signature attachments (JWS
RS256/384/512) must sign the statement they are attached to and are verified
when they embed an x5c certificate.
When SCORM content sets cmi.exit (cmi.core.exit in 1.2) to suspend and
then terminates, the commit returns suspended: true and the attempt stays
IN_PROGRESS: the session time is banked as total_time, and the next
POST /courses/:id/attempts for that learner resumes the same attempt with
entry: "resume" and its location and suspend_data intact. Any other exit
finalizes the attempt, and the next start opens a new one. The /play page
seeds entry and the learner id / name, so content can tell a resume from a
first launch.
The engine plays the cmi5 LMS role end to end. launch abandons any session the
learner left open on that AU, opens a new one, writes LMS.LaunchData, records
launched, and returns the AU URL with endpoint, fetch, actor,
registration and activityId attached. The AU redeems the fetch URL once for
an auth token and sends it to the built-in LRS as Authorization: Basic ….
Statements sent under that token are checked against the cmi5 statement rules
before they are stored — initialized first, nothing after terminated, no
LMS-only verbs, matching actor / registration / session id, the cmi5 and moveon
categories, completed / passed once per registration, masteryScore checks,
no moveOn verbs in Browse / Review mode — and a violation is a 403. A token
also only reaches its own learner and registration. When every AU in a block
meets its moveOn criterion the engine records satisfied for that block, and
when every AU in the course does, for the course.
Learner preferences (languagePreference, audioPreference) are stored as the
cmi5LearnerPreferences Agent Profile document, where AUs read them through the
LRS. Set them with PUT /cmi5/learner-preferences, or pass
learnerPreferences when creating a registration.
Session tokens and hosted-content links live for JWT_ATTEMPT_TTL_SECONDS.
Register an endpoint with the events it wants (or *):
curl -X POST $ENGINE/api/v1/webhooks -H "X-API-Key: $KEY" -H "content-type: application/json" \
-d '{"url":"https://example.com/hooks/scormflow","events":["course.completed","cmi5.course.satisfied"]}'Events: attempt.started, attempt.suspended, attempt.terminated, course.completed,
course.passed, course.failed, score.updated (SCORM attempts — each fires on
the transition, not on every commit); objective.mastered (a SCORM objective's
success status first becomes passed, matched by objective id);
cmi5.session.terminated, cmi5.session.abandoned, cmi5.au.waived,
cmi5.block.satisfied, cmi5.course.satisfied; xapi.statement.stored (once
per newly stored LRS statement — including cmi5 and LMS-issued ones, never for a
duplicate resend; data.statement is the stored statement); and webhook.test.
Each delivery is a JSON POST of { id, type, createdAt, data } with
ScormFlow-Signature: t=<unix>,v1=<hex> — HMAC-SHA256 over "<t>.<raw body>"
with the endpoint secret. Verify it against the raw body before trusting the
event (verifySignature here, or verifyWebhookSignature in @scormflow/sdk).
A 2xx acknowledges it. Anything else — including redirects and timeouts — is
retried with exponential backoff (WEBHOOK_RETRY_BASE_SECONDS × 2ⁿ, capped at 6 h)
until WEBHOOK_MAX_ATTEMPTS, then the delivery is parked as FAILED (the
dead-letter state) and can be re-sent from the delivery log. Delivery is
at-least-once: dedupe on the event id.
Retries are picked up by an in-process poller (WEBHOOK_WORKER_INTERVAL_MS) that
the server entrypoint starts; when embedding, pass startWebhookWorker: true to
buildApp. Deliveries are claimed with a lease, so several engine instances can
share one database. Endpoint URLs on localhost / private / link-local addresses
are refused unless WEBHOOK_ALLOW_PRIVATE_URLS=true; this check does not resolve
DNS, so pair it with egress filtering if tenants are untrusted.
src/
├── parser/ imsmanifest.xml + ZIP extraction [done]
├── runtime/ SCORM 1.2 + 2004 CMI model + session [done]
├── tracking/ Attempt + progress persistence [done]
├── xapi/ Built-in LRS (statements, documents, lookups) [done]
├── cmi5/ cmi5 course structure, launch, statement rules [done]
├── webhooks/ Endpoints, signing, delivery + retry worker [done]
├── storage/ Storage adapters: local disk, S3 / R2 / MinIO [done]
├── analytics/ Analytics + reporting [done]
├── http/ Fastify routes + runtime bridge [done]
├── auth/ API keys + attempt JWTs [done]
├── services/ Course / attempt / runtime services [done]
├── db/ Prisma client [done]
└── server.ts Entrypoint
Database: PostgreSQL (production) or SQLite (dev). ORM: Prisma. Schema in prisma/schema.prisma.
.env (see .env.example for the full list):
Checkboxes reflect the actual state of main.
- Repo init, TypeScript strict mode, Fastify app skeleton
- Prisma schema (Tenant, ApiKey, Course, Sco, Learner, Attempt, Interaction, Objective, CommitLog; xAPI, cmi5 + webhook tables)
- Pluggable
StorageAdapterinterface + local disk driver - SCORM ZIP extraction (yauzl)
-
imsmanifest.xmlparser (fast-xml-parser) - SCORM 1.2 / 2004 version detection
- Course ingestion service (upload → extract → parse → persist)
- Course management routes (upload, list, get, delete)
- Package validation endpoint (
POST /courses/validate) - API key auth + tenant scoping (
requireApiKeypreHandler) - OpenAPI 3.1 spec as source of truth
- Dockerfile + docker-compose (engine + Postgres)
- S3 / R2 / MinIO storage driver (dependency-free SigV4, multipart uploads)
- Readiness probe that checks the database and storage
- JWT issuance for runtime / player tokens (
POST /auth/attempt-token) - Attempt lifecycle routes (start/resume, read-back, terminate-on-commit)
- CMI data model + value validation (SCORM 1.2)
- CMI data model + value validation (SCORM 2004)
- Runtime API routes (batched commit + runtime rehydration)
- Resume support (suspend_data, lesson_location persistence; exit=suspend keeps the attempt open across sessions)
- Basic analytics endpoints (course / learner / overview)
- Standalone iframe player route (
GET /play/:attemptId) - Drop-in runtime bridge (
GET /runtime.js) - Multi-tenant rate limiting + isolated storage prefixes
- End-to-end test: upload → launch → track → resume → complete
- xAPI 1.0.3 Statement API (store, query, void, paging)
- xAPI 1.0.3 State API (documents, merge, ETag preconditions)
- xAPI 1.0.3 Activity Profile + Agent Profile APIs
- xAPI 1.0.3 Activity + Agent (Person) lookups
- xAPI attachments (multipart statement upload,
attachments=trueretrieval, JWS signature checks) - Built-in LRS, tenant-scoped and queryable
- cmi5 course structure import (
cmi5.xml, ZIP or structure-only) - cmi5 launch + registration management (LaunchData, one-time fetch URL, session tokens)
- cmi5 session lifecycle (initialized → completed / passed / failed / terminated / abandoned)
- cmi5 statement rules enforced by the LRS; moveOn evaluation, waive, course
satisfied - cmi5 block-level
satisfiedstatements andcmi5LearnerPreferencesagent profile - Webhook system (signed payloads, retries, dead-letter, delivery log)
- Webhook events for xAPI statements and SCORM objectives (
objective.mastered) - Offline sync queue + reconciliation (
POST /attempts/:id/sync, idempotentseqreplay, conflict reporting) - Certificate engine (PDF generation on completion)
- Event bus (BullMQ / Redis streams)
- Plugin system (
plugins: [analyticsPlugin(), certificatePlugin()])
- AI analytics layer with BYO provider + key (OpenAI / Anthropic / Gemini / Mistral / Ollama)
- PII redaction pass for AI prompts
- Session replay (event timeline, heatmaps, engagement scoring)
- Git-style course versioning (releases, diffs, rollback)
- Universal Learning Content Gateway (SCORM / xAPI / cmi5 / H5P / HTML / video / PDF)
- Compliance dashboards (audit log, attestation, certification expiry)
- Learning data warehouse export (BigQuery / Snowflake / Redshift / S3 Parquet)
pnpm install # or npm install
pnpm db:generate # generate Prisma client
pnpm db:migrate # run migrations
pnpm dev # tsx watch
pnpm test # vitest
pnpm openapi:lint # validate OpenAPI spec
pnpm build # tsc → dist/Requires Node ≥ 20.
Issues and PRs welcome. Before opening a PR:
- Run
pnpm typecheck && pnpm test && pnpm openapi:lint. - Match the existing style (Biome / ESLint settings in repo).
- For new endpoints, update
openapi/openapi.yaml— it's the contract the client repo depends on.
See SECURITY.md for vulnerability disclosure.
MIT — see LICENSE.
scorm-engine-client— TypeScript SDK, iframe player, and React components.scorm-engine-demo— reference LMS built on both repos (planned).
