Skip to content

API Reference

All paths are relative to the base URL (sandbox: https://sandbox.lms.api.fsri.org/api/partner/v1). Every request needs Authorization: Bearer {token} and x-api-key: {{apiKey}} (Authentication) except where noted. Scope: read = partner-api/read, write = partner-api/write.

Machine-readable spec + try-it-out UI: interactive docs — public, no credentials needed. See Versioning & the OpenAPI Spec for the stable-URL commitment and what v1 guarantees.

Quick Reference

Method Path Scope X-User-ID
GET /ping (API key only) —
POST /users/register write —
GET /users/{uuid} read —
PATCH /users/{uuid} write —
DELETE /users/{uuid} write —
GET /consents/ulri-marketing read —
PUT /users/{uuid}/consents/ulri-marketing write —
GET /organizations · /organizations/{id} read —
GET /categories · /categories/{id} read —
GET /courses · /courses/{id} read —
GET /modules · /modules/{id} read —
GET /modules/{id}/content read ✔
GET /modules/{id}/refresh-url read ✔
POST /modules/{id}/complete write ✔
GET /course-progresses · /course-progresses/{id} read ✔
GET /transcripts · /transcripts/{id} read ✔
GET /transcripts/pdf read ✔
GET /transcripts/{id}/certificate/pdf read ✔

Health

GET /ping

Service health. Requires only the API key (no JWT — by design, so you can verify connectivity in isolation). Returns 200 with "status": "healthy" and dependency checks.


Users

POST /users/register

Register a learner and get back their UUID — persist it, it's the learner's identity in every subsequent call. Idempotent per partner+email: registering the same email again returns the existing record, so retries after timeouts are safe.

curl -s -X POST "$BASE_URL/users/register" \
  -H "Authorization: Bearer $ACCESS_TOKEN" -H "x-api-key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "jane.doe@yourcompany.example",
    "firstName": "Jane",
    "lastName": "Doe",
    "organizationId": 123
  }'

Returns 201 for a new learner, 200 for a duplicate registration. The response is a confirmation, not the full profile — four fields only:

{
  "uuid": "3f2a…",
  "createdAt": "2026-07-31T14:31:43+00:00",
  "status": "active",
  "organizationId": 123
}

organizationId is echoed back so you can confirm the affiliation stored without a follow-up call. An unknown or inactive id is rejected with 422 before the learner is created — as a violations[] entry with propertyPath: "organizationId" — so a bad value never leaves a learner behind with no affiliation.

[!NOTE] status is not always active. A new learner always is, but because registration is idempotent, re-registering an existing email returns that learner's current status, which may be inactive or deleted. Check it rather than assuming.

For the full profile, call GET /users/{uuid}.

GET /users/{uuid}

Fetch a learner's full profile.

Profile fields (response, and PATCH-able): email, firstName, lastName, organizationId, countryCode (ISO 3166-1 alpha-2), address, address2, city, stateProv, postalCode, phone, status (active | inactive | deleted) — plus read-only uuid, createdAt, updatedAt.

Read-only consent fields: marketingConsent (unset | granted | declined) and marketingConsentAt (ISO 8601 timestamp, or null while unset) — see Marketing Consent.

PATCH /users/{uuid}

Update profile fields. Requires Content-Type: application/merge-patch+json — send only the fields you're changing:

curl -s -X PATCH "$BASE_URL/users/$USER_UUID" \
  -H "Authorization: Bearer $ACCESS_TOKEN" -H "x-api-key: $API_KEY" \
  -H "Content-Type: application/merge-patch+json" \
  -d '{"firstName": "Janet"}'

Deactivate / reactivate — same PATCH with {"status": "inactive"} (or "active"). Deactivation is reversible; while inactive, user-scoped calls with that UUID are rejected. Transcripts, certificates, and progress are preserved.

DELETE /users/{uuid}

Permanent GDPR erasure. Returns 204. The learner's identifying data is removed; their transcripts, certificates, and progress records are retained but anonymized (de-identified, relinked to a placeholder) so already-issued credentials remain verifiable. Not reversible. A UUID you don't own returns 404.


