Skip to content

Staying in Sync

The Partner API has no webhooks. You keep your copy of the FSRI catalog current by polling, and this page is the whole recipe: one full load, a daily delta poll, a weekly check for removals, and a way to tell new courses from edited ones.

Every request and response on this page was run against the sandbox. Responses are trimmed for length (marked …), and request IDs are replaced; nothing else is changed.

At a glance

Job Request How often
First load GET /courses, /modules, /categories, every page Once
Keep records current the same, with updatedAt[after]={last run} Daily
Catch removed courses GET /courses?itemsPerPage=100, compare ids Weekly
Spot new releases GET /courses?publishedAt[after]={last run} Daily, alongside the delta poll

Daily is plenty: the catalog changes a few times a month, not a few times a day. Run your sync at a quiet minute rather than on the hour, so you are not queued behind everyone else's scheduler (see Rate Limits).

1. First load

Pull every page of each catalog collection and store each record by its id. The default page size is 25; ask for the maximum of 100 to make fewer requests.

GET /api/partner/v1/courses?itemsPerPage=100

Each page tells you where the next one is. Follow hydra:next until it is absent:

{
  "hydra:totalItems": 38,
  "hydra:member": [ … ],
  "hydra:view": {
    "@id": "/api/partner/v1/courses?page=1",
    "@type": "hydra:PartialCollectionView",
    "hydra:first": "/api/partner/v1/courses?page=1",
    "hydra:last": "/api/partner/v1/courses?page=2",
    "hydra:next": "/api/partner/v1/courses?page=2"
  }
}

That response is the default page size: 38 courses over two pages of 25. No hydra:view at all means your Accept header is application/json, which strips the pagination links; send application/ld+json (see Response Format).

Note the time before you start. That moment is your first cursor for the next step.

2. Daily: fetch only what changed

Ask for records whose updatedAt is at or after your last run:

GET /api/partner/v1/courses?updatedAt[after]=2026-05-15T00:00:00Z&itemsPerPage=25
{
  "@id": "/api/partner/v1/courses",
  "hydra:totalItems": 3,
  "hydra:member": [
    {
      "@id": "/api/partner/v1/courses/82",
      "id": 82,
      "name": "Air Entrainment by Fire Service Hose Streams",
      "updatedAt": "2026-06-01T19:25:39+00:00",
      "moduleIds": [208, 209, 210, 211, 212],
      "categoryIds": [6],
      …
    },
    {
      "@id": "/api/partner/v1/courses/121",
      "id": 121,
      "name": "Prerequisites for Live Fire Training Participants",
      "updatedAt": "2026-06-01T19:07:41+00:00",
      …
    },
    {
      "@id": "/api/partner/v1/courses/162",
      "id": 162,
      "name": "Understanding Flow Paths: Low-Intake, High-Exhaust",
      "updatedAt": "2026-06-01T19:26:09+00:00",
      …
    }
  ],
  "hydra:view": {
    "@id": "/api/partner/v1/courses?itemsPerPage=25&updatedAt%5Bafter%5D=2026-05-15T00%3A00%3A00Z",
    "@type": "hydra:PartialCollectionView"
  }
}

Upsert each record by id: update it if you have it, insert it if you do not. A delta returns whole records, not just the fields that changed.

A delta can be more than one page, and it pages the same way as a full load. Here a wider window returns 21 courses at two per page, so it ends at page 11:

"hydra:view": {
  "@id": "/api/partner/v1/courses?itemsPerPage=2&updatedAt%5Bafter%5D=2026-04-01T00%3A00%3A00Z&page=1",
  "hydra:first": "…&page=1",
  "hydra:last": "…&page=11",
  "hydra:next": "…&page=2"
}

Rules that keep a delta poll correct:

  • Use the start of your previous run as the cursor, less a few minutes. [after] includes the boundary, and the small overlap covers any clock difference between your servers and ours. A record you have already seen may arrive again; the upsert makes that harmless.
  • Advance the cursor only after the run succeeds. If a run fails partway, the next one starts from the same point and picks up everything.
  • Keep one cursor per collection. updatedAt does not cascade: editing a module moves that module's updatedAt, not the course's. Poll /courses, /modules and /categories each with its own cursor, or you will miss content changes with no error to tell you.
  • An empty result is normal. Most days nothing changes, and you get "hydra:totalItems": 0.
  • A new field needs one full reload. Adding a field does not move updatedAt on existing records, so a delta never fetches it. The Changelog says when that applies.

Use updatedAt[strictly_after] instead of [after] if you prefer not to receive boundary records twice; the Conventions page lists all four operators.

3. Weekly: catch removed courses

