# Sourcing and refusals

The three source classes, the providers inside each, the redistribution flag that gates them, and the sources we fetch for ourselves and never republish.

**What this covers:** The three source classes; Every provider, with its live row count; The redistribution flag — a join, not a filter; The sources we fetch and never republish; Staffing agencies and job boards.

**Assumed knowledge:** The [data model](./ledger-data-model.md) — live, withheld and closed.

**Canonical HTML:** https://jobopportunitiesapi.org/docs/ledger/sourcing  
**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="source-classes"></a>

## 1. The three source classes

Every published row comes from an employer's own site, an employer's applicant tracking system, or a public employment agency. There is no fourth class.

### 1.1 In depth

- **`direct` — the employer's own careers page** — Read straight from the employer's site. The most first-party source there is: nobody stands between the vacancy and the row. This is the largest class by a wide margin.
- **`employer_ats` — the employer's applicant tracking system** — Greenhouse, Lever, Workday, SmartRecruiters, Personio, Teamtailor, Ashby, Workable, Recruitee, BambooHR, Oracle HCM and the rest. The employer publishes into the ATS; we read the ATS's own public feed. The apply link goes to the employer's ATS-hosted application, which is where a candidate would apply anyway.
- **`government` — public employment services** — National employment agencies publishing on employers' behalf, where the licence permits redistribution. Currently a small share of rows.

A row carries the class twice, in two vocabularies, and this trips people up. `provider_type` holds the original vocabulary (`employer_ats`, `government`, `direct`, `aggregator`) and is kept so that a query written against it still resolves. `source_type` holds the newer, finer per-row vocabulary (`ats`, `career_site`, `public_agency`, `aggregator`, `agency`, `unknown`). The `source_type` filter accepts the legacy spellings too and maps them across.

### 1.2 Exact contract

| source_class | providers | live rows |
| --- | --- | --- |
| direct | 3 | 1,811,123 |
| employer_ats | 17 | 1,696,888 |
| government | 3 | 11,353 |

_Measured 8 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._

| Legacy value (`provider_type`) | Maps to (`source_type`) | Still accepted by the filter? |
| --- | --- | --- |
| `employer_ats` | `ats` | Yes |
| `government` | `public_agency` | Yes |
| `direct` | `career_site` | Yes |
| `aggregator` | `aggregator` | Yes — but it matches no rows; see below |

> **`aggregator` and `agency` are reserved and match nothing** — Both are accepted by the `source_type` filter and both currently return an empty page. Every aggregator source is marked non-redistributable, so that inventory never enters the served set at all, and no provider is classified `agency` yet. An empty page here is the data, not a fault.

**See also**

