Documentation

Build on Sereni LRS

The endpoints, headers and rules as the service actually implements them — enough to send your first statement and to debug the one that was rejected.

From nothing to a stored statement

Four requests: sign in, create a Store, mint an access key, then send a statement and read it back.

1. Sign in to the admin API

The admin API issues the JWT used to manage Spaces, Stores and keys. It is separate from the xAPI credentials your applications will use.

POST /api/auth/login
curl -X POST https://app.serenilrs.com/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{ "email": "[email protected]", "password": "..." }'

# → { "token": "eyJhbGciOi..." }

2. Create a Store

POST /api/stores
curl -X POST https://app.serenilrs.com/api/stores \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Safety training", "space_id": "..." }'

List the Spaces available to you with GET /api/space, and the Stores inside one with GET /api/space/{id}/stores.

3. Mint an access key

An access key is an access key / secret key pair scoped to one Store. Creating the key and reading its credentials are two calls: the secret is returned by the credentials endpoint.

POST /api/stores/{store_id}/access-key
curl -X POST https://app.serenilrs.com/api/stores/$STORE_ID/access-key \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -H "Content-Type: application/json" \
  -d '{ "name": "safety-training-prod" }'

# then read the pair
curl https://app.serenilrs.com/api/stores/$STORE_ID/access-key-credentials/$KEY_ID \
  -H "Authorization: Bearer $ADMIN_JWT"

4. Send a statement

Authenticate with HTTP Basic using the pair, and send the xAPI version header. Both are required: a request without X-Experience-API-Version is rejected with 400 before it reaches validation.

POST /{store}/xAPI/statements
curl -X POST https://api.serenilrs.com/$STORE_SLUG/xAPI/statements \
  -u "$ACCESS_KEY:$SECRET_KEY" \
  -H "X-Experience-API-Version: 2.0.0" \
  -H "Content-Type: application/json" \
  -d '{
    "actor": { "mbox": "mailto:[email protected]" },
    "verb": {
      "id": "http://adlnet.gov/expapi/verbs/completed",
      "display": { "en-US": "completed" }
    },
    "object": { "id": "https://acme.com/courses/safety-101" }
  }'

# → 200  ["8f3a1c62-5d3e-4a77-9f2b-1c0d7e9a4b55"]

The response is the array of statement IDs the LRS assigned. An id, timestamp and stored value are generated for you when omitted.

If the request comes back 400, the xAPI Validator will tell you what is wrong with the statement itself before you go looking at the request.

5. Read it back

GET /{store}/xAPI/statements
curl "https://api.serenilrs.com/$STORE_SLUG/xAPI/statements?limit=1" \
  -u "$ACCESS_KEY:$SECRET_KEY" \
  -H "X-Experience-API-Version: 2.0.0"

Two schemes, one pair of credentials

Basic authentication for machine clients, Bearer JWT where a token is easier to pass. Both resolve to an access key scoped to a single Store.

SchemeHeaderUse it when
BasicAuthorization: Basic base64(accessKey:secretKey)A server, an LMS connector or a script talks to the LRS. This is the xAPI standard scheme and what most authoring tools expect.
BearerAuthorization: Bearer <JWT>A token is easier to carry than a pair — a gateway that mints short-lived tokens, or a client that already holds one. The JWT's claims carry the same access key and secret.

Anything else is refused. A missing or malformed header returns 401 with WWW-Authenticate: Basic realm="xAPI", so a standards-compliant client knows what to send next.

401 response
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="xAPI"

{ "error": "Missing Authorization header" }

Managing keys

Method and pathDoes
POST /api/stores/{store_id}/access-keyCreate a key for a Store
GET /api/stores/{store_id}/access-keyList the Store's keys
GET /api/stores/{store_id}/access-key/{id}Read one key's metadata
PUT /api/stores/{store_id}/access-key/{id}Rename or re-scope a key
DELETE /api/stores/{store_id}/access-key/{id}Revoke a key immediately
GET /api/stores/{store_id}/access-key-credentials/{id}Read the access key and secret pair
PUT /api/stores/{store_id}/access-key-credentials/{id}Regenerate the secret, invalidating the old one

Rotating without downtime

  1. Create a second key on the same Store.
  2. Deploy it to the applications that write to that Store.
  3. Watch traffic until nothing authenticates with the old key.
  4. Delete the old key.

Regenerating a secret in place is the faster path, but it invalidates the previous secret the moment it succeeds — anything still holding it starts failing with 401. Prefer a second key when the writers are not all under your control.

The statement resource

Five methods, the full xAPI query parameter set, and the two headers that make paging and concurrency work.

MethodBehaviourSuccess
POSTAccepts a single statement or an array. Validated synchronously, then queued for storage.200 with the array of assigned statement IDs
GETQuery statements. Parameters below.200 with a statement result object
PUTStore a statement under an id you choose, for idempotent writes.204 No Content
HEADHeaders only — existence and consistency checks without a body.200
DELETERemoves a statement.204 No Content

