Job Opportunities API

Check the data. Then trust it.

api

Pagination

Keyset cursors, not offsets. One rule matters more than the rest: hand next_cursor back exactly as you received it.

Last verified 2026-08-22 · Assumes: Authentication. · Markdown copy

1How paging works

Every listing response carries next_cursor and has_more. Send the cursor back as ?cursor= to get the next page, and stop when has_more is false.

In depthWhy it exists, what it is not, what people get wrong

Listing endpoints are keyset-paged: the cursor encodes the position of the last row you received in the sort order, and the next request continues from there. /v1/jobs sorts by posted_at DESC NULLS LAST, id DESC; the id component is what makes the ordering total, so rows sharing a posted_at cannot be reordered between two requests.

Offsets are not offered on the listing endpoints, and the reason is not philosophical. At this scale rows shift between requests (a role closes, a refresh lands) and an offset then double-counts or skips. Keyset paging is immune to that: it asks for “rows after this one”, which stays meaningful however much moved.

Exact contractTypes, defaults, ranges, errors, edge cases

Flood control and the cursor (24 Oct 2026). With collapse=company or max_per_company=N the page is assembled by walking the result in the same published order and hiding rows (never reordering any). The cursor resumes after the LAST ROW THE REQUEST DECIDED ON, returned or hidden, so a hidden row never comes back on a later page and a row the request never reached is never skipped. That is why a collapsed page can carry fewer than limit rows while has_more is still true: the walk hid rows, it did not run out. include_total keeps counting matching rows, not employers.

limitintegerdefault 251 to 200

1-200 (default 25). Anything else is a 422 bad_limit, not clamped.

cursorstring

The next_cursor from the previous page.

2 parameters, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug. Please say so with the thumbs-down below.

Response fieldTypeMeaning
dataarrayThe rows. Empty array, never null.
next_cursorstring \| nullOpaque. Pass back verbatim. Null when there is no next page.
has_morebooleanWhether another page exists. Loop on this, not on data.length.
completenessobjectOnly when you passed require_fields. See require_fields.
location_ambiguousbooleanOnly on a ?city= request: true when the matched set sits in more than one known country, false when it does not (an empty match is false).
location_countriesarrayOnly on a ?city= request: the country breakdown of the whole matched set, never this page, largest first with the unknown country last. [] when nothing matched.

Loop on has_more, not on whether data came back full. A page can be shorter than limit and still have more behind it (filters are applied after the keyset window in some shapes), so “fewer rows than I asked for means the end” is wrong here.

location_ambiguous and location_countries (26 Oct 2026) appear only when the request carries ?city= and describe the whole matched set, exactly as include_total counts it, never just this page. They are additive: a request without ?city= gets byte-identical payloads, and a ?city= search that matches nothing answers location_countries: [] with location_ambiguous: false rather than going silent. If the breakdown cannot be computed in its own time budget the two fields are omitted and the page is served anyway, because a bookkeeping extra must never cost a caller their rows.

Two pages by hand
A=https://api.jobopportunitiesapi.org
H="Authorization: Bearer $JOA_KEY"

page1=$(curl -s -H "$H" "$A/v1/jobs?country=IE&limit=25")
echo "$page1" | jq '.data | length, .has_more'

# Quote it. The cursor contains characters a shell will otherwise eat.
cursor=$(echo "$page1" | jq -r '.next_cursor')
curl -s -H "$H" --get "$A/v1/jobs" \
  --data-urlencode "country=IE" \
  --data-urlencode "limit=25" \
  --data-urlencode "cursor=$cursor" | jq '.data[0].title'

2How many rows match: include_total

Pages carry no total by default. Add include_total=true to /v1/jobs and the page also carries total_estimate and total_estimate_status.

In depthWhy it exists, what it is not, what people get wrong

Counting a broad slice can cost more than serving a page of it (one wide text query took 10.5 s to count and 0.3 s to page), so the count is opt-in. It runs as one extra query with its own budget of 4 seconds and is not charged as records. It cannot fail the request: when the count does not finish in time, the page is served as usual and only the estimate is missing.

total_estimate_statustotal_estimateMeaning
exactthe countExactly this many rows match your filters (up to 10,000).
at_least10001More than 10,000 rows match. Narrow the filters if you need the exact figure.
timed_outomittedCounting took longer than 4 seconds. The page itself is complete and correct.

The estimate ignores the cursor, so every page of the same query reports the same number (apart from rows that open or close in between). A query that matches nothing returns an ordinary empty page: data is empty, has_more is false and, with include_total, total_estimate is 0 with status exact. /v1/export refuses include_total with a 400, because a stream has no pages to count.

3next_cursor is opaque: this is the rule that bites

It looks like a timestamp and a uuid joined by a pipe. It is not a structure you may read. Pass it back exactly as received, and nothing else.