The full flow, including when to ask and how to render the prompt, is on Marketing Consent.

GET /consents/ulri-marketing

The current prompt: consentType, version, text (render it verbatim) and presentation (how to render it).

PUT /users/{uuid}/consents/ulri-marketing

Record the learner's answer: {"granted": true|false, "promptVersion": "…"}, both required. Returns {consentType, state, recordedAt, promptVersion}, where state is the decision that now governs, which may differ from what you sent. An unissued promptVersion returns 400; a UUID you don't own returns 404.


Organizations

GET /organizations · GET /organizations/{id}

Read-only reference data. Use organizations to populate a dropdown at registration; pass the chosen id as organizationId when registering. Managed by FSRI — the API cannot create or modify them.


Catalog: Categories, Courses, Modules

GET /categories · /categories/{id} — GET /courses · /courses/{id} — GET /modules · /modules/{id}

The content catalog. All three collections support pagination and updatedAt delta-sync filters.

Only published content is visible through the API.

Module records include passingScore (integer 0–100, or null for non-assessed content) — surface it so learners know the threshold before starting.

Modules carry no artwork. GET /modules/{id}/content returns playback metadata only — module_id, content_type, content_url, duration_seconds, expires_at, mime_type. Note that content_url points at an embeddable player (mime_type: text/html, often content_type: interactive) rather than a media file, so there is generally no video frame to sample for a thumbnail.

Course records include artwork: thumbnailUrl (card, 3:2) and heroImageUrl (banner, 2:1) — absolute URLs on FSRI's public image CDN, ready to render with no signing. A course without artwork omits the fields entirely. Append ?width= and ?format=auto to request a smaller image; see Course Imagery for the parameters, examples and caching behavior. smallThumbnailUrl is absent from every course and should not be used. If you cached the catalog before 2026-08-05, run one full re-sync to pick them up — adding the fields did not bump updatedAt, so a delta cursor will never fetch them.

Course records also carry publishedAt, the moment the course was first released to the catalog. It is set once and never moves: editing a course moves updatedAt only, and a course that is withdrawn and later released again keeps its original publishedAt. Courses released before publish dates were recorded omit the field. To fetch only courses that are new to the catalog, filter on it: GET /courses?publishedAt[after]=…. See New releases.

Course records also include applicableStandards — the "Applicable NFPA Standards" list as authored in the LMS, for example NFPA 1001 (2019): 4.3.10, 4.3.11, 5.3.2.

applicableStandards is HTML, not plain text

It is authored in a rich text field, so the value arrives as markup:

"applicableStandards": "<div>NFPA 1001 (2019): 4.3.10, 4.3.11, 5.3.2&nbsp;<br></div>"

Render it inside a container you control, and sanitise it if your framework does not already. Courses with no standards omit the field entirely, so a truthiness check is enough to decide whether to render the panel.

The value is unstructured today: you can display the list but cannot filter or search by a specific standard. Making standards a selectable field is separate future work. As with artwork, adding this field did not bump updatedAt, so a delta cursor will not pick it up — run one full re-sync if you cached the catalog before it shipped.

Listing a course's modules

GET /courses/{id} returns the course's module IDs in moduleIds (and a moduleCount). moduleIds is in curriculum display order — render your course outline and any "next module" logic in that sequence. Two ways to get the module records themselves:

  • GET /modules?courseId={id} — the modules in one course, in a single call. This is the recommended way to render a course's module list.
  • GET /modules/{id} — one module at a time, if you already hold an ID from moduleIds.

Both courseId and the updatedAt[…] filters can be combined on GET /modules.

Field names & shapes are defined by the spec

This page describes behaviour, not exhaustive field lists. The authoritative schema for every resource — exact field names, types, and nullability — is the machine-generated interactive OpenAPI spec, which is produced from the running API and cannot drift from it. When in doubt, trust the spec over any hand-written example.


Content Delivery

GET /modules/{id}/content — user-scoped

Returns a signed, time-limited content URL for a learner:

