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.
| Status | Code | Meaning | Retry? |
|---|---|---|---|
| 400 | invalid_request | A parameter is missing, malformed or out of range. The message names it. | No — fix the request. The message names the parameter. |
| 401 | missing_authorization | No 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. |
| 401 | unauthorized | A 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. |
| 403 | forbidden | The key is valid but your plan does not include this dataset. | No — upgrade the plan or drop the dataset. |
| 404 | not_found | No resource with that id on this dataset. | No. |
| 410 | gone | This 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. |
| 429 | rate_limited | Plan rate exceeded. Retry-After gives the wait in seconds. | Yes — after Retry-After. |
| 429 | quota_exceeded | The 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.