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.
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.
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.
Scheme
Header
Use it when
Basic
Authorization: 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.
Bearer
Authorization: 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.
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
Create a second key on the same Store.
Deploy it to the applications that write to that Store.
Watch traffic until nothing authenticates with the old key.
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.
Method
Behaviour
Success
POST
Accepts a single statement or an array. Validated synchronously, then queued for storage.
200 with the array of assigned statement IDs
GET
Query statements. Parameters below.
200 with a statement result object
PUT
Store a statement under an id you choose, for idempotent writes.
204 No Content
HEAD
Headers only — existence and consistency checks without a body.
200
DELETE
Removes a statement.
204 No Content
Query parameters
Parameter
Type
Filters on
statementId
UUID
One statement by id
voidedStatementId
UUID
A voided statement by id
agent
JSON agent object
Actor, or object when it is an agent
verb
IRI
Verb id
activity
IRI
Activity id of the object
registration
UUID
One registration — a single attempt or enrolment
related_activities
boolean
Widens activity to parent, grouping, category and other
related_agents
boolean
Widens agent to authority, instructor and team
since
ISO 8601
Stored strictly after this timestamp
until
ISO 8601
Stored at or before this timestamp
limit
integer
Maximum statements returned
format
ids | exact | canonical
How much of each object to return
attachments
boolean
Returns multipart/mixed with attachment bodies included
ascending
boolean
Oldest 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.
Resource
Holds
Identified by
/activities/state
Per-learner, per-activity state — resume position, attempt scratch data
Metadata about an activity itself, shared across learners
activityId + profileId
/agents/profile
Metadata about a person, shared across activities
agent + 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.
Rule
Message
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
id — a UUID is generated when omitted, and returned in the response.
timestamp — set to the time of receipt when absent.
stored — always set by the LRS, never taken from the client.
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.
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.
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.
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.