Job Opportunities API

Check the data. Then trust it.

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.

Last verified 2026-08-22 · Assumes: Authentication and the record meter. · Markdown copy

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. Paging and ordering parameters do not: the export has no pages and no sort, so limit, cursor, offset, sort and include_total are refused with a 400 unsupported_parameter before anything is charged, and an unknown parameter is a 400 unknown_parameter. To take a small sample, narrow the filters (a small country, a recent posted_after) or use /v1/jobs?limit=.

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 · uuid

Resume cursor: the id of the last row you received.

statusstringdefault live
liveclosedany
include_descriptionboolean

Return the full advert text in a description field. Off by default: descriptions averaged 2,581 bytes (measured 5 Aug 2026), 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. Personal email addresses in the text are replaced with [personal contact removed]; company mailboxes and phone numbers are kept (see the description field).

categorystringcomma-separated

Comma-separated. uncategorised selects rows with no confident classification.

familystring

Alias for category (the facet key in /v1/meta/facets is named family; the filter parameter was category). Both names are accepted everywhere category is.

countrystringcomma-separated

Comma-separated ISO-3166 alpha-2, at most 60 codes (more is a 422 too_many_values, never truncated).

remotestring

remote, hybrid, on_site, or not_stated.

senioritystringcomma-separated

Comma-separated; not_stated selects rows with none.

providerstringcomma-separated

Comma-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-separated

Comma-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-separated

Comma-separated company slugs.

posted_afterstring

Date or RFC3339 timestamp.

verified_afterstring

Only rows whose last_verified_at (the last positive confirmation at the source, see the Job schema) is at or after this instant.

has_salarystring
truestructuredany

true (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_descriptionboolean

true returns only the 2,318,210 live rows (95.1%) that carry a description.

15 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.

CodeWhat it means
200A stream of listings, one JSON object per line.
400A parameter the endpoint does not take. unknown_parameter: the name is not a parameter of this endpoint; the message names it and, when a valid parameter is within a small edit distance, suggests it ("unknown parameter: 'countyr'; did you mean 'country'?"). unsupported_parameter: a real parameter of another endpoint that has no meaning here (for example limit on /v1/export). Refused before the meter: nothing is charged.
401Missing, unknown, revoked or expired key. All four are reported identically.
402The 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 allowance resets. The monthly window is the CALENDAR month in UTC: it resets on the 1st of the next month at 00:00 UTC, and the body gives the exact instant. Do not retry before then. Every metered response carries X-RateLimit-Records-Remaining and X-RateLimit-Records-Reset (the reset instant, Unix seconds), so this is visible long before it happens.
403This 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.
422A parameter value we will not guess at: an unknown source_type or provider, an unparseable timestamp, a malformed cursor, a limit outside 1-200 or not a whole number (bad_limit), a flag that is not true/false (bad_boolean), a comma-separated list longer than its maximum (too_many_values), a text filter the query grammar cannot honour (query_syntax: an unmatched parenthesis, an empty group, an OR with no term on one side, a bare AND or NOT, since 2026-10-25). Nothing is charged. The one visible reduction: a limit within 1-200 but above the plan's page cap is reduced to the cap and flagged in X-JOA-Limit-Applied. Unknown parameter NAMES are a 400 (BadRequest), never ignored.
429Over your plan's per-minute or per-day limit. Retry-After says how long (seconds to the next minute boundary, or to UTC midnight on a daily breach). The body says exactly when the limit resets (resets_at, resets_at_text) and, when a higher plan exists, links a same-key upgrade (upgrade_url).

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).

Export one country to a file
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 resumable export loop
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")" >&2

A 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
The last line, when the allowance runs out mid-stream
{
  "error": "record_quota_exhausted",
  "message": "This plan's monthly record allowance is used up. It resets on the 1st.",
  "after": "9f3c1a2e-…" 
}
Streaming an export correctly, checking the last line
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"):
    print(row["id"], row["title"])

This page was rendered 27 October 2026, 11:28 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.