Job Opportunities API

Check the data. Then trust it.

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.

Last verified 2026-08-22 · Assumes: Pagination and the data model. · Markdown copy

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
sincestringrequired
limitintegerdefault 5001–5000

2 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 page of changes.
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 1st of the month. Do not retry. Every metered response carries X-RateLimit-Records-Remaining, 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.
429Over 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.

The response envelope
{
  "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 delisted row.

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`CauseWhat your mirror should do
createdNew vacancy in the served set.Insert.
updatedAny field changed, or a withdrawn row was re-admitted.Upsert.
delistedThe vacancy left its source. Carries closed_at and closed_reason.Mark closed. Do not delete — the closure is data.
withdrawnWe 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 elseA 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
  1. Seed once. Take a full pull of the slice you care about with /v1/jobs (or /v1/export if you have bulk_export), and record the timestamp you started.
  2. Poll `/v1/changes` with since=<that timestamp> on the first call, then with the next_since from each response.
  3. Apply each change by kind, with a default branch that retracts rather than ignores.
  4. Persist `next_since` after applying, not before — otherwise a crash between the two loses the batch.
  5. 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
A complete, correct sync loop — standard library only
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: current

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