Query parameters

ParameterTypeFilters on
statementIdUUIDOne statement by id
voidedStatementIdUUIDA voided statement by id
agentJSON agent objectActor, or object when it is an agent
verbIRIVerb id
activityIRIActivity id of the object
registrationUUIDOne registration — a single attempt or enrolment
related_activitiesbooleanWidens activity to parent, grouping, category and other
related_agentsbooleanWidens agent to authority, instructor and team
sinceISO 8601Stored strictly after this timestamp
untilISO 8601Stored at or before this timestamp
limitintegerMaximum statements returned
formatids | exact | canonicalHow much of each object to return
attachmentsbooleanReturns multipart/mixed with attachment bodies included
ascendingbooleanOldest first instead of newest first

Paging through a large result set

Because writes are asynchronous, the boundary of a complete result is a timestamp rather than a row count. Every response carries X-Experience-API-Consistent-Through: results are complete up to that instant, and anything stored after it may still be in flight.

Walking forward with since
# first page
curl -i "https://api.serenilrs.com/$STORE/xAPI/statements?limit=500&ascending=true" \
  -u "$ACCESS_KEY:$SECRET_KEY" -H "X-Experience-API-Version: 2.0.0"

# → X-Experience-API-Consistent-Through: 2026-08-28T09:14:22.481Z

# next page: start where the last one ended
curl "https://api.serenilrs.com/$STORE/xAPI/statements?limit=500&ascending=true\
&since=2026-08-28T09:14:22.481Z" \
  -u "$ACCESS_KEY:$SECRET_KEY" -H "X-Experience-API-Version: 2.0.0"

Concurrency

Responses carry an ETag, and If-Match and If-None-Match are accepted on writes. Use them on the State and Profile resources, where two clients editing the same document is normal; a conditional request that loses the race fails rather than overwriting.

Documents, not statements

Four document resources for the data that is not an event: where a learner left off, and metadata about an activity or a person.

Statements are immutable records of things that happened. State and Profile documents are mutable key-value storage the LRS keeps for you — a bookmark, a partially completed form, a learner's preferences. Each supports GET, POST, PUT, HEAD and DELETE.

ResourceHoldsIdentified by
/activities/statePer-learner, per-activity state — resume position, attempt scratch dataactivityId + agent + stateId, optionally registration
/agents/statePer-learner state independent of any activityagent + stateId, optionally registration
/activities/profileMetadata about an activity itself, shared across learnersactivityId + profileId
/agents/profileMetadata about a person, shared across activitiesagent + profileId
Saving and reading resume state
# save where the learner stopped
curl -X PUT "https://api.serenilrs.com/$STORE/xAPI/activities/state\
?activityId=https://acme.com/courses/safety-101\
&agent=%7B%22mbox%22%3A%22mailto%3Alearner%40acme.com%22%7D\
&stateId=resume" \
  -u "$ACCESS_KEY:$SECRET_KEY" \
  -H "X-Experience-API-Version: 2.0.0" \
  -H "Content-Type: application/json" \
  -d '{ "slide": 14, "secondsWatched": 386 }'

# read it back on the learner's next visit
curl "https://api.serenilrs.com/$STORE/xAPI/activities/state\
?activityId=https://acme.com/courses/safety-101\
&agent=%7B%22mbox%22%3A%22mailto%3Alearner%40acme.com%22%7D\
&stateId=resume" \
  -u "$ACCESS_KEY:$SECRET_KEY" -H "X-Experience-API-Version: 2.0.0"

Parameter names here are checked for case exactly as they are on statements: activityId, agent, stateId, registration and since on the state resources; activityId, profileId and since on the profile resources.

Editing safely

Send If-Match with the ETag you read. If another client wrote first, your request fails instead of discarding their change — which matters most for exactly the documents two browser tabs will both try to save.

What gets rejected, and the error you get

Statements are validated strictly and synchronously, before anything is queued. A malformed statement fails at the door rather than becoming a row you find later.

Loose validation is how an LRS accumulates data that cannot be queried: three spellings of the same field, timestamps that do not sort, context keys nothing understands. These are the checks a statement has to pass, with the message returned when it does not.

RuleMessage
Field names are case-sensitive. A near-miss is named rather than ignored.field name must be exactly 'timestamp', not 'Timestamp' (xAPI requires case-sensitive field names)
Unknown top-level fields are refused outright.invalid field 'attempt' - not allowed in statements
timestamp must be a string.timestamp must be a string
timestamp must parse as ISO 8601.timestamp must be in ISO 8601 format: <parse error>
The -00:00 offset is refused — xAPI reserves it as unknown-offset, which makes ordering ambiguous.invalid timestamp: -00:00 offset not allowed
Fractional seconds, when present, need at least millisecond precision.fractional seconds must have at least milliseconds
contextActivities keys are limited to the four the specification defines.contextActivities contains invalid key 'module' - must be one of: parent, grouping, category, other
Statements nested as an object are validated by the same rules, recursively.substatement <the nested error>

