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.
Exact contractTypes, defaults, ranges, errors, edge cases
sincestringrequiredlimitintegerdefault 5001–50002 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. |
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. |
429 | Over your plan's per-minute or per-day limit. Retry-After says how long. |
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. 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. Stop serving it, exactly as you would a
delistedrow.
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 11 September 2026, 13: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.