endpoints
The delta feed
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.
1GET /v1/changes
Poll this instead of re-reading the ledger. Pass since on the first call and the returned next_since from then on.
In depthWhy it exists, what it is not, what people get wrong
The alternative (walking /v1/jobs nightly and diffing against your own copy) costs a record for every row you look at, whether or not it changed. On a slice of any size that is the difference between a plan you can afford and one you cannot. The delta feed charges for the rows that actually moved.
It is also more correct. A diff cannot tell you the difference between a row that closed and a row that fell off the end of your pagination, and it cannot see a row that was created and closed between two of your polls. The feed reports both.
Events are relative to what we serve: you receive one when a row enters the served set (created), when a served row changes or is re-admitted (updated), and when it leaves (withdrawn, delisted). A row that closes without ever having been served is not an event here, because you never had it; it is in /v1/jobs/closed. A change usually appears within a minute or two of being committed (removals first; a bulk correction of many rows can take a few minutes longer), and next_since never moves past a change that is still being written, so a cursor cannot skip one. An event carries the row as it is when the event is published, so a row that changed twice between two of your polls arrives once, in its latest state.
Exact contractTypes, defaults, ranges, errors, edge cases
countrystringcomma-separatedRestrict the change feed to these upper-cased ISO country codes (comma-separated, up to 12, same semantics as /v1/jobs). A key country lock applies alongside this filter.
sincestringrequiredlimitintegerdefault 5001 to 50003 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 page of changes. |
400 | A 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. |
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 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. |
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 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. |
429 | Over 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 delta_feed. Without it, 403 and X-JOA-Required-Feature: delta_feed. Records charged: one per change returned. `since` is required: omitting it is a 422 with missing_since.
{
"count": 2,
"data": [
{ "change": "withdrawn", "job": { "id": "…", "title": "…", … } },
{ "change": "updated", "job": { "id": "…", "title": "…", … } }
],
"next_since": "2026-08-22T19:04:56.842089Z|037eac30-…"
}2The four change kinds, and the one that breaks integrations
created, updated, delisted and withdrawn. The last is newer than most integrations and ignoring it means serving rows we have retracted.
In depthWhy it exists, what it is not, what people get wrong
created- A vacancy entered the served set for the first time after your cursor. Add it.
updated- A row you already have changed. Replace it.
delisted- The vacancy is gone from its source. Stop serving it.
withdrawn- We stopped serving the row. The vacancy may well still exist. Most often the apply link resolves to a government or aggregator portal rather than to the employer, so it no longer meets the employer-direct promise this API is sold on. It is also how a hold reaches you: a duplicate of another row, a portal or poster-type judgement, an employer opt-out. Stop serving it, exactly as you would a
delistedrow. When the row may no longer be shown at all (an opt-out, or a source we may no longer redistribute),jobcarries onlyidandstatus.
A withdrawn row keeps its id. If we later judge it servable again it returns as updated, so a mirror keyed on id needs no special handling for the round trip.
Exact contractTypes, defaults, ranges, errors, edge cases
| `change` | Cause | What your mirror should do |
|---|---|---|
created | New vacancy in the served set. | Insert. |
updated | Any field changed, or a withdrawn row was re-admitted. | Upsert. |
delisted | The vacancy left its source. Carries closed_at and closed_reason. | Mark closed. Do not delete: the closure is data. |
withdrawn | We retracted the row: quality verdict, a poster-type judgement, or an employer opt-out. | Stop serving. Keep the id so a later updated re-admits it. |
| anything else | A kind added after you wrote your client. | Stop serving the row and re-fetch it by id. Never ignore. |
An employer opt-out reaches you through this channel as withdrawn. That is the mechanism by which somebody else's removal request becomes your obligation as well, which is why ignoring unknown kinds is a compliance problem and not only a correctness one. See opt-out propagation.
3The sync loop, written correctly
Store next_since, poll on your own schedule, upsert on created and updated, retract on delisted and withdrawn, and default conservatively.
In depthWhy it exists, what it is not, what people get wrong
- Seed once. Take a full pull of the slice you care about with
/v1/jobs(or/v1/exportif you havebulk_export), and record the timestamp you started. - Poll `/v1/changes` with
since=<that timestamp>on the first call, then with thenext_sincefrom each response. - Apply each change by kind, with a default branch that retracts rather than ignores.
- Persist `next_since` after applying, not before: otherwise a crash between the two loses the batch.
- Loop while `count` equals your `limit`. A short page means you are current.
Poll frequency is yours to choose. The ledger refreshes every few hours, so polling every fifteen minutes is more than enough and every minute is waste. There is no webhook and no streaming endpoint.
Exact contractTypes, defaults, ranges, errors, edge cases
import json, os, pathlib, urllib.parse, urllib.request
API = "https://api.jobopportunitiesapi.org"
KEY = os.environ["JOA_KEY"]
STATE = pathlib.Path(".joa-since")
def fetch(since, limit=500):
qs = urllib.parse.urlencode({"since": since, "limit": limit})
req = urllib.request.Request(
f"{API}/v1/changes?{qs}",
headers={"Authorization": f"Bearer {KEY}"},
)
with urllib.request.urlopen(req, timeout=120) as r:
return json.load(r)
def apply(change):
kind, job = change["change"], change["job"]
if kind in ("created", "updated"):
upsert(job)
elif kind in ("delisted", "withdrawn"):
retract(job["id"])
else:
# A kind added after this was written. Retracting is the safe default:
# `withdrawn` was new once, and clients that ignored it went on serving
# rows we had already retracted.
retract(job["id"])
since = STATE.read_text().strip() if STATE.exists() else "2026-08-01T00:00:00Z"
while True:
body = fetch(since)
for change in body["data"]:
apply(change)
# AFTER applying, so a crash in the middle re-reads the batch instead of
# skipping it. next_since is opaque: store the string, never parse it.
since = body["next_since"]
STATE.write_text(since)
if body["count"] < 500:
break # short page: currentupsert and retract are yours. If your store keeps closures (and on a ledger it should), retract sets a flag rather than deleting the row.
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.