endpoints
Bulk export
The whole corpus as a stream of NDJSON, resumable to the exact row, with one failure mode you must handle: the error can arrive as the last line of a 200.
1GET /v1/export
One JSON object per line, not a JSON array, so the stream can be processed as it arrives and a dropped connection costs one line rather than the document.
In depthWhy it exists, what it is not, what people get wrong
NDJSON rather than a JSON array is the whole design. An array has to be complete before it can be parsed, so a multi-gigabyte export would have to be buffered entirely, and a connection dropped at 97% would leave you with nothing. A stream of lines can be consumed with a for line in response loop and the worst case is one truncated line.
Rows are ordered by id, which is what makes resumption exact: pass the last id you received as after and the stream continues from there. Every filter /v1/jobs takes applies here too.
Use this rather than a paged listing whenever you want a whole slice. It does not re-run an ordering query per page, it does not risk the listing timeout, and it resumes properly.
Exact contractTypes, defaults, ranges, errors, edge cases
afterstring · uuidResume cursor — the id of the last row you received.
statusstringdefault liveliveclosedanyinclude_descriptionbooleanReturn the full advert text in a description field. Off by default:
descriptions average 2,581 bytes, so a 200-row page would be a 516 KB
response nobody asked for. With this on, limit may not exceed 50 —
a larger request is refused with 422 rather than quietly clamped.
categorystringcomma-separatedComma-separated. uncategorised selects rows with no confident classification.
countrystringcomma-separatedComma-separated ISO-3166 alpha-2.
remotestringremote, hybrid, on_site, or not_stated.
senioritystringcomma-separatedComma-separated; not_stated selects rows with none.
providerstringcomma-separatedComma-separated list of the exact source systems to include, e.g.
greenhouse,lever,workday. This is the ATS or board a vacancy came
from, finer than source_type which buckets them. A name we do not
publish is a 422, never a silently empty page. Up to 12 values.
source_typestringcomma-separatedComma-separated provenance filter. The legacy spellings
employer_ats, government and direct are still accepted and map
onto ats, public_agency and career_site.
Filter values are matched case-insensitively: category=engineering
and category=Engineering are the same query. Enumerate the legal
values with /v1/meta/facets.
companystringcomma-separatedComma-separated company slugs.
posted_afterstringDate or RFC3339 timestamp.
verified_afterstringOnly rows re-confirmed at their source since this instant.
has_salarystringtruestructuredanytrue (and structured) returns only rows whose salary the SOURCE published — the meaning this parameter has always had, kept so that adding derived salaries does not change the results of a query you already ship. any also includes figures we read out of the advert text (salary_source: parsed_description, reported as inferred). AI estimates are never published under any value.
has_descriptionbooleantrue returns only the 2,935,596 live rows (80.9%) that carry a description.
14 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.
| Code | What it means |
|---|---|
200 | A stream of listings, one JSON object per line. |
401 | Missing, unknown, revoked or expired key. All four are reported identically. |
402 | The plan's monthly RECORD allowance is spent. Distinct from 429: a 429 means slow down and retry, a 402 means the licence is used up until the 1st of the month. Do not retry. Every metered response carries X-RateLimit-Records-Remaining, so this is visible long before it happens. |
403 | This endpoint is not included in your plan. X-JOA-Required-Feature names the missing entitlement — delta_feed or bulk_export. See https://jobopportunitiesapi.org/api for the tiers that include it. |
422 | A parameter we will not guess at: an unknown source_type or provider, an unparseable timestamp, a malformed cursor. Note the deliberate exceptions, which are clamps rather than errors: limit is capped at the plan maximum (a non-numeric value falls back to the default), and comma-separated lists are truncated to the per-parameter maximum shown on each. Unknown query parameters are ignored. |
429 | Over your plan's per-minute or per-day limit. Retry-After says how long. |
Plan: requires bulk_export. Without it, 403 with X-JOA-Required-Feature: bulk_export. Content type: application/x-ndjson. Records charged: one per row written, charged in batches as they are written — see below.
curl -sN -H "Authorization: Bearer $JOA_KEY" \
'https://api.jobopportunitiesapi.org/v1/export?country=IE' \
> ie.ndjson
wc -l ie.ndjson
head -1 ie.ndjson | jq '{id, title, company}'2Resuming an interrupted export
Pass the id of the last row you received as after. Rows are ordered by id, so the stream continues exactly where it stopped.
In depthWhy it exists, what it is not, what people get wrong
On a multi-gigabyte transfer an interruption is not a rare event, it is the normal case — a deploy, a proxy timeout, a laptop lid. Resuming is one parameter, and the ordering by id (rather than by anything that can change) is what makes it exact rather than approximate.
Exact contractTypes, defaults, ranges, errors, edge cases
A=https://api.jobopportunitiesapi.org
H="Authorization: Bearer $JOA_KEY"
out=ie.ndjson
touch "$out"
while : ; do
# Resume from the last id we successfully wrote, if any.
after=$(tail -1 "$out" | jq -r '.id // empty' 2>/dev/null)
curl -sN --fail-with-body -H "$H" --get "$A/v1/export" \
--data-urlencode 'country=IE' \
${after:+--data-urlencode "after=$after"} >> "$out" && break
echo "interrupted after ${after:-start}; retrying in 5s" >&2
sleep 5
done
# The LAST LINE may be an error object rather than a row. Always check.
tail -1 "$out" | jq -e 'has("error") | not' >/dev/null \
|| echo "export ended early: $(tail -1 "$out")" >&2A partial last line is possible if the connection dropped mid-write. Truncate to the last complete line before reading .id from it — jq will refuse a partial object, which the 2>/dev/null above swallows, and the loop then restarts from the previous complete row. Duplicated rows on resume are possible; upsert on id.
3The failure mode that catches people
If the record allowance runs out mid-stream, the last line of a 200 response is an error object with the resume cursor. Check the final line.
In depthWhy it exists, what it is not, what people get wrong
The response begins with a 200 as soon as the first bytes are written, and HTTP does not let it become a 402 afterwards. So the error has to arrive in the body, and it arrives as the last line: an object carrying error: "record_quota_exhausted" and the after cursor to resume from once the allowance resets or the plan is upgraded.
Records are charged as rows are written, not at the end. An export you abandon halfway is still billed for what it delivered — which is the honest accounting, since you received the rows, but it does mean an aborted export is not free.
Exact contractTypes, defaults, ranges, errors, edge cases
{
"error": "record_quota_exhausted",
"message": "This plan's monthly record allowance is used up. It resets on the 1st.",
"after": "9f3c1a2e-…"
}import json, os, urllib.parse, urllib.request
API = "https://api.jobopportunitiesapi.org"
KEY = os.environ["JOA_KEY"]
def export(**filters):
"""Yield rows. Raises if the stream ended on an error object."""
qs = urllib.parse.urlencode(filters)
req = urllib.request.Request(
f"{API}/v1/export?{qs}",
headers={"Authorization": f"Bearer {KEY}"},
)
last = None
with urllib.request.urlopen(req, timeout=None) as r:
for raw in r: # one object per line, as it arrives
line = raw.decode().strip()
if not line:
continue
last = json.loads(line)
if "error" in last:
break # do not yield the error as a row
yield last
# The stream can end on an error INSIDE a 200. Silence here is a truncated
# dataset that looks complete.
if last and "error" in last:
raise RuntimeError(
f"export ended early: {last['error']}; resume with after={last.get('after')}"
)
for row in export(country="IE", limit=1):
print(row["id"], row["title"])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.