# What the ledger is

Employer-direct openings, kept as a record rather than a feed. What that phrase actually commits us to, and who it suits.

**What this covers:** “Employer-direct” — what it means and what it excludes; A ledger, not a feed; Who this suits, and who it does not.

**Assumed knowledge:** Nothing.

**Canonical HTML:** https://jobopportunitiesapi.org/docs/ledger/what-joa-is  
**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="employer-direct"></a>

## 1. “Employer-direct” — what it means and what it excludes

A row is employer-direct when it came from the employer's own careers page, their applicant tracking system, or a government employment feed — and applies at their URL.

### 1.1 In depth

The phrase is doing two jobs. The first is about **where the row came from**: a source we have classified as the employer publishing their own vacancy, or a public agency publishing on employers' behalf. The second is about **where the row sends you**: `apply_url` points at the employer's own application page, not at a board's redirect and not at an agency's landing page.

Both halves matter, and the second is the one that is easy to lose. A vacancy can be scraped from an employer's ATS and still carry an apply link that goes through an intermediary; that row does not meet the promise, and it is withheld rather than served. This is why the served count is smaller than the ledger count, and why the difference is published rather than hidden.

What it does **not** mean is that a human at the employer typed the row into this system. It means we can name the system it came out of and show you the link it resolves to. Everything beyond that — whether the salary was stated, whether the role is really remote — is answered per field, not per row, by [`field_sources`](./ledger-provenance.md#field-sources).

### 1.2 Exact contract

Mechanically, redistribution is a **join, not a filter**. `joa.providers.redistributable` defaults to `false`, and the projection that builds the served set inner-joins on it. So a provider added upstream tomorrow cannot appear in this API before a human has classified it — the default is exclusion. That is a stronger property than a `WHERE provider NOT IN (…)` blacklist, which fails open on anything new.

On top of that, listings whose poster is a staffing agency or a job board are held out of the served set by default even when the source itself is redistributable. They can be re-admitted deliberately on a paid key with `include_poster_type=staffing,jobboard,all`, and they arrive labelled. See [Poster type](./ledger-sourcing.md#poster-type).

| source_class | providers | live rows |
| --- | --- | --- |
| direct | 3 | 1,732,053 |
| employer_ats | 17 | 1,660,110 |
| government | 3 | 10,258 |

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

**See also**

- [The three source classes](./ledger-sourcing.md#source-classes)
- [Withheld rows — held, not served](./ledger-data-model.md#withheld)
- [The redistribution flag — a join, not a filter](./ledger-sourcing.md#redistributable)

<a id="ledger-vs-board"></a>

## 2. A ledger, not a feed

Rows are not deleted when a role closes. They are marked closed, with a date and a reason, and stay queryable — so the data answers questions about time.

### 2.1 In depth

A job feed's job is to tell you what is open now. Everything else is noise, so a feed drops rows the moment they stop being current. That makes a feed cheap and makes it impossible to ask when a company started hiring, how long a role stayed open, or which teams a company stopped recruiting for in the last quarter.

A ledger keeps the entries. When a role leaves its source, the row gets a `closed_at` and a `closed_reason` and moves out of the default result set — it is still there, and `status=closed` or `status=any` will return it. Roughly two rows in five in this ledger are closed rather than live, and that half of the data is the half most people underuse.

The practical consequence for an integrator is that you do not have to build the history yourself. You do not need to snapshot the API nightly and diff it to find out that a role disappeared: `/v1/changes` tells you, `/v1/jobs/expired` gives you the cheap id-only version, and `/v1/jobs/closed` gives you the rows.

### 2.2 Exact contract

| field | rows | definition |
| --- | --- | --- |
| live_listings | 3,402,421 | 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 | 226,954 | 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,673,635 | Roles that came off their source, retained with their closure date and reason. |
| ledger_rows | 9,303,010 | live_listings + withheld_listings + closed_listings. It reconciles exactly. |
| employers | 189,140 | Distinct employers with at least one retrievable row. |
| countries | 249 | Distinct ISO country codes on live rows. |
| posted_last_7d | 193,462 | Live rows posted in the last seven days. |
| stale | false | true means the snapshot is older than its refresh window. |
| age_seconds | 8,130 | Seconds since measured_at. |

Reconciliation: `3,402,421 + 226,954 + 5,673,635 = 9,303,010`, and `ledger_rows` is `9,303,010` — they match exactly.

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

Closures carry one of two reasons. `expired_upstream` means the source itself declared the vacancy over — an expiry date passed, or the ATS marked it closed. `not_seen` means the vacancy stopped appearing where it had been appearing, for long enough that its absence is a signal rather than a hiccup. The distinction matters if you are measuring hiring velocity: `expired_upstream` is a statement by the employer, `not_seen` is an inference by us.

**See also**

- [Closed roles](./ledger-data-model.md#closed-roles)
- [GET /v1/changes](./endpoints-changes.md#endpoint-changes)
- [Detecting closures cheaply](./recipes-sync.md#recipe-closures)

<a id="who-uses-it"></a>

## 3. Who this suits, and who it does not

It suits anyone who needs to say where a row came from. It does not suit anyone who needs the largest possible catalogue.

### 3.1 In depth

- **Job boards and vertical marketplaces** — You get rows that link to the employer, so your users apply somewhere real, and you can state your sourcing policy honestly. The trade is fewer rows in thin categories than an aggregator would sell you.
- **Sales and go-to-market teams** — Hiring is a buying signal, and `company_domain` is the join key you already have in a CRM. The closure feed matters as much as the live one: a company that stopped hiring for a role is a different signal from one that never started.
- **Labour-market research** — Because closures are retained with dates and reasons, and because per-field provenance is on every row, you can state exactly what your dataset does and does not know. That is usually the hard part of publishing.
- **Products that need a salary distribution** — This is the poorest fit. Salary is published on a small minority of rows and is never modelled. `require_fields=salary` gives you a clean subset with a real figure on every row — read [the per-country split](./ledger-coverage.md#countries-list) before you plan around it.
- **Anyone who needs every posting in a market** — Not this. An aggregator that redistributes everything will always have more rows; what it cannot tell you is which of them the employer actually posted.

### 3.2 Exact contract

The honest way to decide is to measure rather than to read. Every figure you need is keyless: `/public/coverage` for the ledger-wide picture, `/public/coverage/countries` for your market, `/public/facets` for whether your category exists at the volume you need, and `/public/jobs` with your real filters for whether the rows look like what you expected. That is an afternoon and no account.

The evaluation, in four keyless requests

```bash
A=https://api.jobopportunitiesapi.org
curl -s "$A/public/coverage"                | jq '{live_listings, employers, measured_at, stale}'
curl -s "$A/public/coverage/countries"      | jq '.countries[] | select(.country=="DE")'
curl -s "$A/public/facets"                  | jq '.data.family[:10]'
curl -s "$A/public/jobs?country=DE&category=Engineering&limit=5" | jq '.data[].title'
```

**See also**

- [The coverage report](./ledger-coverage.md#coverage-report)
- [Per-country coverage](./ledger-coverage.md#countries-list)
- [The plans](./account-plans.md#plans-table)

---

## Where to go next

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

- [The data model](./ledger-data-model.md) — live + withheld + closed = ledger_rows, and it reconciles exactly. Almost every misunderstanding about this product traces back to this one equation.
- [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
