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.

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-separated

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

sincestringrequired
limitintegerdefault 5001 to 5000

3 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.
400A 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.
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 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.
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.
422A 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.
429Over 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.

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 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 delisted row. When the row may no longer be shown at all (an opt-out, or a source we may no longer redistribute), job carries only id and status.

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