Docs / Shared / Pagination & errors

Pagination & errors

Cursor pagination and the same error codes on every dataset.

Paying nothing for a page that has not changed

Every 200 carries an ETag. Send it back as If-None-Match and an unchanged page answers 304 Not Modified with no body.

curl -sS -D- -o/dev/null https://api.softon.dev/v1/jobs?limit=100 \
  -H "Authorization: Bearer $SOFTON_KEY"

ETag: "9f2c1ab4e0d73851c6ba204fd1e88a37"

# ...later, asking whether it moved
curl -sS -D- -o/dev/null https://api.softon.dev/v1/jobs?limit=100 \
  -H "Authorization: Bearer $SOFTON_KEY" \
  -H 'If-None-Match: "9f2c1ab4e0d73851c6ba204fd1e88a37"'

HTTP/2 304

A 304 costs no credit. That is not a special case — a credit is one successful request, and a 304 is not a 2xx. The per-second rate limit still applies, so this is not a way to poll without limit; it is a way to stop paying for bytes you already hold.

The tag is computed over the data, not the envelope, so the request_id that differs on every response does not change it. It is strong and exact: same rows, same tag.

Cache-Control is private, max-age=0, must-revalidate. Private matters — a response is scoped to your key, so a shared cache holding one account's page and serving it to another would leak data. Do not put these behind a shared proxy.

One honest steer on when to use it: if you are tailing new and changed rows, updated_after is cheaper than any amount of revalidation and is what you should reach for. Conditional requests are for the other job — periodically verifying a full set, which is what reconciliation and re-checking removed records need.

Pagination

Collections are cursor-paginated. Follow meta.next until it is null; cursors are opaque, so do not construct them yourself, and keep every other filter identical between pages — a cursor is only valid for the query and sort order that produced it. Offset pagination is deliberately not offered: it skips and repeats rows when the underlying data moves between pages, and every dataset here is rewritten by a scraper on its own schedule — see Data freshness.

# page 1
GET https://api.softon.dev/v1/jobs?limit=25
# page 2 — pass the cursor back, keep the other filters identical
GET https://api.softon.dev/v1/jobs?limit=25&cursor=eyJvIjoyfQ

limit defaults to 25 and is capped at 100 — or at a collection's own cap, for the few reference collections that declare one. Asking for more is a 400 that names the cap, not a silent truncation, so a client that expected more rows finds out rather than concluding the dataset is small.

Errors

The same codes on every dataset. The HTTP status is authoritative; error.code is the stable string to branch on. An unrecognised query parameter is a 400 naming the parameter and listing the accepted ones — a mistyped filter must not look like an empty result set.

StatusCodeMeaningRetry?
400invalid_requestA parameter is missing, malformed or out of range. The message names it.No — fix the request. The message names the parameter.
401missing_authorizationNo usable credential arrived: either no Authorization header at all, or one this API could not read as a bearer token. Distinct from unauthorized, which means a key did arrive and was not recognised. A proxy or a redirect that drops the header looks exactly like a client that never sent one, and this code is what tells the two apart.No — nothing readable reached us in the Authorization header.
401unauthorizedA key was presented and this API does not serve it. `error.reason` says why: `invalid_api_key` for a key it never issued, `revoked_api_key` for one that was revoked or whose account is suspended (the message says which). The code itself stays `unauthorized`: splitting it was announced on 2026-10-03 and withdrawn on 2026-10-15, so a branch on it keeps working. If the key worked yesterday, your dashboard lists revoked keys rather than deleting them.No — error.reason says why: invalid_api_key or revoked_api_key.
403forbiddenThe key is valid but your plan does not include this dataset.No — upgrade the plan or drop the dataset.
404not_foundNo resource with that id on this dataset.No.
410goneThis record was held and has since been removed at the request of the employer or the individual concerned. It will not come back. If you hold a copy, stop serving it. A single-resource path answers it only for an id this platform actually had -- an id that never existed here is still a 404. Every route of a retired dataset answers it too, collections included, with a message naming the day the dataset stopped; this schema lists those datasets with status `retired`.Never — and delete any copy you hold.
429rate_limitedPlan rate exceeded. Retry-After gives the wait in seconds.Yes — after Retry-After.
429quota_exceededThe account's monthly request allowance is spent. It resets on the 1st; X-Quota-Limit and X-Quota-Used say where you are. Retrying sooner cannot help, which is why this is a different code from rate_limited.Not this month — the allowance resets on the 1st. Upgrade, or wait.

For these read and lookup endpoints, retry a 5xx with exponential backoff and honor Retry-After when present. For 429 rate_limited, wait for Retry-After before retrying. A 429 quota_exceeded needs an allowance reset or an upgrade; it has no Retry-After. Other 4xx responses need a corrected request or credentials. Failed requests do not spend monthly credits. A 403 means the key is real but does not include the dataset it asked for — a key can be scoped to a subset of its plan's datasets when it is issued, and the error body names both what was asked and what the key can read.