api
Pagination
Keyset cursors, not offsets. One rule matters more than the rest: hand next_cursor back exactly as you received it.
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
limitintegerdefault 251–200cursorstringThe 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 field | Type | Meaning |
|---|---|---|
data | array | The rows. Empty array, never null. |
next_cursor | string \| null | Opaque. Pass back verbatim. Null when there is no next page. |
has_more | boolean | Whether another page exists. Loop on this, not on data.length. |
completeness | object | Only when you passed require_fields. See require_fields. |
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.
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'2next_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.
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;
}
}3The 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–200offsetinteger0–100000cursorstringThe 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.
4Resuming 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 doing | Endpoint | Store | Resume with |
|---|---|---|---|
| Walking a filtered listing | /v1/jobs | the last next_cursor | ?cursor= |
| Bulk downloading the corpus | /v1/export | the id of the last row written | ?after= |
| Keeping a mirror current | /v1/changes | the last next_since | ?since= |
| Marking rows stale | /v1/jobs/expired | the last next_since | ?since= |
| Paging a directory | /v1/companies | next_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 11 September 2026, 03:39 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.