Changes feed

An ordered log of every change to the catalogue. Use it to keep your own copy of the jobs current without re-downloading everything.

GET/v1/changes

Parameters

ParameterTypeDescription
sincestringReturn changes after this position. Start with 0, then pass the next_since you stored.
limitintegerChanges per batch, 1–1000. Default 500.
descriptionstringnone (default), text, html or both. Not included for expired jobs.
source_fieldsbooleanIncluded by default; pass false for smaller batches.

Response

json
{
  "changes": [
    { "seq": "1201", "kind": "created", "at": "2026-10-11T14:02:11.000Z", "job": { /* job object */ } },
    { "seq": "1202", "kind": "expired", "at": "2026-10-11T14:05:40.000Z", "job": { "id": 88, "status": "expired", … } }
  ],
  "next_since": "1202",
  "more": true
}
ParameterTypeDescription
kindcreatedA new job was collected.
kindupdatedThe job's content changed. Replace your copy.
kindexpiredThe job closed. It was missing from two complete scans of its source at least two hours apart.
kindexcludedThe job is no longer eligible for this feed (for example, a location turned out to be outside Canada). It did not necessarily close.

Sync loop

JavaScript
let since = await loadCheckpoint() ?? "0";
for (;;) {
  const response = await fetch(`https://api.thejobsapi.com/v1/changes?since=${since}&limit=1000`, {
    headers: { Authorization: `Bearer ${process.env.THEJOBSAPI_KEY}` },
  });
  if (!response.ok) throw new Error((await response.json()).error);
  const batch = await response.json();
  for (const change of batch.changes) {
    if (change.kind === "expired" || change.kind === "excluded") await removeJob(change.job.id);
    else await upsertJob(change.job);
  }
  since = batch.next_since;
  await saveCheckpoint(since);          // save after each batch
  if (!batch.more) break;               // caught up; poll again later
}

Poll every few minutes. Every change event in a batch counts as one job toward your allowance.