# Pagination

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

**What this covers:** How paging works; next_cursor is opaque — this is the rule that bites; The one endpoint with offsets; Resuming an interrupted pull.

**Assumed knowledge:** [Authentication](./api-authentication.md).

**Canonical HTML:** https://jobopportunitiesapi.org/docs/api/pagination  
**Machine-readable index:** https://jobopportunitiesapi.org/docs/ai/index.md  
**Last verified:** 2026-08-22  
**Superseded by:** the live API at https://api.jobopportunitiesapi.org and its spec at https://jobopportunitiesapi.org/openapi.json — where this file and the API disagree, the API is right.

---

<a id="cursor-basics"></a>

## 1. How 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.

### 1.1 In depth

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.

> **Keyless responses do not page** — On `/public/*` the cursor is refused with a 402 and every response reports `next_cursor: null` because the keyless surface does not page. `has_more` tells the truth — it is `true` when more rows match — and `paging: "key_required"` plus a `note` field say why you cannot reach them without a key. See [Keyless limits](./api-keyless.md#keyless-limits).

### 1.2 Exact contract

| parameter | type | required | default | allowed values | range | comma-separated | description |
| --- | --- | --- | --- | --- | --- | --- | --- |
| limit | integer | no | 25 | — | 1–200 | no | — |
| cursor | string | no | — | — | — | no | The next_cursor from the previous page. |

_2 parameters for `GET /v1/jobs`, generated from `https://jobopportunitiesapi.org/openapi.json`. The spec is served by the running API and is the contract._

| 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](./ledger-provenance.md#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.

### 1.3 Worked examples

Two pages by hand

```bash
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'
```

**See also**

- [next_cursor is opaque — this is the rule that bites](./api-pagination.md#cursor-opacity)
- [Resuming an interrupted pull](./api-pagination.md#resumable-pulls)
- [The one endpoint with offsets](./api-pagination.md#offset-companies)

<a id="cursor-opacity"></a>

## 2. next_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.

### 2.1 In depth

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](#resumable-pulls).

### 2.2 Exact contract

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

```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;
  }
}
```

**See also**

- [Resuming an interrupted pull](./api-pagination.md#resumable-pulls)
- [The sync loop, written correctly](./endpoints-changes.md#changes-loop)
- [422 — a value we will not guess at](./api-errors.md#error-422)

<a id="offset-companies"></a>

## 3. The 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.

### 3.1 In depth

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.

### 3.2 Exact contract

| parameter | type | required | default | allowed values | range | comma-separated | description |
| --- | --- | --- | --- | --- | --- | --- | --- |
| limit | integer | no | 25 | — | 1–200 | no | — |
| offset | integer | no | — | — | 0–100000 | no | — |
| cursor | string | no | — | — | — | no | The next_cursor from the previous page. Overrides offset. |

_3 parameters for `GET /v1/companies`, generated from `https://jobopportunitiesapi.org/openapi.json`. The spec is served by the running API and is the contract._

> **Use the cursor for deep paging** — `offset` is capped at 100,000 and an offset beyond it is a **422**, not a clamped 200. The cursor is lossless and has no limit — if you are walking the whole directory, use it.

**See also**

- [GET /v1/companies](./endpoints-companies.md#endpoint-companies)
- [next_cursor is opaque — this is the rule that bites](./api-pagination.md#cursor-opacity)

<a id="resumable-pulls"></a>

## 4. Resuming 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.

### 4.1 In depth

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=` |

> **A cursor is a position, not a bookmark** — It stays valid for as long as the ordering it encodes is stable, which is fine across a pull that takes minutes or hours. It is not a way to remember where you were last week — for that, use `/v1/changes`, which is designed for exactly that question and costs far fewer records.

### 4.2 Exact contract

`/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”.

**See also**

- [GET /v1/export](./endpoints-export.md#endpoint-export)
- [The sync loop, written correctly](./endpoints-changes.md#changes-loop)
- [A daily pull for one country](./recipes-sync.md#recipe-daily-country-pull)

---

## Where to go next

This file is part of **API**. Others in the same group:

- [API overview](./api-overview.md) — Hosts, versioning, caching, CORS and the HTTP conventions every endpoint follows. Read the hosts section first — getting it wrong costs people hours.
- [Authentication](./api-authentication.md) — One header, three accepted spellings, and a deliberate refusal to tell an attacker which kind of wrong a wrong key is.
- [Keyless access](./api-keyless.md) — What /public/* gives you with no account: real rows, one page at a time, bounded so that evaluating is free and extracting is not.
- [Rate limits](./api-rate-limits.md) — Two independent ceilings — requests per minute and per day — plus a monthly record allowance that is a licence rather than a throttle. They fail differently.
- [Errors](./api-errors.md) — Every status the API emits, its JSON shape, whether it is worth retrying, and what to do about it. Two of them are routinely confused and it is expensive.

Always useful:

- [index.md](./index.md) — the map of every file here
- [BUILD-A-SITE.md](./BUILD-A-SITE.md) — the paste-whole brief for building against this API
- [quickstart.md](./quickstart.md) — zero to a first authenticated response
- [api-errors.md](./api-errors.md) — every status code and whether to retry it