What is filled in for you

  1. id — a UUID is generated when omitted, and returned in the response.
  2. timestamp — set to the time of receipt when absent.
  3. stored — always set by the LRS, never taken from the client.
  4. authority — derived from the access key that authenticated the request.

Moving history in

A dedicated endpoint for migrations: existing statements keep their original ids and timestamps instead of being re-dated on arrival.

Replaying years of history through POST /statements works, but it is the slow path — every statement goes through validation and the live write queue. The import endpoint writes in batches and reports exactly what it did.

One statement in the request body
{ "statements": [ {
  "statement_id":  "8f3a1c62-5d3e-4a77-9f2b-1c0d7e9a4b55",
  "raw_statement": { "id": "8f3a1c62-...", "actor": {}, "verb": {}, "object": {} },
  "actor_ifi":     "mbox:mailto:[email protected]",
  "verb":          { "id": "http://adlnet.gov/expapi/verbs/completed" },
  "verb_id":       "http://adlnet.gov/expapi/verbs/completed",
  "object":        { "id": "https://acme.com/courses/safety-101" },
  "object_id":     "https://acme.com/courses/safety-101",
  "object_type":   "Activity",
  "result":        null,
  "context":       null,
  "registration":  null,
  "timestamp":     "2024-03-11T08:22:31.004Z",
  "stored":        "2024-03-11T08:22:31.221Z",
  "voided":        false,
  "version":       "1.0.3",
  "authority":     null
} ] }

actor_ifi is the field to get right: it is not a hash, but a string built from whichever identifier the actor carries — mbox:<mbox>, mbox_sha1sum:<sha1>, openid:<url>, account:<name>:<homePage>, or unknown when the actor has none. It is what statements are matched to a learner by, so a wrong value writes a row that a query by actor will not return.

Response
{
  "inserted": 4821,
  "skipped": 0,
  "clickhouseinserted": 4821,
  "clickhouseskipped": 0
}

Counts are reported separately for the transactional store and the analytics store, which is how you confirm a migration landed in both. A gap between the two means the analytics write path needs attention before you cut over.

Re-running a batch is safe. Statements are keyed on their statement id and an existing one is updated in place rather than duplicated, so an import interrupted at batch 812 can start again from the beginning. Original timestamp and stored values are kept, so imported history keeps its ordering instead of being re-dated to the day you migrated.

Which specification, and which version you get

IEEE 9274.1.1-2023 — xAPI 2.0 — with 1.0.x clients served as 1.0.3 on the same endpoints.

Every request must declare its version. The header is checked before routing, so an incorrect one fails immediately and identically on every endpoint.

You sendYou get backStatus
X-Experience-API-Version: 2.0.02.0.0Request proceeds
X-Experience-API-Version: 1.0.3 (or any 1.0.x)1.0.3Request proceeds
No version header2.0.0400 — Missing X-Experience-API-Version header
A malformed value2.0.0400 — Invalid X-Experience-API-Version header format: <value>
A version outside 1.0.x and 2.0.x2.0.0400 — Unsupported X-Experience-API-Version header: <value>

That means a 1.0.3 authoring tool and a 2.0.0 service can write to the same Store without either being reconfigured: each is answered in the version it asked for.

Discovering it at runtime

GET /{store}/xAPI/about
curl https://api.serenilrs.com/$STORE/xAPI/about

# → { "version": ["1.0.3", "2.0.0"], "extensions": { ... } }

The about resource is the one endpoint that answers without a version header, so a client can negotiate before it commits to one.

Status codes and response shapes

What each failure means and which layer produced it.

StatusMeansBody
400 Bad RequestVersion header missing, malformed or unsupported; a query parameter in the wrong case; a statement that failed validation.{ "error": "<the specific reason>" }
401 UnauthorizedNo Authorization header, an unrecognised scheme, or credentials that do not resolve to a key on this Store.WWW-Authenticate: Basic realm="xAPI"
404 Not FoundThe Store slug does not exist, or the document or statement requested is not there.{ "error": "..." }
412 Precondition FailedAn If-Match or If-None-Match condition did not hold — another client wrote first.empty
429 Too Many RequestsThe gateway's rate limit was exceeded. Back off and retry.{ "error": "Rate limit exceeded" }
500 Internal Server ErrorAn unexpected failure. Statements already accepted are not lost — the write queue retains them.{ "error": "..." }

Operational endpoints

PathReturns
GET /healthGateway status and queue connectivity
GET /metricsRequest counts, latencies and throughput for monitoring