In depthWhy it exists, what it is not, what people get wrong

The temptation is obvious and the failure is delayed, which is the worst combination. The cursor is legible, so someone reads the timestamp out of it to show progress, or rebuilds one from a stored posted_at to resume a job, and it works. It keeps working until the sort order changes, or a tie-break component is added, or the encoding gains a field, and then it silently skips or repeats rows rather than failing.

Silent is the operative word. A broken cursor does not raise an error; it returns a perfectly valid page from the wrong place. If you are mirroring the ledger, the symptom appears weeks later as gaps you cannot explain.

Do
Store the whole string. Pass it back URL-encoded. Treat it as bytes.
Do not
Parse it. Split it. Reconstruct it from a timestamp. Truncate it. Compare two cursors for ordering. Assume it stays the same length.
Also do not
Cache a cursor for days and assume it still points somewhere sensible. Cursors are positions in an ordering, not permanent bookmarks (see resumable pulls).
Exact contractTypes, defaults, ranges, errors, edge cases

Practical encoding notes. The cursor contains characters that are unsafe in a URL and in a shell: percent-encode it in the query string (--data-urlencode in curl, URLSearchParams in JavaScript, urllib.parse.urlencode in Python) and quote it in shell scripts. A malformed cursor is a 422, not a silently ignored parameter, so a truncated one fails loudly at least.

The delta endpoints use the same idea under a different name. /v1/changes and /v1/jobs/expired take since and return next_since, which is a keyset cursor of the same character (“a timestamp and a uuid”) and is subject to exactly the same rule. The first call may take a real RFC3339 timestamp; every subsequent call should take the next_since you were given.

The correct shape, in TypeScript-flavoured JavaScript
async function* pages(params, key) {
  // The cursor is state, not a value you derive. Keep it exactly as received.
  let cursor = null;
  for (;;) {
    const qs = new URLSearchParams({ ...params, limit: '200' });
    if (cursor) qs.set('cursor', cursor);   // URLSearchParams encodes it for you
    const res = await fetch(
      `https://api.jobopportunitiesapi.org/v1/jobs?${qs}`,
      { headers: { Authorization: `Bearer ${key}` } },
    );
    if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
    const body = await res.json();
    yield body.data;
    if (!body.has_more || !body.next_cursor) return;  // has_more, not data.length
    cursor = body.next_cursor;
  }
}

4The one endpoint with offsets

/v1/companies accepts offset as well as cursor, capped at 100,000. Beyond that it is a 422 rather than a silently clamped page.

In depthWhy it exists, what it is not, what people get wrong

Companies are a directory rather than a stream, and an offset is genuinely useful there: a paginated table in a UI wants to jump to page seven. So the endpoint takes both, and cursor overrides offset when both are sent.

The ordering is GREATEST(open_roles, own_site_roles) DESC, name, slug, and the slug on the end is load-bearing: without it, tie groups covering about a fifth of all companies could be reordered between two requests, so offset paging silently repeated and skipped rows. That is the same failure keyset paging avoids everywhere else.

Exact contractTypes, defaults, ranges, errors, edge cases
limitintegerdefault 251 to 200
offsetinteger0 to 100000
cursorstring

The next_cursor from the previous page. Overrides offset.

3 parameters, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug. Please say so with the thumbs-down below.

5Resuming an interrupted pull

For a listing pull, store the last next_cursor. For an export, store the last id. For a sync, store next_since. Three different tokens, three different endpoints.

In depthWhy it exists, what it is not, what people get wrong

Any pull long enough to matter will be interrupted: a deploy, a network blip, a container restart. The API gives each family a resume token, and using the right one is the difference between resuming and starting again.

What you are doingEndpointStoreResume with
Walking a filtered listing/v1/jobsthe last next_cursor?cursor=
Bulk downloading the corpus/v1/exportthe id of the last row written?after=
Keeping a mirror current/v1/changesthe last next_since?since=
Marking rows stale/v1/jobs/expiredthe last next_since?since=
Paging a directory/v1/companiesnext_cursor, or offset?cursor= or ?offset=
Exact contractTypes, defaults, ranges, errors, edge cases

/v1/export is the one that most rewards resuming. Rows stream ordered by id, so an interrupted transfer restarts exactly where it stopped: pass the last id you received as after. On a multi-gigabyte export that is not a rare need. Note also that records are charged as they are written, so an abandoned export is still billed for what it delivered.

An empty page from /v1/jobs/expired echoes your cursor back rather than returning null, so a polling loop keeps working once it is current: you do not have to special-case “caught up”.

This page was rendered 27 October 2026, 11:28 UTC. Every figure on it comes from the endpoint named beside it, and every published request is re-sent against the live API before this site is allowed to build. If something here is wrong, the thumbs-down above reaches a person.