LMS compatibility
What is verified where: dated SCORM Cloud and Moodle runs for SCORM 1.2 and 2004, the per-LMS matrix, the two platform quirks every vendor hits, and how to reproduce every claim.
Last reviewed
This page tracks how Scormbusters exports are verified. The policy: no green check without a receipt — a claim appears here only with the evidence that produced it.
Standards conformance
| Standard | Status | Evidence |
|---|---|---|
| SCORM 1.2 | Spec-conformant in code | 100+ package tests: manifest schema, API call order, status vocabulary, suspend-data limits, cross-session resume, cmi.interactions |
| SCORM 2004 3rd Ed. | Spec-conformant in code; full-course export verified on SCORM Cloud 2026-09-24; probe passed on Moodle 4.5, 2026-09-17 (smoke test — see note) | Same suite: dual completion/success status, cmi.score.scaled, sequencing namespaces. Moodle documents its native SCORM 2004 support as incomplete, so the Moodle run verifies the data model and API calls, not SCORM 2004 conformance; the conformance claim rests on SCORM Cloud. |
| SCORM Cloud validation | SCORM 1.2 probe verified 2026-09-17; full-course exports verified under SCORM 1.2 and SCORM 2004 2026-09-24 | Probe: 22 checks against a live registration, results below. Full course: a real multi-lesson export with a quiz, walked like a learner by the automated runner — registration recorded completed / passed / score 100 under both standards, and a deliberately failed attempt recorded failed with the remediation path shown. |
| LTI 1.3 launch + AGS grade passback | Verified on Canvas and Moodle 5.2.3, 2026-10-05 (beta until Blackboard) | OIDC login, JWKS, nonce replay protection, provisioned learner sign-in; AGS score passback on completion. Automated end-to-end run: passing and failing learners' scores both land in Moodle's gradebook — results below. No Deep Linking. |
| xAPI statement forwarding | Implemented | Statements forwarded to the LRS endpoint configured for the organization |
Per-LMS verification matrix
Practitioners know that "SCORM-conformant" and "works in my LMS" are different claims — Cornerstone, SumTotal, and Moodle each have documented quirks that break conformant packages. This matrix records real imports into real LMS instances. Empty cells mean not yet verified, not "broken".
| LMS | SCORM 1.2 | SCORM 2004 | LTI 1.3 | Notes |
|---|---|---|---|---|
| SCORM Cloud | ✅ probe 2026-09-17 + 2026-10-03, full course 2026-09-24 + 2026-10-03 | ✅ probe 2026-10-03, full course 2026-09-24 + 2026-10-03 | n/a | Pre-opens the session; reports entry as empty on resume. Both handled. Full-course runs are automated (scripts/lms-probe/run-scorm-cloud.mjs). |
| Moodle 4.5 | ✅ 2026-09-17 | ⚠️ probe passed 2026-09-17 — smoke test only, see note | — | Automated — npm run probe:moodle. Same two quirks as SCORM Cloud. Moodle's own docs call its native SCORM 2004 support unsupported/incomplete (no sequencing), so a passing probe there does not establish 2004 conformance. |
| Moodle 5.2.3 | ✅ package replacement 2026-10-07 | — | ✅ launch + AGS 2026-10-05 | Automated — npm run probe:moodle:lti (registration, launch, provisioning, grade passback) and npm run probe:moodle:replace (an imported course replacing its original keeps completions and grades); see below. |
| saLTIre (ceLTIc platform emulator) | n/a | n/a | ✅ launch + AGS 2026-10-05 | Strict, schema-validating second implementation. Automated — npm run probe:saltire:lti. Both line item paths; see below. |
| Canvas (open-source canvas-lms, 2026-04-30) | — | — | ✅ launch + AGS 2026-10-05 | Assignment and module-item placements. Automated — npm run probe:canvas:lti in a Codespace; see below. |
| Blackboard | — | — | — | |
| Cornerstone | — | — | — | |
| TalentLMS | — | — | — | |
| Docebo | — | — | — |
SCORM Cloud — SCORM 1.2, 17 September 2026
Run with the conformance probe (npm run build:probe) against a live registration on SCORM Cloud, the engine written by the authors of the spec. A failure here is ours, not the platform's, which is why it is the first platform tested and the only one where "the LMS is wrong" is never the explanation.
Result: all data-model checks passed. Two behaviours differ from a naive reading of the spec, and both are now handled in the driver with tests.
| Check | Result |
|---|---|
| API discovered | pass — 1 frame up |
| Learner name and id supplied | pass |
lesson_location round-trip | pass |
suspend_data round-trip at 256 / 512 / 1024 / 4096 chars | pass at every size |
suspend_data meets the spec floor | pass — 4096 chars survived, which is the SCORM 1.2 requirement |
lesson_status = incomplete / completed / passed | pass |
score.raw, score.min, score.max | pass |
session_time accepted | pass — 0000:02:30.00 |
cmi.interactions write | pass |
| Invalid element reports an error | pass — 201, "Invalid argument error" |
Commit | pass |
| Resume after a full relaunch | pass — suspend_data and lesson_location both survived |
| Registration state at the LMS | completion COMPLETED, success PASSED, score 75 |
SCORM Cloud — full-course exports, 24 September 2026
The probe proves the driver; it does not prove a real export. On 2026-09-24 a real multi-lesson course with a graded quiz was exported under each standard and walked like a learner by the automated runner (scripts/lms-probe/run-scorm-cloud.mjs, which imports the zip, launches it in SCORM Cloud, clicks through every lesson and answers the quiz):
| SCORM 1.2 | SCORM 2004 3rd Ed. | |
|---|---|---|
| Import accepted | pass | pass |
| Launches and plays | pass | pass |
| Completion recorded | COMPLETED | COMPLETED |
| Success recorded | PASSED | PASSED |
| Score recorded | 100 | 100 |
| Deliberate wrong answers | FAILED recorded, remediation path shown | FAILED recorded, remediation path shown |
Worth recording rather than hiding: this run is what *first made exports work in a real LMS*. Until that day every lesson was packaged as its own SCO (so the player never initialized and nothing was ever recorded), branching dropped quiz scores, and media was never bundled. The whole class of defect is why this page's policy exists — the unit suite was green throughout.
SCORM Cloud — re-verified 3 October 2026
Re-run with the then-current exporter (after slide masters, per-object language, cmi5/xAPI format parity and the audit-trail work landed): a fresh single-lesson course with a graded multiple-choice question, exported under both standards and walked by the automated runner. Both registrations recorded completion COMPLETED, success PASSED, score 100.
The 68-check probe was also run on SCORM Cloud under both standards the same day (the 2004 probe's first SCORM Cloud run — 2026-09-17 covered 1.2 there and 2004 only on Moodle). SCORM 1.2: all checks pass, same two known quirks. SCORM 2004: every data-model, interaction and resume check passes — including entry = "resume", which 1.2 leaves empty — with one new observation: SCORM Cloud reads cmi.success_status back as "unknown" within the session even after accepting a write of "passed", and a probe-style session that never re-terminates after resume is left UNKNOWN at the registration. Real exports are unaffected (the full-course walk above records PASSED under 2004): the driver writes status and never depends on reading it back. Recorded here so nobody mistakes the probe's read-back line for an export defect.
Two things worth knowing about SCORM Cloud
It opens the session before the content does. LMSInitialize returns "false" to the content, because the session is already open — not because anything failed. Every subsequent call works normally. Content that treats a false return as fatal will break on the reference implementation, which is a sharp edge worth knowing about if you are writing your own player. Our driver asks LMSGetLastError whether the session is usable rather than trusting the return value, and warns the learner only when it genuinely is not.
`cmi.core.entry` is empty on resume, not `"resume"`. A player that decides whether to restore progress by reading entry will start a returning learner from the beginning. Ours reads lesson_location and suspend_data directly and never consults entry.
Both are recorded as regression tests in packages/scorm/src/__tests__/scorm-driver.test.ts.
Moodle 4.5 — SCORM 1.2 and 2004, 17 September 2026
What the 2004 column here does and does not show. Moodle's own documentation (SCORM FAQ) says Moodle is not fully SCORM 2004 compliant — it implements the 2004 run-time data model but not sequencing and navigation. Our 2004 probe passing in Moodle therefore shows that the exported package launches, that the data model round-trips (including score.scaled and the 64,000-character suspend_data floor) and that resume works in Moodle — a useful smoke test, not SCORM 2004 conformance. The 1.2 columns are the verified claim; 2004 verification belongs to SCORM Cloud, where the full-course run passed on 2026-09-24 (table above).
Run against a Moodle installed from scratch in Docker, driven end to end in headless Chromium: log in, create a course, upload the package through Moodle's file picker, launch, read the report, relaunch to test resume. No step is manual, so this is the one platform where a regression is caught here rather than reported by a customer.
npm run build:probe && npm run probe:moodle:up
npm run probe:moodle -- scorm12
npm run probe:moodle -- scorm2004| SCORM 1.2 | SCORM 2004 3rd Ed. | |
|---|---|---|
| Checks passed | 22 | 46 |
| Failures | 0 | 0 |
| Warnings | 2 (both expected, below) | 2 (both expected, below) |
suspend_data ceiling reached | 4,096 chars — the full 1.2 spec floor | 64,000 chars — the full 2004 spec floor |
| Resume across a relaunch | suspend_data and lesson_location both survived | survived |
cmi.interactions | accepted; read back empty, which 1.2 permits | accepted and read back (probe-q1) |
| Invalid element reports an error | 201, "Invalid argument error" | 401, "Undefined Data Model" |
| Completion / success / score | recorded correctly | recorded correctly, including score.scaled |
Moodle reopens a completed attempt in review mode on relaunch, which is correct — the probe finishes the course on its first pass, so the second launch is a review of a finished attempt rather than a resume. A learner who leaves mid-course gets normal mode and their state back; that is the 1.2 row above, where the probe stops short of completion.
The same two quirks appear on both platforms
That SCORM Cloud and Moodle — independent implementations, one of them the reference — both do these things makes them properties of the category, not one vendor's bug:
Initialize returns false because the session is already open. Moodle reports it as error 101 ("General exception") under SCORM 1.2 and 103 ("Already Initialized") under 2004; SCORM Cloud returns false with no error at all. Three different signals for one situation, which is why the driver asks whether the session *works* — it reads a value the LMS always supplies and checks whether that read errored — instead of trying to interpret the return code.
`cmi.core.entry` is empty on resume rather than `"resume"`. On both platforms. A player that decides whether to restore progress by reading entry sends every returning learner back to slide one, on the two most common SCORM hosts in the world. Ours reads lesson_location and suspend_data directly.
Moodle 5.2.3 — LTI 1.3 launch and grade passback, 5 October 2026
SCORM proves the packages; LTI is hosted delivery, so it gets its own run. scripts/lms-probe/run-moodle-lti.mjs stands it up end to end with nothing typed by hand: it registers Scormbusters in Moodle as an LTI 1.3 tool with grade sync, registers Moodle in Scormbusters through the same API the settings page uses, signed in as the organization's owner, then launches as two Moodle students. One answers the course's graded question correctly, one does not, and both mark the course complete. It then reads Moodle's gradebook.
| Check | Result |
|---|---|
| OIDC login → platform auth request | pass — a GET, as the spec expects |
| Signed launch verified, learner provisioned and signed in | pass — no Scormbusters login screen |
| Launch lands on the player | pass — a GET |
| Passing learner's score in the Moodle gradebook | pass — 100 |
| Failing learner's score in the Moodle gradebook | pass — 0, with the comment "Did not reach the passing score" |
| Learner names passed through from Moodle | pass |
Worth recording rather than hiding, as with the SCORM exports: the first run is what made grade passback work for anyone who followed our own setup screen. Three defects, each invisible to the unit suite:
- Nothing in the product ever created the organization's signing key, so the token request grade passback depends on could not be signed. Registering a platform now creates the key.
- The JWKS URL the settings page (and this documentation) told admins to register returned an empty key set — keys are served per organization and the URL lacked the organization's id. Both now show the full URL.
- Both handshake redirects were 307s, which re-POST. The hop to the platform's auth endpoint has to be a GET: a cross-site POST does not carry an LMS session cookie set
SameSite=Lax, which is how Moodle sets it. Both are now 303s.
Launches had worked throughout; grades silently never arrived. That is the whole case for running this against a real LMS rather than trusting a green unit suite. Canvas and Blackboard are next — until they are verified, LTI stays labelled beta.
saLTIre — strict LTI 1.3 + AGS conformance, 5 October 2026
Moodle is lenient: it accepted a launch handshake that broke the spec until the fix above. So the same flow was run against saLTIre, the ceLTIc project's platform emulator — an independent implementation that validates service requests against the specs' JSON schemas — reading saLTIre's own log of what it received and what it answered (scripts/lms-probe/run-saltire-lti.mjs).
| Check | Line item named in the launch | Only the line item container offered |
|---|---|---|
| OIDC login → platform auth request is a GET | pass | pass |
| Signed launch verified, learner provisioned | pass | pass |
| Token request (our signed client assertion) | pass — 200 | pass — 200 |
| Tool finds its own line item by resource link | — | pass — 200 |
| Score accepted | pass — 204 | pass — 204 |
| Score body | 100 / 100, Completed, FullyGraded | 0 / 100 with comment, Completed, FullyGraded |
The second column is how Canvas behaves when Scormbusters is added as a module item rather than an assignment: the launch carries only the line item container, and the tool must find or create its own column. Reading that path while it ran exposed a latent defect, fixed the same day: the tool took the first line item the container returned. A platform that ignores the resource_link_id filter returns every column in the course, so a learner's score could have been written into another assignment. The tool now only uses a line item whose resourceLinkId matches, and otherwise creates its own.
What this does not replace. saLTIre proves conformance to the spec, not a platform's quirks — the Canvas run below found one it could not have.
Canvas — LTI 1.3 launch and grade passback, 5 October 2026
Instructure's own code: instructure/canvas-lms at commit 1c9f0bb8 (2026-04-30), built with Canvas's docker dev setup in a GitHub Codespace (recipe in scripts/lms-probe/README.md). Canvas is provisioned through its REST API as the site admin, exactly as a school's admin would configure it: an LTI 1.3 developer key with the AGS scopes, turned on and installed account-wide; a course with a 100-point external-tool assignment and a module item. Scormbusters registers Canvas as the org owner, with Canvas's issuer https://canvas.instructure.com. Three Canvas students launch.
| Check | Result |
|---|---|
OIDC login → Canvas authorize_redirect → authorize | pass — GETs |
| Canvas-signed launch verified, learner provisioned | pass |
| Assignment, passing learner — Canvas gradebook | pass — 100, graded |
| Assignment, failing learner — Canvas gradebook | pass — 0, graded, comment "Did not reach the passing score" |
| Module item — tool creates its own gradebook column | pass — "LTI Validation Course", 100 pts |
| Module item, passing learner — that column | pass — 100, graded |
Found on real Canvas, fixed the same day:
- Module-item placements never reached the gradebook. When Canvas names no column, the tool creates one. We bound it to the launch's resource link — standard, and accepted by Moodle and saLTIre — but Canvas binds only links that already belong to an assignment, and a module item's doesn't: it answered 404 and the grade was lost. The tool now falls back to an unbound column (Canvas's documented placeholder assignment) stamped with its own
resourceId, so every learner of that placement finds the same column rather than each creating one. - The launch wrote a learner session that could never be written: an upsert against a partial unique index, which the database API cannot target, failed on every launch on every platform and was logged as a warning. Nothing read it — the player creates the real session — so the write is gone.
Setting up this Canvas also confirmed our verifier refuses undersized keys: Canvas's sample dev config ships 512-bit LTI signing keys, and every launch was rejected ("RS256 requires key modulusLength to be 2048 bits or larger") until they were replaced with 2048-bit keys, as production Canvas uses.
What remains untested on Canvas: Instructure's *hosted* service, which runs the same code but may run ahead of the open-source tree, and Canvas's sso.canvaslms.com endpoints (a local build serves them on its own domain).
Shared registrations — two organizations, one LMS, 5 October 2026
Hosted Canvas sends one issuer for every school; Blackboard sends one issuer — and, when schools share a tool application, one client ID — for every site. Routing by issuer + client ID alone could not tell two Scormbusters customers apart. Run against saLTIre with two Scormbusters organizations registering the same issuer and client ID, separated only by deployment ID, and the same student launching into both:
| Check | Result |
|---|---|
| Each organization registers the shared LMS with its deployment ID | pass — 201, 201 |
| Each launch lands in its own organization's course | pass |
| The student gets a separate identity and learner account per organization | pass — 2 and 2 |
| No enrollment crosses organizations | pass — 0 |
| Each organization records its own completion | pass |
| Registering another organization's deployment ID | refused — 409 |
| Login initiation for an unregistered deployment | refused — 400 |
Before this, a second organization's registration made every launch for that LMS fail (two matching registrations), login initiation sent learners to whichever registration came first, the deployment ID was never saved at registration, login read a parameter name the spec doesn't use (deployment_id for lti_deployment_id), and a student shared between two customers mapped to one identity whose organization flipped on every launch. All fixed: routing is issuer + client ID + deployment ID, refused when ambiguous; identities are unique per organization (migration 20261005000100); clashing registrations are refused without naming the other workspace.
Production keys: Instructure's (sso.canvaslms.com, canvas.instructure.com, beta) and Blackboard's (developer.blackboard.com, including its RS512 key) all import cleanly in the launch verifier.
Moodle 5.2.3 — replacing an imported course's package, 7 October 2026
"Replace the original in your LMS" exports an imported course under the identifiers of the package it came from. Moodle keeps a learner's SCORM tracking per SCO and, when an activity's package is replaced, matches the new SCOs to the old ones by identifier. Run with npm run probe:moodle:replace: an "original" SCORM 1.2 package (ACME_FIRE_SAFETY_SCO) is imported through the import page and exported twice; in Moodle a student passes two activities built on the original at 80%, and each activity's package is then replaced through the activity's own settings form.
| Activity | SCO after replacement | Student's tracking | Gradebook |
|---|---|---|---|
| Replaced with the export that keeps the original identity | same row, ACME_FIRE_SAFETY_SCO | passed, 80 — still attached | 80 |
| Replaced with an ordinary export (control) | new row, our item id | gone | 0 |
Both activities ran the new package afterwards (revision 2). So in Moodle the option is the difference between a corrected course and one whose existing completions and grades are wiped. Not tested: other LMSs — Cornerstone and SCORM Cloud version courses their own way, and the identifiers alone do not decide what they keep.
Reproducing this yourself
The probe is a SCORM package that interrogates whatever LMS it is running in and reports what it found. It is not our player — it talks to window.API directly — so it measures the LMS, not us.
npm run build:probe
# dist/probe/opencourse-conformance-probe-scorm12.zip
# dist/probe/opencourse-conformance-probe-scorm2004.zipImport one into any LMS, launch it, and press "Copy the report". Relaunch to test resume. Paste the result into an issue and we will add it here, pass or fail.
To contribute a verification: export a course, import it into your LMS, confirm launch, completion, score reporting, and resume, and open an issue with the LMS name/version and what you observed — pass or fail. Failures are listed here too, with workarounds when we have them.
The public, always-current version of this page is /scorm-conformance on the marketing site (the domain is deployment-configured); this file is its working copy in the repository.