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

**What this covers:** GET /v1/export; Resuming an interrupted export; The failure mode that catches people.

**Assumed knowledge:** [Authentication](./api-authentication.md) and [the record meter](./account-record-meter.md).

**Canonical HTML:** https://jobopportunitiesapi.org/docs/endpoints/export  
**Machine-readable index:** https://jobopportunitiesapi.org/docs/ai/index.md  
**Last verified:** 2026-08-22  
**Superseded by:** the live API at https://api.jobopportunitiesapi.org and its spec at https://jobopportunitiesapi.org/openapi.json — where this file and the API disagree, the API is right.

---

<a id="endpoint-export"></a>

## 1. GET /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.

### 1.1 In depth

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.

### 1.2 Exact contract

| parameter | type | required | default | allowed values | range | comma-separated | description |
| --- | --- | --- | --- | --- | --- | --- | --- |
| after | string · uuid | no | — | — | — | no | Resume cursor — the `id` of the last row you received. |
| status | string | no | live | live, closed, any | — | no | — |
| include_description | boolean | no | — | — | — | no | Return 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. |
| category | string | no | — | — | — | yes | Comma-separated. uncategorised selects rows with no confident classification. |
| country | string | no | — | — | — | yes | Comma-separated ISO-3166 alpha-2. |
| remote | string | no | — | — | — | no | `remote`, `hybrid`, `on_site`, or `not_stated`. |
| seniority | string | no | — | — | — | yes | Comma-separated; `not_stated` selects rows with none. |
| provider | string | no | — | — | — | yes | 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_type | string | no | — | — | — | yes | 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. |
| company | string | no | — | — | — | yes | Comma-separated company slugs. |
| posted_after | string | no | — | — | — | no | Date or RFC3339 timestamp. |
| verified_after | string | no | — | — | — | no | Only rows re-confirmed at their source since this instant. |
| has_salary | string | no | — | true, structured, any | — | no | `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_description | boolean | no | — | — | — | no | `true` returns only the 2,989,334 live rows (81.5%) that carry a description. |

_14 parameters for `GET /v1/export`, generated from `https://jobopportunitiesapi.org/openapi.json`. The spec is served by the running API and is the contract._

| code | meaning |
| --- | --- |
| 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](#export-quota).

### 1.3 Worked examples

Export one country to a file

```bash
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}'
```

**See also**

- [Resuming an interrupted export](./endpoints-export.md#export-resume)
- [The failure mode that catches people](./endpoints-export.md#export-quota)
- [Queries that can time out, and how to make them fast](./api-filtering.md#expensive-queries)

<a id="export-resume"></a>

## 2. Resuming 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.

### 2.1 In depth

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.

### 2.2 Exact contract

A resumable export loop

```bash
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`.

**See also**

- [Resuming an interrupted pull](./api-pagination.md#resumable-pulls)
- [The failure mode that catches people](./endpoints-export.md#export-quota)

<a id="export-quota"></a>

## 3. The 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.

### 3.1 In depth

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.

> **A consumer that assumes a clean end of stream will silently truncate** — It will see a 200, a valid NDJSON body and fewer rows than expected, with nothing anywhere saying why. Always inspect the final line before treating an export as complete.

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.

### 3.2 Exact contract

The last line, when the allowance runs out mid-stream

```json
{
  "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

```python
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"])
```

**See also**

- [402 — the licence, not the throttle](./api-errors.md#error-402)
- [Running out](./account-record-meter.md#meter-exhausted)
- [A bulk export, done properly](./recipes-sync.md#recipe-bulk-export)

---

## Where to go next

This file is part of **Endpoints**. Others in the same group:

- [Listings](./endpoints-jobs.md) — The four endpoints that return job rows: the live list, one listing, the closure list, and the cheap id-only closure feed.
- [Companies](./endpoints-companies.md) — The employer directory, the single-company endpoint, and an honest account of why company slugs are not yet stable.
- [The delta feed](./endpoints-changes.md) — One ordered stream of everything that changed — created, updated, withdrawn and delisted — so you never have to re-read the ledger to find out what moved.
- [Key and metadata](./endpoints-meta.md) — Four endpoints that describe the API rather than return rows: your key, the filter vocabularies, the freshness report and the provider list.
- [Keyless endpoints](./endpoints-public.md) — Everything under /public/*, what each returns, and which of them have no keyed equivalent at all.

Always useful:

- [index.md](./index.md) — the map of every file here
- [BUILD-A-SITE.md](./BUILD-A-SITE.md) — the paste-whole brief for building against this API
- [quickstart.md](./quickstart.md) — zero to a first authenticated response
- [api-errors.md](./api-errors.md) — every status code and whether to retry it
