# The data model

live + withheld + closed = ledger_rows, and it reconciles exactly. Almost every misunderstanding about this product traces back to this one equation.

**What this covers:** The three populations; Withheld rows — held, not served; Closed roles; Checking the equation yourself; Identity: ids, slugs and domains.

**Assumed knowledge:** Nothing. This is the central concept.

**Canonical HTML:** https://jobopportunitiesapi.org/docs/ledger/data-model  
**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.

---

> **If you read one section on this site, read this one** — Three different numbers on this site describe “how many jobs are there”, and they are all correct. Which one you want depends on whether you mean rows you can fetch, rows we hold, or rows that were ever here.

---

<a id="ledger-model"></a>

## 1. The three populations

Every row in the ledger is exactly one of live, withheld or closed. Add them up and you get ledger_rows, with nothing left over.

### 1.1 In depth

- **`live`** — Rows a caller can obtain from `/v1/jobs`: not closed, not opted out, and not held back by the quality gate. This is the only figure you can reproduce by paging the API, and it is the number the website quotes.
- **`withheld`** — Rows present in the ledger and deliberately not served. Three reasons, each with its own count — see [Withheld rows](#withheld).
- **`closed`** — Roles that came off their source. Retained with the date and the reason, queryable with `status=closed`. See [Closed roles](#closed-roles).
- **`ledger_rows`** — All three, summed. The size of the record, not the size of the product.

The reason to publish all four rather than only the first is that the difference between them *is* the editorial policy. A product that serves 3.7 million rows out of a 7.4 million row ledger has made about half of its decisions in the gap, and hiding the gap would make those decisions unauditable. Publishing it invites the obvious question — what is in the other half — and the answer is on this page.

### 1.2 Exact contract

| field | rows | definition |
| --- | --- | --- |
| live_listings | 3,412,993 | Rows a caller can obtain from /v1/jobs: not delisted, not opted out, and not withheld by the quality gate. This is the number you can reproduce by paging the API. |
| withheld_listings | 224,891 | Rows present in the ledger and deliberately not served. quality_removed breaches the employer-direct guarantee or comes from a discovery-only source; quality_gated is reversible doubt; optout_hidden is a verified employer opt-out. |
| closed_listings | 5,695,531 | Roles that came off their source, retained with their closure date and reason. |
| ledger_rows | 9,333,415 | live_listings + withheld_listings + closed_listings. It reconciles exactly. |
| employers | 189,464 | Distinct employers with at least one retrievable row. |
| countries | 249 | Distinct ISO country codes on live rows. |
| posted_last_7d | 193,628 | Live rows posted in the last seven days. |
| stale | false | true means the snapshot is older than its refresh window. |
| age_seconds | 2,297 | Seconds since measured_at. |

Reconciliation: `3,412,993 + 224,891 + 5,695,531 = 9,333,415`, and `ledger_rows` is `9,333,415` — they match exactly.

_Measured 11 September 2026, 16:52 UTC. Generated from `GET https://api.jobopportunitiesapi.org/public/coverage` (no key required). These figures move — the endpoint supersedes this file._

`live_listings` here and `employer_direct_live` on `/public/stats` are the same population by different names. `/public/stats` also carries a broader `live_listings` that **includes** the quality-gated rows, which is why the two endpoints appear to disagree — see [/public/stats](./ledger-coverage.md#stats-vs-coverage) for the exact mapping. `/public/coverage` is canonical.

**See also**

- [Withheld rows — held, not served](./ledger-data-model.md#withheld)
- [Closed roles](./ledger-data-model.md#closed-roles)
- [Checking the equation yourself](./ledger-data-model.md#reconciliation)
- [/public/stats and why it looks like it disagrees](./ledger-coverage.md#stats-vs-coverage)

<a id="withheld"></a>

## 2. Withheld rows — held, not served

Rows we keep but do not serve, for three reasons: a broken employer-direct promise, reversible doubt, or a verified employer opt-out.

### 2.1 In depth

- **`quality_removed`** — The row breaches the employer-direct guarantee, or it came from a discovery-only source. Typically the apply link resolves to an aggregator, an agency or a government portal rather than to the employer. **No query parameter re-admits these.** That is the point of the guarantee: it is not a default you can turn off.
- **`quality_gated`** — Reversible doubt. A `posted_at` in the future, a missing apply URL, a shape that usually indicates a broken scrape. These can be re-admitted deliberately with `quality=all` on a paid key, and each returned row then carries its verdict, the rule id that matched, and the host that triggered it — so you can judge for yourself rather than trusting the gate.
- **`optout_hidden`** — A site owner asked to be removed and we verified the request. These are never re-admitted by any parameter, and the removal propagates to every endpoint including the closure feeds. See [Employer opt-out](./ledger-optout.md).

Why keep them at all? Because deleting them would make the ledger unable to answer why a row is not there, and would make the same row re-enter and re-leave every time the source is re-read. Retention is what makes “we deliberately do not serve this” a checkable statement rather than an absence.

### 2.2 Exact contract

The counts per reason are on `/public/coverage` under `withheld_reasons`, and they sum to `withheld_listings`. `quality=all` moves `quality_gated` rows into the result set and nothing else; it is documented in full in [the parameter reference](./api-parameters.md#params-provenance-filters).

| Reason | Re-admittable? | How |
| --- | --- | --- |
| `quality_removed` | Never | There is no parameter. The guarantee is not optional. |
| `quality_gated` | Yes, paid keys | `?quality=all`. Rows arrive with `quality_verdict`, the rule id and the matched host. |
| `optout_hidden` | Never | Removal is at the owner's request and propagates everywhere. |

> **Withheld rows still count against the ledger, not against your meter** — They are not returned, so they do not consume records. The record meter counts rows delivered to you — see [the record meter](./account-record-meter.md).

**See also**

- [The three populations](./ledger-data-model.md#ledger-model)
- [Removing your site](./ledger-optout.md#optout-how)
- [What counts as a record](./account-record-meter.md#meter-what-counts)

<a id="closed-roles"></a>

## 3. Closed roles

When a role leaves its source it is marked closed with a date and a reason, and stays queryable forever. Closures are kept, not deleted.

### 3.1 In depth

Two things make closures worth having. The first is correctness: if you are mirroring this data you need to know when to stop serving a row, and a row that simply vanished from a listing endpoint is indistinguishable from one that fell off the end of your pagination. The second is signal: the date a company stopped recruiting for a role is often more informative than the date they started.

There are three ways to consume closures, in increasing cost. `/v1/jobs/expired` returns ids, closure dates and reasons only — about ninety per cent smaller than the equivalent page of rows, and enough to mark your own records stale. `/v1/changes` folds closures into a single ordered stream with creations and updates. `/v1/jobs/closed` returns the full rows, with every filter `/v1/jobs` takes.

> **There is a fourth way a row leaves the served set, and it is not a closure** — `/v1/changes` can report `change: "withdrawn"`. That means the vacancy may well still exist, but we are no longer willing to stand behind the link — most often because it resolves to an aggregator or a portal rather than the employer. Treat it exactly as you treat `delisted`: stop serving the row. An integration that switches on `change` and ignores unknown values will keep serving rows we have withdrawn. See [Change kinds](./endpoints-changes.md#change-kinds).

### 3.2 Exact contract

| Field | Type | Meaning |
| --- | --- | --- |
| `status` | `live` \\| `closed` | Which population this row is in right now. |
| `closed_at` | timestamp \\| null | When we moved the row out of the served set. |
| `closed_reason` | `expired_upstream` \\| `not_seen` \\| null | Why. `expired_upstream` is the source's statement; `not_seen` is our inference from repeated absence. |
| `upstream_expired_at` | timestamp | When the vacancy was proved dead at its source, which can be earlier than `closed_at`. |

`status` defaults to `live` on every listing endpoint. `status=closed` returns only closures, `status=any` returns both and every row carries its own `status`. The parameter is paid-endpoints-only: on `/public/*` it is ignored and you get live rows.

| field | value | meaning |
| --- | --- | --- |
| measured_at | 11 September 2026, 16:52 UTC | When the snapshot was taken. |
| generated_at | 11 September 2026, 17:30 UTC | When this response was assembled. |
| live_listings | 3,412,993 | Rows /v1/jobs will serve. |
| ledger_rows | 9,333,415 | live + withheld + closed. |
| closures.last_24h | 74,101 | Roles that left their source in the last day. |
| closures.last_7d | 1,640,083 | …and in the last week. |
| closures.total | 5,695,531 | Retained: kept indefinitely. |

_Measured 11 September 2026, 16:52 UTC. Generated from `GET https://api.jobopportunitiesapi.org/public/freshness` (no key required). These figures move — the endpoint supersedes this file._

### 3.3 Worked examples

The cheap way to keep a mirror honest: ids only, since your last sync

```bash
curl -s -H "Authorization: Bearer $JOA_KEY" \
  'https://api.jobopportunitiesapi.org/v1/jobs/expired?since=2026-08-21T00:00:00Z&limit=1000'
```

**See also**

- [GET /v1/jobs/expired](./endpoints-jobs.md#endpoint-jobs-expired)
- [The four change kinds — and the one that breaks integrations](./endpoints-changes.md#change-kinds)
- [Detecting closures cheaply](./recipes-sync.md#recipe-closures)

<a id="reconciliation"></a>

## 4. Checking the equation yourself

One keyless request returns all four figures. Add the first three and compare with the fourth — if they ever disagree, that is a defect worth reporting.

### 4.1 In depth

The reconciliation is the guarantee. It says that every row is accounted for in exactly one bucket, that nothing has been quietly dropped, and that the served population is a stated subset rather than whatever survived a pipeline. It is cheap to verify and you should verify it rather than take it on trust — that is the whole idea.

The report is measured on a schedule rather than per request, because counting it per request took twenty-four seconds and returned a 503 instead. So it carries `measured_at`, `age_seconds` and a `stale` flag. `stale: true` means the snapshot is older than its refresh window; the numbers are then indicative rather than current.

### 4.2 Exact contract

The equation, live

```console
$ curl -s https://api.jobopportunitiesapi.org/public/coverage \
  | jq '{live_listings, withheld_listings, closed_listings, ledger_rows, measured_at, stale}'
{
  "age_seconds": 2297,
  "closed_listings": 5695531,
  "countries": 249,
  "definitions": {
    "closed_listings": "Roles that came off their source, retained with their closure date and reason.",
    "ledger_rows": "live_listings + withheld_listings + closed_listings. It reconciles exactly.",
    "live_listings": "Rows a caller can obtain from /v1/jobs: not delisted, not opted out, and not withheld by the quality gate. This is the number you can reproduce by paging the API.",
    "measured_at": "When these figures were counted. The report is measured on a schedule, not per request, because counting it per request took 24 seconds and returned 503 instead.",
    "withheld_listings": "Rows present in the ledger and deliberately not served. quality_removed breaches the employer-direct guarantee or comes from a discovery-only source; quality_gated is reversible doubt; optout_hidden is a verified employer opt-out."
  },
  "employers": 189464,
  "excluded_sources": [
    "adzuna",
    "apify_all_jobs",
    "apify_fantastic_jobs",
    "apify_job_listings",
    "apify_xing",
    "arbeitnow",
    "arbeitsagentur",
    "careerjet",
    "eures",
… 101 more lines
```

_Real response, fetched from `/public/coverage` when this file was built (11 September 2026, 17:30 UTC)._

…and the assertion, as a one-liner that exits non-zero if it ever fails

```bash
curl -s https://api.jobopportunitiesapi.org/public/coverage | jq -e '
  (.live_listings + .withheld_listings + .closed_listings) == .ledger_rows
' && echo 'ledger reconciles'
```

**See also**

- [The coverage report](./ledger-coverage.md#coverage-report)
- [The three populations](./ledger-data-model.md#ledger-model)

<a id="ids-and-slugs"></a>

## 5. Identity: ids, slugs and domains

A listing's uuid is permanent. Its slug is a readable alias. A company's slug is readable but is not currently guaranteed stable — key on the domain where you can.

### 5.1 In depth

Every listing has a `id` (a uuid) and a `slug`. The uuid is the identity: it never changes, it survives a role being closed and re-opened, and it is what `/v1/changes` and `/v1/jobs/expired` refer to. The slug is a human-readable alias built from the title and the first eight characters of the uuid, which is what makes it unique. `GET /v1/jobs/{id}` accepts either.

Companies are the harder case. A company has a `slug` and, on about a third of rows, a `website` domain. The slug is derived from the display name, and display names are corrected over time — an employer whose ATS token was `jj` becomes `johnson-johnson` once the name is resolved properly. That correction is a genuine improvement and it moves the slug.

> **Company slugs are not currently stable across refreshes** — They can change when duplicate company rows compete for the same bare slug. Until that is fixed: key on `company_domain` where you have one, and treat a 404 on a slug that used to work as “re-resolve this company”, not as “this company is gone”. [Full detail and the workaround](./endpoints-companies.md#slug-instability).

### 5.2 Exact contract

| Identifier | Type | Stable? | Use it for |
| --- | --- | --- | --- |
| `job.id` | uuid | Permanent | The primary key of a listing in your own store. |
| `job.slug` | string | Stable in practice; derived from title + uuid prefix | Readable URLs. `/v1/jobs/{id}` accepts it. |
| `company.slug` | string | **Not guaranteed** — see above | Display and links. Re-resolve on 404. |
| `company.website` | domain | As stable as the company's domain | Joining to a CRM. Filter with `company_domain`. |
| `next_cursor` | opaque token | Valid until the ordering changes | Handing back verbatim, nothing else. |

`company_domain` takes bare domains — `stripe.com`, not `https://stripe.com/careers` — though a value with a scheme and a path is tolerated and reduced. About 37% of companies carry a domain at all; the rest can never match that filter, which is a coverage fact rather than a bug, and it is why the recommendation is “where you have one” rather than “always”.

**See also**

- [Company slugs are not yet guaranteed stable](./endpoints-companies.md#slug-instability)
- [GET /v1/companies/{slug}](./endpoints-companies.md#endpoint-company)
- [Hiring signal for a CRM](./recipes-build.md#recipe-crm-enrichment)

---

## Where to go next

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

- [What the ledger is](./ledger-what-joa-is.md) — Employer-direct openings, kept as a record rather than a feed. What that phrase actually commits us to, and who it suits.
- [Provenance](./ledger-provenance.md) — Every field on every row says whether the source published it, whether we inferred it, or whether it is absent. This is the most distinctive thing in the product.
- [Sourcing and refusals](./ledger-sourcing.md) — The three source classes, the providers inside each, the redistribution flag that gates them, and the sources we fetch for ourselves and never republish.
- [Coverage and honesty](./ledger-coverage.md) — How to read /public/coverage, why the weak numbers are published as prominently as the strong ones, and what measured_at and stale actually mean.
- [Employer opt-out and takedowns](./ledger-optout.md) — How a site owner removes themselves, why the removal is verified rather than taken on trust, and how it propagates to every endpoint.

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
