recipes
Keeping data current
Four jobs you will end up writing: a daily country pull, an incremental sync, closure detection, and a bulk export — with the record cost of each.
1A daily pull for one country
The simplest useful job: fetch everything in a market once a day, resumably, and write it somewhere.
In depthWhy it exists, what it is not, what people get wrong
Use this to seed a store, or when your market is small enough that a full re-read is cheaper than the machinery of an incremental sync. Once the slice is more than a few tens of thousands of rows, switch to the incremental sync — the record cost of a daily full read grows linearly and the delta feed's does not.
Two details make it resumable rather than merely repeatable. The cursor is written to disk after each page is processed, so a crash restarts from the last complete page rather than from the beginning. And rows are keyed on id, so re-processing a page is harmless.
Exact contractTypes, defaults, ranges, errors, edge cases
"""Fetch every live row for one country and write NDJSON.
export JOA_KEY=...
python3 daily_pull.py IE
Resumable: the cursor is persisted after each page, so an interrupted run
continues rather than restarting. Rows are keyed on id, so re-processing a
page costs records but corrupts nothing.
"""
import json, os, pathlib, sys, urllib.parse, urllib.request
API = "https://api.jobopportunitiesapi.org"
KEY = os.environ["JOA_KEY"]
country = sys.argv[1] if len(sys.argv) > 1 else "IE"
state = pathlib.Path(f".joa-{country}.cursor")
out = pathlib.Path(f"{country}.ndjson")
def get(params):
req = urllib.request.Request(
f"{API}/v1/jobs?" + urllib.parse.urlencode(params),
headers={"Authorization": f"Bearer {KEY}"},
)
with urllib.request.urlopen(req, timeout=90) as r:
return json.load(r)
cursor = state.read_text().strip() if state.exists() else ""
rows = 0
with out.open("a") as f:
while True:
params = {"country": country, "limit": 200}
if cursor:
params["cursor"] = cursor
page = get(params)
for job in page["data"]:
f.write(json.dumps(job, separators=(",", ":")) + "\n")
rows += 1
f.flush()
if not page.get("has_more") or not page.get("next_cursor"):
state.unlink(missing_ok=True) # finished: forget the cursor
break
cursor = page["next_cursor"]
state.write_text(cursor) # AFTER writing the page
print(f"{rows} rows -> {out}")Record cost: one per row, every run. A market with 60,000 live rows costs 60,000 records a day, which is the whole Growth allowance in a single run — this is precisely the calculation that should push you to the delta feed.
2An incremental sync
Seed once, then poll /v1/changes. You pay for rows that moved, not for rows you look at, and you cannot miss a row to a shared timestamp.
In depthWhy it exists, what it is not, what people get wrong
This is the shape almost every serious integration ends up with. Seed the store once — with a full pull, or with /v1/export if your plan has it — then poll the delta feed every fifteen minutes or so and apply the changes.
It is not only cheaper, it is more correct. A nightly diff cannot see a row that was created and closed between two runs, and it cannot distinguish a row that closed from one that fell off the end of your pagination. The feed reports both, explicitly, in order.
Exact contractTypes, defaults, ranges, errors, edge cases
The full loop, with the state handling and the change-kind switch, is on the delta feed page. The summary:
- Seed with
/v1/jobsor/v1/export; record the instant you started. GET /v1/changes?since=<that instant>; thensince=<next_since>forever after.created/updated→ upsert.delisted/withdrawn/ anything else → retract.- Persist
next_sinceafter applying the batch. - Loop while
count == limit; a short page means you are current.
| Daily full pull | Incremental sync | |
|---|---|---|
| Records charged | every row, every day | only rows that moved |
| Sees short-lived rows | no | yes |
| Distinguishes closed from missing | no | yes |
| Sees employer opt-outs | only as an absence | explicitly, as withdrawn |
| Plan needed | any | delta_feed |
| Complexity | a loop | a loop and a cursor in your database |
3Detecting closures cheaply
/v1/jobs/expired returns ids, dates and reasons only — about ninety per cent smaller than the same page of full rows.
In depthWhy it exists, what it is not, what people get wrong
If all you need is to mark your own rows stale, you do not need the rows back. This endpoint gives you the id, when it closed and why, which is enough to update your store — and it is the smallest possible response for that job.
Note that it charges the same one record per id as a full row would. The saving is in bandwidth, latency and parsing, not in the meter. If you are already running the delta feed you do not need this endpoint at all — closures arrive there as delisted.
Exact contractTypes, defaults, ranges, errors, edge cases
#!/bin/bash
# Mark rows stale in our own store. Idempotent; safe to run hourly.
set -euo pipefail
A=https://api.jobopportunitiesapi.org
STATE=${STATE:-.joa-expired-cursor}
since=$(cat "$STATE" 2>/dev/null || echo '2026-08-01T00:00:00Z')
while : ; do
page=$(curl -s --fail-with-body -H "Authorization: Bearer $JOA_KEY" \
--get "$A/v1/jobs/expired" \
--data-urlencode "since=$since" --data-urlencode 'limit=1000')
count=$(jq -r '.count' <<<"$page")
jq -r '.data[] | "\(.id)\t\(.closed_at)\t\(.closed_reason)"' <<<"$page" \
| while IFS=$'\t' read -r id at reason; do
echo "UPDATE jobs SET closed_at='$at', closed_reason='$reason' WHERE id='$id';"
done
# Persist AFTER emitting, so a crash re-reads rather than skips.
jq -r '.next_since' <<<"$page" > "$STATE"
[ "$count" -eq 1000 ] || break # short page: current
doneAn empty page echoes your cursor back rather than returning null, so this loop keeps working once it is current — no special case needed.
4A bulk export, done properly
Stream NDJSON, resume with after, and check the last line — because the error can arrive inside a 200.
In depthWhy it exists, what it is not, what people get wrong
Reach for the export rather than a paged listing whenever you want a whole slice: it streams, it resumes to the exact row, and it does not re-run an ordering query per page. It needs a plan with bulk_export.
Exact contractTypes, defaults, ranges, errors, edge cases
#!/bin/bash
set -euo pipefail
A=https://api.jobopportunitiesapi.org
out=${1:-corpus.ndjson}
touch "$out"
for attempt in 1 2 3 4 5; do
after=$(tail -1 "$out" 2>/dev/null | jq -r '.id // empty' 2>/dev/null || true)
if curl -sN --fail-with-body -H "Authorization: Bearer $JOA_KEY" \
--get "$A/v1/export" \
--data-urlencode 'country=IE' \
${after:+--data-urlencode "after=$after"} >> "$out"; then
break
fi
echo "attempt $attempt interrupted after ${after:-start}" >&2
sleep $((attempt * 5))
done
# The stream can end on an error object inside a 200. Silence here is a
# truncated dataset that looks complete.
if tail -1 "$out" | jq -e 'has("error")' >/dev/null 2>&1; then
echo "INCOMPLETE: $(tail -1 "$out" | jq -r '.error')" >&2
echo "resume with after=$(tail -1 "$out" | jq -r '.after')" >&2
exit 1
fi
echo "$(wc -l < "$out") rows"Rows may repeat across a resume if the connection dropped mid-line — upsert on id rather than appending blindly into a store that cares.
This page was rendered 11 September 2026, 16:40 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.