What an LRS is
What is a learning record store? Mechanically, it is a web service that implements the server side of the xAPI specification: a handful of HTTP resources under one base URL that accept JSON statements, keep them, and hand them back on request. The xAPI spec defines it as the system responsible for "receiving, storing, and providing access to" learning records. It is not an LMS: no course catalogue, no enrolment, no launch button, and no reports beyond a filtered list. What it has is a strict contract — validate every incoming statement, never alter one once stored, answer the same queries in the same shape as every other conformant store — and that contract is what lets content from one vendor, a store from another, and a dashboard from a third agree on what happened.
If statement, actor, and verb are new words, What is xAPI covers the protocol; this article is about the store.
What it stores
Four kinds of thing, each behind its own resource under the base URL — the "endpoint" a launch or config file refers to, such as https://lrs.example.org/xapi/.
| Resource | Path | Holds | SCORM counterpart |
|---|---|---|---|
| Statements | /statements | Immutable records of what happened: actor, verb, object, result, context, timestamp | The values written with SetValue — score, status, session time — but as events, not a single overwritten record |
| State | /activities/state | Any document keyed by activity + agent (+ optional registration). Bookmarks and in-progress answers live here | cmi.suspend_data and cmi.location, with no 4 KB / 64 KB cap in the spec |
| Activity Profile | /activities/profile | Documents about an activity itself, independent of learner | Nothing directly; closest is manifest metadata |
| Agent Profile | /agents/profile | Documents about a learner, independent of activity (cmi5 keeps cmi5LearnerPreferences here) | cmi.learner_preference.* |
| About | /about | Which xAPI versions the store accepts | Nothing |
The statement rules are where the store earns its keep. On every POST it checks that the actor has exactly one identifier, that verb and activity ids are IRIs, that result.score.scaled is between −1 and 1, that duration is ISO 8601, and that the X-Experience-API-Version header is present; anything malformed gets a 400 and nothing is stored. Once stored, a statement cannot change: a PUT to /statements?statementId=… with an existing id and different content is refused with 409 Conflict. To retract one you send a new statement whose verb is http://adlnet.gov/expapi/verbs/voided and whose object is a StatementRef to the original, which stays on disk and is excluded from normal queries.
The State resource deserves a concrete example, because it is what makes resume work. A course saving a bookmark for learner dana in …/fire-safety/module-1 does this:
PUT /xapi/activities/state
?activityId=https%3A%2F%2Fexample.org%2Fcourses%2Ffire-safety%2Fmodule-1
&agent=%7B%22account%22%3A%7B%22homePage%22%3A%22https%3A%2F%2Flms.example.org%22%2C%22name%22%3A%22dana%22%7D%7D
®istration=6c1e8f0a-2d4b-4b7e-9a3f-0f5b1c2d3e4f
&stateId=bookmark
Content-Type: application/json
X-Experience-API-Version: 1.0.3
{ "page": 14, "answers": { "q3": "b", "q7": "d" }, "videoPos": 212.4 }
HTTP/1.1 204 No Content
On the next launch the course issues the same request as a GET and receives the document back. Compare the SCORM failure mode where a bookmark string silently truncates at 4,096 characters: here the document is whatever JSON you wrote, and a rejection comes back as an error code.
Querying and reporting
Reading is a GET on /statements. The specification defines exactly these filters: statementId, voidedStatementId, agent, verb, activity, registration, related_activities, related_agents, since, until, limit, format, attachments, and ascending. "Everything Dana did in the fire-safety course since Monday" is one request:
GET /xapi/statements
?agent=%7B%22mbox%22%3A%22mailto%3Adana%40example.org%22%7D
&activity=https%3A%2F%2Fexample.org%2Fcourses%2Ffire-safety
&related_activities=true
&since=2026-08-24T00:00:00Z
&limit=100
X-Experience-API-Version: 1.0.3
HTTP/1.1 200 OK
{
"statements": [ … up to 100 statements, newest first … ],
"more": "/xapi/statements/more/7d2c1f…"
}
The more URL is the next page; an empty string means you have everything. related_activities=true matches statements where the course id appears anywhere in context.contextActivities, not only as the object — which is how you get statements about module 1 when you asked about the course.
Notice what is missing: no filter on score, no group-by, no count, no "learners who have not completed". The xAPI query interface is a filtered dump, deliberately, so that every store is interchangeable and analysis happens in whatever reads the dump. "An LRS with reporting" is therefore two products in one box — the conformant store, and a proprietary analytics layer that indexes statements into something you can chart — and you should evaluate them separately. The store's job is defined by ADL's open-source LRS conformance test suite, which anyone can run against any endpoint; the reporting layer's job is defined by nobody but you.
Forwarding between record stores
Because a statement is a self-contained JSON document with a UUID, one store can send it to another with an ordinary POST /statements, and the receiving store keeps the same id. That is all "statement forwarding" is. The specification does not define it — most LRS products implement it — but the specification is what makes it work: an immutable record with a globally unique id can be copied anywhere without ambiguity, and a store that receives a statement it already holds must not store a second copy.
Three shapes come up in practice:
- Vendor to customer. A training provider runs content against its own store and forwards statements about each customer's learners to that customer's store, filtered by actor
homePage. - Department to central. Business units run their own stores and forward everything to one enterprise store for cross-unit reporting.
- Old to new. A migration: page through
GET /statements?since=…on the old store andPOSTthe pages to the new one. Because ids and timestamps travel with the statements, history survives intact — something SCORM records, which live inside one LMS's database schema, generally do not.
Check two things first: that the source forwards voiding statements too, or the destination keeps records the source retracted; and that the destination's authority handling is what you expect: the specification says a store SHOULD overwrite authority with the credential that posted the statement and MAY keep the submitted one only under "a strong trust relationship", so by default the forwarded copy names the forwarding store as its authority, not the original content. If provenance matters, keep the original store.
When you need one
The answer follows from which standard your content uses, and it is more clear-cut than vendors make it.
- SCORM 1.2 or 2004 only: no. The content talks to the LMS's JavaScript API and the LMS stores the
cmi.*values in its own database; there is nothing for an LRS to receive. - cmi5: yes, and the specification puts the obligation on the LMS — "the LMS MUST implement an LRS as defined in xAPI". A platform that claims cmi5 support has one, embedded or connected. Your question is whether you can read it, because your per-interaction statements land there too.
- Plain xAPI: yes, unavoidably. The statements have to go somewhere, and the LRS is the only thing defined to receive them.
- SCORM for the LMS plus xAPI statements on the side: yes, but only for the extra data. Completion and score still come from the SCORM record. This is the common first step because it changes nothing about the import the customer already trusts — the trade-offs are in SCORM vs cmi5 vs xAPI.
Built-in vs external
Once you need one there are three ways to get it, and the choice is less about features than about who owns the data and the identifiers.
| Built into the LMS | Hosted LRS product | Self-hosted open source | |
|---|---|---|---|
| Set-up | None; the endpoint exists already | Sign up, create a credential, note the endpoint | Deploy, back up, patch, monitor |
| Who can write to it | Usually only that LMS's launches | Anything you give a key to | Anything you give a key to |
| Reading it back | Often only through the LMS's own reports; a raw GET /statements may not be exposed | Full xAPI query plus the vendor's reporting | Full xAPI query; reporting is yours to build or bolt on |
| Actor identifiers | The LMS's user ids | Whatever each source sends — you must align them | Same |
| Leaving | Export depends on the vendor | Forward or page out with since | It is your database |
Built-in is the right answer when cmi5 completion in one LMS is all you need. The moment a second system — a simulator, a mobile app, another LMS — must write statements about the same learners, an external store becomes the neutral ground where identifiers are reconciled and where no one platform's migration deletes the history. ADL publishes an open-source reference LRS alongside its conformance test suite, and other open-source stores such as Yet Analytics' SQL LRS and TRAX LRS exist if you would rather run one than rent one. Whichever you choose, run the conformance suite against it before you trust it.
Where SCORM Central fits
SCORM Central converts files you already have — PDF, PowerPoint, Word, or video — into SCORM 1.2, SCORM 2004, cmi5, or xAPI, and shows you the manifest, the full runtime log, and a conformance report on every build, so you can see exactly what a package will report before an LMS does. See how it works, or start with one file.