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

**What this covers:** GET /v1/changes; The four change kinds — and the one that breaks integrations; The sync loop, written correctly.

**Assumed knowledge:** [Pagination](./api-pagination.md) and [the data model](./ledger-data-model.md).

**Canonical HTML:** https://jobopportunitiesapi.org/docs/endpoints/changes  
**Machine-readable index:** https://jobopportunitiesapi.org/docs/ai/index.md  
**Last verified:** 2026-08-22  
**Superseded by:** the live API at https://api.jobopportunitiesapi.org and its spec at https://jobopportunitiesapi.org/openapi.json — where this file and the API disagree, the API is right.

---

<a id="endpoint-changes"></a>

## 1. GET /v1/changes

Poll this instead of re-reading the ledger. Pass since on the first call and the returned next_since from then on.

### 1.1 In depth

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.

> **next_since is a keyset cursor, not a timestamp** — It is `<timestamp>|<uuid>`, so no row is lost to a shared timestamp — which is the failure a bare `since=<last time I polled>` has. Pass it back verbatim. The first call may take a real RFC3339 timestamp.

### 1.2 Exact contract

| parameter | type | required | default | allowed values | range | comma-separated | description |
| --- | --- | --- | --- | --- | --- | --- | --- |
| since | string | yes | — | — | — | no | — |
| limit | integer | no | 500 | — | 1–5000 | no | — |

_2 parameters for `GET /v1/changes`, generated from `https://jobopportunitiesapi.org/openapi.json`. The spec is served by the running API and is the contract._

| code | meaning |
| --- | --- |
| 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`.

The response envelope

```json
{
  "count": 2,
  "data": [
    { "change": "withdrawn", "job": { "id": "…", "title": "…", … } },
    { "change": "updated",   "job": { "id": "…", "title": "…", … } }
  ],
  "next_since": "2026-08-22T19:04:56.842089Z|037eac30-…"
}
```

**See also**

- [The four change kinds — and the one that breaks integrations](./endpoints-changes.md#change-kinds)
- [The sync loop, written correctly](./endpoints-changes.md#changes-loop)
- [GET /v1/jobs/expired](./endpoints-jobs.md#endpoint-jobs-expired)

<a id="change-kinds"></a>

## 2. The 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.

### 2.1 In depth

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

> **An unknown-value branch that ignores `withdrawn` will keep serving retracted rows** — Before this kind existed, such rows simply stopped being mentioned — so a mirror built from the feed kept serving them indefinitely. If your integration switches on `change`, add the value before you upgrade anything else, and make your default branch conservative: treat an unrecognised kind as “stop serving this row and re-fetch it”.

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.

### 2.2 Exact contract

| `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](./ledger-optout.md#optout-propagation).

**See also**

- [How it propagates](./ledger-optout.md#optout-propagation)
- [Closed roles](./ledger-data-model.md#closed-roles)
- [Versioning and how changes are made](./api-overview.md#versioning)

<a id="changes-loop"></a>

## 3. The 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.

### 3.1 In depth

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.

### 3.2 Exact contract

A complete, correct sync loop — standard library only

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

**See also**

- [An incremental sync](./recipes-sync.md#recipe-incremental-sync)
- [next_cursor is opaque — this is the rule that bites](./api-pagination.md#cursor-opacity)
- [What counts as a record](./account-record-meter.md#meter-what-counts)

---

## Where to go next

This file is part of **Endpoints**. Others in the same group:

- [Listings](./endpoints-jobs.md) — The four endpoints that return job rows: the live list, one listing, the closure list, and the cheap id-only closure feed.
- [Companies](./endpoints-companies.md) — The employer directory, the single-company endpoint, and an honest account of why company slugs are not yet stable.
- [Bulk export](./endpoints-export.md) — The whole corpus as a stream of NDJSON, resumable to the exact row, with one failure mode you must handle: the error can arrive as the last line of a 200.
- [Key and metadata](./endpoints-meta.md) — Four endpoints that describe the API rather than return rows: your key, the filter vocabularies, the freshness report and the provider list.
- [Keyless endpoints](./endpoints-public.md) — Everything under /public/*, what each returns, and which of them have no keyed equivalent at all.

Always useful:

- [index.md](./index.md) — the map of every file here
- [BUILD-A-SITE.md](./BUILD-A-SITE.md) — the paste-whole brief for building against this API
- [quickstart.md](./quickstart.md) — zero to a first authenticated response
- [api-errors.md](./api-errors.md) — every status code and whether to retry it