{
  "@type": "ModuleContent",
  "module_id": 467,
  "content_type": "interactive",
  "content_url": "https://…/module_0467/index_lms.html?…",
  "expires_at": "2026-07-26T15:30:00Z",
  "duration_seconds": 600,
  "mime_type": "text/html"
}
  • content_type is one of video, document, interactive, resource.
  • Embed content_url in an <iframe> (interactive content and documents) or player (video). FSRI hosts and renders interactive content — your app never interprets the package.
  • The URL is signed and expires (expires_at, ~60 minutes). Fetch at click-time; never persist a content URL.
  • Side effect: the learner's progress record is auto-created (status in_progress) on first content access — you never create progress records yourself.

Status codes: 200 published + user is yours · 400 missing X-User-ID · 404 module not reachable through a published course, or the UUID isn't yours.

GET /modules/{id}/refresh-url — user-scoped

Fresh signed URL for content already playing — same response shape as /content. For long-form video, call this when expires_at − now < 60s so playback never breaks mid-session.

POST /modules/{id}/complete — user-scoped

Completion notification for video, document, and resource modules — your UI's "Mark as Complete" control calls this. No request body.

curl -s -X POST "$BASE_URL/modules/$MODULE_ID/complete" \
  -H "Authorization: Bearer $ACCESS_TOKEN" -H "x-api-key: $API_KEY" \
  -H "X-User-ID: $USER_UUID"
Status Meaning
202 Accepted Completion recorded (FSRI emits the learning-record statement server-side)
202 (repeat call) Idempotent no-op — already complete, safe to retry
409 Conflict The module is interactive and reports its own completion; do not call this for it
400 / 403 / 404 Missing/invalid X-User-ID, inactive user, or module/user not found

Course Progress

GET /course-progresses · GET /course-progresses/{id} — user-scoped

The learner's progress records. Read-only — records are created on first content access and updated by FSRI as the learner works (xAPI statements from the content, or your /complete calls).

Fields include status (not_started | in_progress | completed), completionPercent, completedModuleIds, lastActivityAt, startedAt, completedAt, createdAt, updatedAt. Course-level completion is derived from the learner's transcript — a course shows completed once FSRI's system of record says so.

completionPercent is the share of the course's required modules the learner has completed, floored to a whole number, so it is safe to render directly as a progress bar. Optional modules are excluded from both the numerator and the denominator. It reads 100 only when the course is genuinely complete (a transcript exists); a learner one module short reads 99 rather than rounding up to a completion that has not happened. Completing a module shared between courses advances every course that contains it.

completedModuleIds answers the other half of the question — which modules are done, so you can render a course outline with checkmarks instead of a single bar. Ids come back in curriculum order, matching the order of moduleIds on the course, and are always a subset of it. The array is empty, never null, when nothing has been completed.

[!IMPORTANT] completedModuleIds and completionPercent measure different things, and on a course with optional modules they will not agree.

The list includes every completed module; the percentage counts required ones only. A learner who has finished one optional module of a five module course gets one id and 0. Use the ids for "what is done" and the percentage for "how far along" — computing your own percentage from completedModuleIds.length / moduleCount will drift from ours.

{
  "courseId": 82,
  "status": "in_progress",
  "completionPercent": 40,
  "completedModuleIds": [208, 209]
}

Supports updatedAt delta-sync filters — poll on a schedule with those rather than refetching everything. Finishing a module moves the record's updatedAt, so a delta poll picks up completionPercent and completedModuleIds changes as the learner works.


Transcripts & Certificates

GET /transcripts · GET /transcripts/{id} — user-scoped

The learner's completion records: course/module reference and name, completionDate, score (0–100 or null), isCourse, certificateAvailable. Supports updatedAt filters.

When certificateAvailable is true, the certificate PDF endpoint will serve it.

GET /transcripts/pdf — user-scoped

The learner's full transcript (all completions) as application/pdf. Generated on demand, typically 1–3 seconds — call when the user asks for the document; don't pre-fetch in bulk.

GET /transcripts/{id}/certificate/pdf — user-scoped

Single course certificate as application/pdf. The transcript must belong to the X-User-ID learner.