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. Asking for more is a 400, not a silent truncation, so a client that expected 500 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.
401unauthorizedThe key is missing, malformed or revoked.No — check the Authorization header.
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. Returned only by the single-resource paths, and only for an id this platform actually had -- an id that never existed here is still a 404.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.

A 5xx is always safe to retry with backoff. A 4xx never is: the request itself needs changing, and replaying it just spends quota. 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.