A delta poll can only tell you what changed. It can never tell you what left. When FSRI withdraws a course it disappears from GET /courses, and GET /courses/{id} returns 404. Its modules disappear from /modules with it.

Once a week, fetch the current course ids and compare them with yours:

GET /api/partner/v1/courses?itemsPerPage=100

The whole catalog fits in one page today (38 courses on sandbox). Any id you hold that is not in the response has been withdrawn: hide it from your learners, but keep your own record of it so past enrolments and completions still make sense. A withdrawn course can come back; when it does, it reappears in your next delta poll.

4. New releases: publishedAt

updatedAt moves on every edit, so it cannot tell a brand new course from a corrected one. Courses also carry publishedAt, the moment the course was first released, and GET /courses filters on it the same way:

updatedAt publishedAt
Means the record changed the course was first released
Moves when anything about the course is edited never; it is set once
Use it to keep your copy current announce new courses, set a "new" badge
Filter on /courses, /modules, /categories and more /courses only
GET /api/partner/v1/courses?publishedAt[after]=2026-10-01T00:00:00Z
{
  "@context": "/api/contexts/PartnerCourse",
  "@id": "/api/partner/v1/courses",
  "@type": "hydra:Collection",
  "hydra:totalItems": 0,
  "hydra:member": [],
  "hydra:view": {
    "@id": "/api/partner/v1/courses?publishedAt%5Bafter%5D=2026-10-01T00%3A00%3A00Z",
    "@type": "hydra:PartialCollectionView"
  }
}

Empty is the right answer here, and the one you will usually get: no course has been released on sandbox since publish dates began to be recorded. When one is, it appears in this list with its publishedAt.

Things worth knowing:

  • Older courses have no publishedAt. Courses released before publish dates were recorded omit the field and never match a publishedAt filter. That means "released before we started recording", not "unreleased": every course GET /courses returns is live. Expect an empty result until FSRI releases its next course.
  • It never moves. A course withdrawn and released again keeps its original date, so it does not reappear on a publishedAt filter. It does move updatedAt, so your delta poll still sees it return.
  • Run it alongside the delta poll, not instead of it. The delta poll keeps your records right; publishedAt[after] tells you which of them to announce. Use the same cursor for both.

Mistakes return 400, not silence

Every query parameter is checked. A typo is an error rather than a filter that quietly does nothing, so a broken sync fails loudly instead of handing you the whole catalog, or none of it.

An unknown parameter names the parameters that endpoint accepts:

GET /api/partner/v1/courses?updatedSince=2026-09-01T00:00:00Z
{
  "@type": "hydra:Error",
  "hydra:title": "Invalid Request",
  "hydra:description": "Unknown query parameter \"updatedSince\". This endpoint accepts: categoryId, itemsPerPage, page, publishedAt, updatedAt.",
  "status": 400,
  "detail": "Bad Request",
  "trace_id": "…",
  "requestId": "…",
  "timestamp": "2026-10-15T18:54:45Z"
}

A date the API cannot read is rejected too:

GET /api/partner/v1/courses?updatedAt[after]=not-a-date
{
  "@type": "hydra:Error",
  "hydra:title": "Invalid Request",
  "hydra:description": "updatedAt[after] is not a valid ISO 8601 timestamp.",
  "status": 400,
  "detail": "Bad Request",
  "trace_id": "…",
  "requestId": "…",
  "timestamp": "2026-10-15T18:54:57Z"
}

Always send a full ISO 8601 timestamp

Send dates like 2026-10-01T00:00:00Z. A timestamp without an offset is read as UTC. Some relative words, such as yesterday, are currently accepted rather than rejected, and are read against FSRI's clock, not yours. Do not rely on that: only ISO 8601 is supported, and a cursor you store as an exact timestamp is one you can reason about when something goes wrong.

So is a page number below 1:

{
  "status": 400,
  "hydra:description": "The \"page\" query parameter must be a positive integer, got \"0\"."
}

One exception: itemsPerPage above 100 is not an error. It is treated as 100.

Encode the brackets

In a URL, updatedAt[after] is updatedAt%5Bafter%5D. Most HTTP libraries encode it for you; on the command line, curl -g or the encoded form avoids surprises.

Coming next

FSRI plans to add version and change information to courses, so you can see what changed in a course and not only that it changed. It is not available yet. When it arrives, the Changelog will say so and whether a full reload is needed.

Checklist

  • First load pulls every page of /courses, /modules and /categories, itemsPerPage=100
  • Records are stored and upserted by id
  • One updatedAt cursor per collection, advanced only after a successful run
  • Cursor is the previous run's start time less a few minutes
  • Daily run, off the top of the hour
  • Weekly id comparison hides withdrawn courses
  • publishedAt[after] drives "new course" announcements
  • A 400 from the sync is alerted on, not swallowed