API Conventions¶
Everything on this page applies across the whole API — learn it once, use it everywhere.
Response Format (JSON-LD / Hydra)¶
Responses use JSON-LD with the Hydra vocabulary. Single resources carry @context, @id, and @type alongside their data fields; collections wrap items in an envelope:
Send Accept: application/ld+json — or no Accept header at all
The API content-negotiates, and Accept: application/json gets you a bare JSON array instead of the envelope below — no hydra:totalItems, no hydra:view, so no total count and no pagination links.
Accept |
Collection response |
|---|---|
| (omitted) | Hydra envelope ✅ |
application/ld+json |
Hydra envelope ✅ |
application/json |
bare array — metadata lost ⚠️ |
Set it once on your HTTP client and forget it. Many libraries default to application/json, so this is worth checking before you conclude that pagination is broken.
{
"@context": "/api/partner/v1/contexts/Course",
"@id": "/api/partner/v1/courses",
"@type": "hydra:Collection",
"hydra:member": [ { "…": "resource objects" } ],
"hydra:totalItems": 109,
"hydra:view": {
"@id": "/api/partner/v1/courses?page=1",
"hydra:first": "/api/partner/v1/courses?page=1",
"hydra:last": "/api/partner/v1/courses?page=22",
"hydra:next": "/api/partner/v1/courses?page=2"
}
}
Treat the @-prefixed and hydra: keys as metadata: read your data from hydra:member (collections) or the top-level fields (single resources).
Field Naming¶
Resource fields are camelCase: firstName, organizationId, passingScore, createdAt, updatedAt.
One exception
The two content-delivery endpoints (/modules/{id}/content and /modules/{id}/refresh-url) return snake_case fields (content_url, expires_at, module_id). Match each endpoint's documented example rather than assuming one convention globally.
Request Content Types¶
| Operation | Content-Type |
|---|---|
POST with a body |
application/json |
PATCH |
application/merge-patch+json — required; a PATCH with application/json is rejected |
GET / DELETE |
(no body) |
Merge-patch semantics: send only the fields you want to change; set a field to null to clear it.
The X-User-ID Header¶
Catalog endpoints are partner-scoped: the JWT alone identifies you. User-scoped endpoints additionally need to know which learner the call is about — pass their UUID:
Requires X-User-ID |
Doesn't |
|---|---|
GET /modules/{id}/content |
GET /ping |
GET /modules/{id}/refresh-url |
POST /users/register |
POST /modules/{id}/complete |
GET /users/{uuid} (UUID is in the path) |
GET /course-progresses (+/{id}) |
GET /organizations, /categories, /courses, /modules (+ details) |
GET /transcripts (+/{id}) |
|
GET /transcripts/pdf, GET /transcripts/{id}/certificate/pdf |
The UUID must belong to a learner you registered. A UUID you don't own behaves exactly like one that doesn't exist (404) — partner isolation on learner data is absolute.
That isolation covers learner data: users, progress and transcripts. It does not cover the catalog. /organizations, /categories, /courses and /modules serve one shared copy of the content to every partner, which is why they take no X-User-ID at all.
Pagination¶
Collections are paginated with query parameters:
page— 1-based page numberitemsPerPage— page size (maximum 100)- Walk
hydra:view.hydra:nextuntil it's absent to iterate a full collection.
Query parameters are validated — bad input is rejected, not ignored
page and itemsPerPage must be positive integers; zero, negative, or non-integer values return 400. itemsPerPage above the maximum (100) is clamped to 100 rather than rejected.
Unknown query parameters return 400. A misspelled filter name (coarseId for courseId) is an error, not silently dropped — so a typo can never quietly hand you an unfiltered collection. Check names against the documented parameters.
No hydra:view in your response?
Check your Accept header — application/json strips the envelope and with it the pagination links. See Response Format.
Delta Sync (updatedAt Filters)¶
The API has no webhooks or push callbacks. Polling these collections with updatedAt filters is the supported way to detect change.
Six collections — /categories, /courses, /modules, /course-progresses, /transcripts, /users — support filtering on updatedAt, so you fetch only what changed since your last sync instead of re-pulling everything:
| Filter | Matches records where updatedAt is… |
|---|---|
updatedAt[after] |
≥ the given ISO 8601 timestamp |
updatedAt[before] |
≤ the timestamp |
updatedAt[strictly_after] |
> the timestamp |
updatedAt[strictly_before] |
< the timestamp |
Recommended pattern: store the timestamp you started each sync at, and use it as updatedAt[after] next time.
Note
Domain-event fields (lastActivityAt on progress, completionDate on transcripts) are not the sync cursor — always cursor on updatedAt.
Consent changes move updatedAt on /users
A learner can change their marketing preference on the FSRI side as well as
through you, and a linked learner is more than one record. Recording a
decision moves updatedAt on every record belonging to that learner, so
GET /users?updatedAt[strictly_after]=... surfaces an opt-out you did not
collect yourself. /users is sorted updatedAt ascending for this reason,
so you can advance your cursor as you page.
updatedAt does not cascade
updatedAt is per record. Editing a module — renaming it or replacing its content — moves that module's updatedAt, not the updatedAt of the courses that contain it. Keep an independent cursor for each collection you sync (/courses, /modules, /categories); a course-only cursor will miss content changes with no error to tell you.
Newly added fields need one full re-sync
Adding a field to a resource does not bump updatedAt on existing records, so a delta cursor will never fetch it. When we add one, the Changelog says so and tells you whether a full re-sync is needed.
New releases: publishedAt filters¶
updatedAt moves on every edit, so it cannot tell you whether a course is new or was just corrected. GET /courses also accepts the same four operators on publishedAt, the moment a course was first released:
| Filter | Matches courses first released… |
|---|---|
publishedAt[after] |
at or after the given ISO 8601 timestamp |
publishedAt[before] |
at or before the timestamp |
publishedAt[strictly_after] |
after the timestamp |
publishedAt[strictly_before] |
before the timestamp |
Things worth knowing:
- Set once. Editing a course never moves
publishedAt. A course withdrawn from the catalog and released again keeps its original date, so it will not reappear on apublishedAtcursor; it does moveupdatedAt, so yourupdatedAtsync sees it come back. - Older courses have none. Courses released before publish dates were recorded omit
publishedAtand never match apublishedAtfilter. That means "released before tracking began", not "unreleased": every courseGET /coursesreturns is live. - Combine freely.
publishedAt[…],updatedAt[…]andcategoryIdcan be used together.
Recommended pattern: keep your updatedAt cursor for keeping records current, and use publishedAt[after] when you want to surface new courses to your learners.
Versioning & the OpenAPI Spec¶
The machine-readable spec and a browsable Swagger UI are published at stable URLs and are open — no credentials, no API key, no login:
| URL | |
|---|---|
| Swagger UI | https://sandbox.lms.api.fsri.org/api/partner/v1/docs |
| OpenAPI 3 spec | https://sandbox.lms.api.fsri.org/api/partner/v1/docs.json |
Deliberately public, so you can evaluate the API before signing anything and read the contract without hunting for a credential. The spec describes the partner surface only — no internal LMS endpoints — and contains no secrets. You still need credentials to call anything.
These URLs are a commitment¶
The spec URL is effectively part of the contract: it gets baked into your code generators, CI checks, and Postman imports. FSRI treats it as stable and will not move or reshape it under you.
What v1 guarantees¶
The /v1/ path is pinned to the version 1 surface. Within it, changes are additive only:
- New endpoints, new optional fields, new enum values — these can appear at any time
- Existing field names, types, and semantics won't change
- Endpoints won't be removed, and required request fields won't be added
Build your client tolerantly — ignore unknown fields rather than rejecting them — and additive changes will never break you.
Anything that would break a v1 client goes in /v2/, served alongside /v1/ rather than replacing it. You migrate deliberately; v1 is not switched off underneath you. Should v1 ever be retired, FSRI will give notice through your integration contact well ahead of any change.
Import the spec, don't transcribe it
docs.json imports directly into Postman, Insomnia, and OpenAPI client generators. Generating from the spec keeps you in step with additive changes for free — and beats hand-copying request shapes out of this documentation.