- [Every provider, with its live row count](./ledger-sourcing.md#providers-list)
- [The redistribution flag — a join, not a filter](./ledger-sourcing.md#redistributable)
- [Provenance and serving — source, provider, quality, poster type, status](./api-parameters.md#params-provenance-filters)

<a id="providers-list"></a>

## 2. Every provider, with its live row count

The provider field names the exact system a row came from. This list is the vocabulary the provider filter accepts — read it at run time, not from here.

### 2.1 In depth

`provider` is finer than `source_type`: it names Greenhouse rather than “an ATS”. That matters when you care about the shape of the data rather than its origin — description coverage, for instance, varies from about 60% to nearly 100% depending on which system published the row, and the table below shows it per provider.

> **Do not pin this list into your code** — On 2026-08-15 three names left it. The next morning the first external evaluator this product ever had copied `?exclude_provider=eures` out of the old documentation and received a 422. Enumerate `/public/providers` (keyless) or the `provider` facet on `/v1/meta/facets` at run time.

### 2.2 Exact contract

| provider | label | live_listings | with_description | with_description_pct | source_class | source_type |
| --- | --- | --- | --- | --- | --- | --- |
| company_site | Company career site | 1,982,441 | 1,679,740 | 84.7% | direct | career_site |
| workday | Workday | 626,633 | 302,787 | 48.3% | employer_ats | ats |
| oracle | Oracle HCM | 248,778 | 214,835 | 86.3% | employer_ats | ats |
| greenhouse | Greenhouse | 168,401 | 168,382 | 99.9% | employer_ats | ats |
| smartrecruiters | SmartRecruiters | 92,809 | 91,539 | 98.6% | employer_ats | ats |
| paylocity | Paylocity | 89,164 | 83,374 | 93.5% | employer_ats | ats |
| teamtailor | Teamtailor | 70,424 | 70,361 | 99.9% | employer_ats | ats |
| ashby | Ashby | 68,990 | 68,990 | 100% | employer_ats | ats |
| workable | Workable | 68,860 | 68,857 | 99.9% | employer_ats | ats |
| lever | Lever | 51,951 | 51,214 | 98.5% | employer_ats | ats |
| personio | Personio | 50,158 | 45,619 | 90.9% | employer_ats | ats |
| recruitee | Recruitee | 36,818 | 36,799 | 99.9% | employer_ats | ats |
| breezy | Breezy HR | 36,176 | 35,518 | 98.1% | employer_ats | ats |
| bamboohr | BambooHR | 29,709 | 29,036 | 97.7% | employer_ats | ats |
| join | join.com | 16,205 | 16,172 | 99.7% | employer_ats | ats |
| rippling | Rippling | 16,087 | 15,742 | 97.8% | employer_ats | ats |
| pinpoint | Pinpoint | 15,299 | 15,299 | 100% | employer_ats | ats |
| jobtech_sweden | JobTech Sweden | 11,479 | 11,479 | 100% | government | public_agency |
| ukg | UKG Pro | 10,517 | 10,517 | 100% | employer_ats | ats |
| af_employer_direct | Employer career site (Sweden) | 1,040 | 1,038 | 99.8% | direct | career_site |
| nav_norway | NAV Norge | 158 | 0 | 0% | government | public_agency |
| extension_submission | Direct submission | 7 | 6 | 85.7% | direct | career_site |

_This list is the vocabulary the `provider` and `exclude_provider` filters accept. A name that is not here is a 422, never a silently empty page. Read it at run time: on 2026-08-15 three names left this list and a documented example became a 422._

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

`provider` and `exclude_provider` take up to twelve comma-separated values each, and an unrecognised one is a 422 with the name echoed back — never a silently empty page. That is deliberate: an empty page from a typo is indistinguishable from an empty page from a narrow filter, and only one of them is your bug.

### 2.3 Worked examples

Enumerate the vocabulary before you filter on it

```console
$ curl -s https://api.jobopportunitiesapi.org/public/providers \
  | jq -r '.data[] | "\(.provider)\t\(.live_listings)\t\(.source_type)"'
{
  "data": [
    {
      "provider": "company_site",
      "label": "Company career site",
      "live_listings": 1982441,
      "with_description": 1679740,
      "with_description_pct": 84.7,
      "source_class": "direct",
      "source_type": "career_site"
    },
    {
      "provider": "workday",
      "label": "Workday",
      "live_listings": 626633,
      "with_description": 302787,
      "with_description_pct": 48.3,
      "source_class": "employer_ats",
      "source_type": "ats"
    },
    {
      "provider": "oracle",
      "label": "Oracle HCM",
      "live_listings": 248778,
      "with_description": 214835,
      "with_description_pct": 86.3,
… 178 more lines
```

_Real response, fetched from `/public/providers` when this file was built (8 September 2026, 05:26 UTC)._

**See also**

- [Controlled vocabularies — never hard-code these](./api-parameters.md#filter-vocabularies)
- [GET /v1/meta/providers](./endpoints-meta.md#endpoint-meta-providers)

<a id="redistributable"></a>

## 3. The redistribution flag — a join, not a filter

A source is published only if it is explicitly marked redistributable. The default is false, so anything new is excluded until a human classifies it.

### 3.1 In depth

This is the single most important implementation detail in the sourcing story, and it is worth understanding the difference it makes. A blacklist — “publish everything except these names” — fails open: a source added upstream on Tuesday is published on Tuesday, and nobody finds out until someone notices. An allowlist enforced as an inner join fails closed: the same source produces zero rows in the served set until it is classified.

That is why the excluded list can be **printed** rather than promised. It is not a statement about intent; it is a description of a column whose default is exclusion.

### 3.2 Exact contract

`joa.providers.redistributable` is a boolean defaulting to `false`. The projection that builds the served set inner-joins listings to providers on that flag. A provider row also carries `source_class`, `source_type`, a licence URL, when the licence was last checked and by whom, any required attribution text, and a structured `restrictions` object — so the classification is a record, not a memory.

One more guarantee sits alongside it, in the same query: every salary column is wrapped in a condition that refuses to emit an AI-estimated figure. Estimated salaries exist elsewhere in the group and cannot reach this API by any route. See [Salary provenance](./ledger-provenance.md#salary-provenance).

**See also**

- [The sources we fetch and never republish](./ledger-sourcing.md#excluded-sources)
- [Salary, specifically](./ledger-provenance.md#salary-provenance)
- [“Employer-direct” — what it means and what it excludes](./ledger-what-joa-is.md#employer-direct)

<a id="excluded-sources"></a>

## 4. The sources we fetch and never republish

Named, not merely counted. These are fetched for our own products and are excluded from this API because we are not licensed to redistribute them.

### 4.1 In depth

- `adzuna`
- `apify_all_jobs`
- `apify_fantastic_jobs`
- `apify_job_listings`
- `apify_xing`
- `arbeitnow`
- `arbeitsagentur`
- `careerjet`
- `eures`
- `france_travail`
- `himalayas`
- `jobicy`
- `jooble`
- `landing_jobs`
- `remoteok`
- `remotive`
- `the_muse`
- `weworkremotely`
- `working_nomads`

_19 excluded sources, listed under `excluded_sources` in `GET https://api.jobopportunitiesapi.org/public/coverage`. Measured 8 September 2026, 04:52 UTC._

There are two reasons on that list and they are not the same reason.

- **Aggregators — the large majority** — Job aggregators and marketplaces whose terms do not permit us to redistribute their inventory. We read them for our own products; nothing from them enters this ledger's served set. Republishing them would also defeat the point of the product: an aggregator's row cannot tell you where it originally came from.
- **Three public agencies, discovery-only since 2026-08-15** — `eures`, `arbeitsagentur` and `france_travail`. These were published until that date and are now used for discovery only — they tell us an employer is hiring, and we then look for the vacancy at the employer. The apply link on those rows resolves to the agency's portal rather than to the employer, which does not meet the employer-direct promise this API is sold on.

> **This is why the catalogue is smaller** — It is the trade the product makes, stated plainly. If you need the union of everything posted anywhere, an aggregator will serve you better and you should buy one. If you need to be able to say where a row came from, this is what that costs.

### 4.2 Exact contract

The list on this page is rendered from `excluded_sources` on `/public/coverage`, which reads it from the provider table at request time — so it is the actual contents of the column, not a copy of it. The per-source licence detail (the URL, when it was last checked, any attribution required) is recorded internally and is not currently exposed on a public endpoint; if you need it for a procurement review, ask at [/contact](https://jobopportunitiesapi.org/contact) and it will be sent to you.

A row from an excluded source is not merely filtered out of responses — it never enters the served set, so it is counted as `quality_removed` in [`withheld_listings`](./ledger-data-model.md#withheld) rather than as a live row you are not allowed to see.

**See also**

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

<a id="poster-type"></a>

## 5. Staffing agencies and job boards

Real vacancies whose poster is not the employer. Excluded by default, re-admittable deliberately on a paid key, and always labelled.

### 5.1 In depth

This is a different category from an excluded source. The posting is genuine and the apply link is the poster's own — it is not a stale copy of somebody else's row. What it is not is the employer. A staffing agency advertising a role it is recruiting for is doing something legitimate, and a row saying so is useful; it simply is not what “employer-direct” means.

The practical reason for the default is volume. One agency with eighteen thousand listings can flood a category until search stops being useful, and the flooding is invisible unless you already know to look for it. Excluding by default and re-admitting on request puts the choice in the caller's hands rather than in the shape of the data.

### 5.2 Exact contract

| parameter | type | required | default | allowed values | range | comma-separated | description |
| --- | --- | --- | --- | --- | --- | --- | --- |
| include_poster_type | string | no | — | staffing, jobboard, all | — | yes | Re-admits vacancies whose poster is a staffing agency or a job board. Excluded by DEFAULT: the posting is real and the apply link is the poster's own, but the poster is not the employer, and one agency with 18,000 listings can flood a category until search stops being useful. Comma-separated. Paid endpoints only. An unrecognised value is a 422. |
| quality | string | no | — | all | — | no | `all` re-admits rows we have GATED -- reversible doubt, such as a future `posted_at` or a missing apply URL. It never re-admits rows we have REMOVED: those breach the employer-direct guarantee and no query parameter may opt back into it. Each returned row carries its verdict, its rule id and the matched host. Paid endpoints only. |

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

`include_poster_type` is a paid-endpoints-only parameter: on `/public/*` it is ignored. An unrecognised value is a 422. It is comma-separated, so `include_poster_type=staffing,jobboard` and `include_poster_type=all` are both valid.

### 5.3 Worked examples

The same query with and without agency postings, so you can see the difference

```bash
A=https://api.jobopportunitiesapi.org
H="Authorization: Bearer $JOA_KEY"
curl -s -H "$H" "$A/v1/jobs?country=GB&category=Engineering&limit=1" | jq '.data|length'
curl -s -H "$H" "$A/v1/jobs?country=GB&category=Engineering&include_poster_type=all&limit=1" | jq '.data|length'
```

**See also**

- [Withheld rows — held, not served](./ledger-data-model.md#withheld)
- [Provenance and serving — source, provider, quality, poster type, status](./api-parameters.md#params-provenance-filters)
- [Twenty questions, and the query for each](./api-filtering.md#filtering-cookbook)

---

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