Skip to content

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:

X-User-ID: a1b2c3d4-5678-9abc-def0-123456789abc
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:

GET /api/partner/v1/courses?page=2&itemsPerPage=25
  • page — 1-based page number
  • itemsPerPage — page size (maximum 100)
  • Walk hydra:view.hydra:next until 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:

GET /api/partner/v1/courses?updatedAt[after]=2026-07-01T00:00:00Z
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:

GET /api/partner/v1/courses?publishedAt[after]=2026-10-01T00:00:00Z
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 a publishedAt cursor; it does move updatedAt, so your updatedAt sync sees it come back.
  • Older courses have none. Courses released before publish dates were recorded omit publishedAt and never match a publishedAt filter. That means "released before tracking began", not "unreleased": every course GET /courses returns is live.
  • Combine freely. publishedAt[…], updatedAt[…] and categoryId can 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.