# Fields

Every field on a job row and a company row, with its type, whether it is always present, and whether it can ever be our inference rather than the source's statement.

**What this covers:** The job row; field_sources; The salary fields; The company row; Nullability, in one table.

**Assumed knowledge:** [Provenance](./ledger-provenance.md) — the published / inferred / absent trichotomy.

**Canonical HTML:** https://jobopportunitiesapi.org/docs/api/fields  
**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.

---

> **Absent is not null and null is not false** — A field can be missing from the JSON entirely (the source had nothing), present and `null` (there is a column and it is empty), or present with a value we derived. `field_sources` is what distinguishes the three, and it is on every job row.

---

<a id="job-fields"></a>

## 1. The job row

Identity, employer, classification, place, salary, timestamps, provenance and the apply link. Generated from the specification.

### 1.1 In depth

Fifteen fields are required and always present; the rest appear only when there is something to put in them. Two behaviours are worth internalising before you write a parser.

- **Optional fields are omitted, not nulled** — with one deliberate exception. `remote_inferred` is always present, including when it is `false`, because it used to be omitted and that made a stated value and an absent one identical on the wire.
- **`closed_at` and `closed_reason` are present and `null`** on a live row, rather than omitted, so a consumer can switch on them without a presence check.
- **`description` is present only when you asked for it** with `include_description=true`, and only on rows that have one.
- **New fields appear without warning.** Parse permissively.

### 1.2 Exact contract


| field | type | always present | allowed values | meaning |
| --- | --- | --- | --- | --- |
| apply_url | string | no | — | — |
| category | string | no | — | — |
| category_confidence | number · nullable | no | — | Classifier confidence in `category`. Below 0.6 no category is published at all, so this is always null or >= 0.6. |
| city | string | no | — | — |
| closed_at | string · nullable | no | — | — |
| closed_reason | string · nullable | no | expired_upstream, not_seen | — |
| company | string | yes | — | — |
| company_logo | string | no | — | — |
| company_slug | string | yes | — | — |
| country | string | no | — | — |
| description | string | no | — | The full advert text. Present ONLY when the request set include_description=true, and only on the 82.7% of rows that have one (3,162,836 live rows). |
| employment_type | string | no | — | — |
| field_sources | FieldSources | yes | — | Per-field provenance. `published` means the value was carried by the source. `inferred` means WE produced it — it may be right, it is not a quotation. `absent` means no value, which is a different failure from a guess and is reported as such. `category` and `seniority` are ALWAYS `inferred`: both are read off the job title by a classifier, never from a field the employer filled in. `salary` is `published` when the source stated it in a field (`salary_source: structured`), and `inferred` when we read it out of the advert text (`salary_source: parsed_description`). It is never an estimate: the projection refuses to emit Erioun's AI salary predictions at all, so a modelled figure cannot reach this API by any route. |
| first_seen_at | string | no | — | When the vacancy first entered this ledger. Populated on 100% of rows, unlike posted_at which the source often omits — so this is the field to sort or backfill by when you need every row to have a date. |
| has_description | boolean | yes | — | — |
| id | string · uuid | yes | — | — |
| last_verified_at | string | yes | — | When we last confirmed this vacancy still exists at its source. |
| location | string | no | — | — |
| posted_at | string | no | — | — |
| provider_type | string | yes | employer_ats, government, direct, aggregator | The provider-level class, in the original vocabulary. Kept so a query written against it still resolves. |
| remote | string | no | remote, hybrid, on_site | — |
| remote_inferred | boolean | yes | — | **Always present.** True when we derived `remote` rather than read it. It used to be omitted when false, which made a stated value and an absent one identical on the wire. |
| salary_currency | string | no | — | — |
| salary_max | number | no | — | — |
| salary_min | number | no | — | — |
| salary_min_annual_eur | number | no | — | salary_min converted to an annual EUR figure, so a row quoting USD/hour and one quoting GBP/year are comparable. NULL when the period or the currency is unrecognised — never guessed, because reading an hourly rate as a salary is wrong by a factor of 2080. Converted with the indicative rates in /v1/meta/freshness, not a settlement rate. |
| salary_period | string | no | — | — |
| salary_source | string | no | structured, parsed_description | Where the figure came from. `structured` is a field the source itself published. `parsed_description` is a real figure quoted in the advert text that WE read out and normalised — we chose the number and the period, so `field_sources.salary` reports it as `inferred`, never `published`. |
| seniority | string | no | — | — |
| slug | string | yes | — | — |
| source | string | yes | — | The provider id, e.g. greenhouse, company_site, workday. The full published list is /public/providers; eures, arbeitsagentur and france_travail were named here until 2026-08-15, when they became discovery-only and left that list. |
| source_type | string | yes | ats, career_site, public_agency, aggregator, agency, unknown | Per-row provenance where the source has been classified per row, otherwise the provider's class. Live distribution: ats 1,063,129, career_site 672,526, public_agency 320,727. RESERVED VALUES: `aggregator` and `agency` are accepted by the filter but currently match no rows. Every aggregator source is marked non-redistributable, so that inventory never enters the ledger at all, and no provider is classified `agency` yet. Filtering on either returns an empty page — that is the data, not a fault. |
| status | string | yes | live, closed | — |
| title | string | yes | — | — |
| upstream_expired_at | string | no | — | When the radar proved the vacancy dead, as opposed to when we removed it. |

### 1.3 Worked examples

One complete row

```console
$ curl -s 'https://api.jobopportunitiesapi.org/public/jobs?limit=1' | jq '.data[0]'
{
  "data": [
    {
      "id": "68a4d294-2e2e-4780-ac4c-eac40bc16f4e",
      "slug": "lab-support-services-manager-68a4d294",
      "title": "Lab Support Services Manager",
      "company": "PerkinElmer",
      "company_slug": "perkinelmer",
      "company_logo": "https://supabase-erioun.tzekos.eu/storage/v1/object/public/company-logos/logos/perkinelmer.png",
      "category": "Operations & Admin",
      "category_confidence": 0.9,
      "country": "GB",
      "city": "Stevenage",
      "location": "Stevenage",
      "remote": "on_site",
      "remote_inferred": true,
      "seniority": "Manager",
      "posted_at": "2026-09-08T00:00:00Z",
      "first_seen_at": "2026-09-06T21:36:59Z",
      "last_verified_at": "2026-09-07T16:20:01Z",
      "status": "live",
      "closed_at": null,
      "closed_reason": null,
      "apply_url": "https://jobs.perkinelmer.com/job/stevenage/lab-support-services-manager/43930/100066747472",
      "source": "company_site",
      "source_type": "career_site",
      "provider_type": "direct",
      "has_description": true,
      "field_sources": {
        "remote": "inferred",
        "employment_type": "absent",
        "category": "inferred",
        "seniority": "inferred",
        "salary": "absent",
        "location": "published",
        "posted_at": "published",
        "description": "published",
        "source_type": "inferred"
      }
    }
… 6 more lines
```

_Real response, fetched from `/public/jobs?limit=1` when this file was built (7 September 2026, 16:58 UTC)._

**See also**

- [field_sources](./api-fields.md#fieldsources-schema)
- [Nullability, in one table](./api-fields.md#nullability)
- [The salary fields](./api-fields.md#salary-fields)

<a id="fieldsources-schema"></a>

## 2. field_sources

Nine fields, each marked published, inferred or absent. Present on every job row, always, with all nine keys.

### 2.1 In depth

This block is required and complete: all nine keys are present on every row, so you never have to test for their existence. Read it before the fields it describes.

Two of the nine can never be `published`, by construction — `category` and `seniority` are read off the job title. One of them is always `inferred` today for a different reason: `source_type`'s per-row column is null on every live row, so the provider's class is the only path taken.

### 2.2 Exact contract

Per-field provenance. `published` means the value was carried by the
source. `inferred` means WE produced it — it may be right, it is not a
quotation. `absent` means no value, which is a different failure from a
guess and is reported as such.

`category` and `seniority` are ALWAYS `inferred`: both are read off the
job title by a classifier, never from a field the employer filled in.

`salary` is `published` when the source stated it in a field
(`salary_source: structured`), and `inferred` when we read it out of the
advert text (`salary_source: parsed_description`). It is never an
estimate: the projection refuses to emit Erioun's AI salary predictions
at all, so a modelled figure cannot reach this API by any route.

| field | type | always present | allowed values | meaning |
| --- | --- | --- | --- | --- |
| category | string | yes | published, inferred, absent | — |
| description | string | yes | published, inferred, absent | — |
| employment_type | string | yes | published, inferred, absent | — |
| location | string | yes | published, inferred, absent | — |
| posted_at | string | yes | published, inferred, absent | — |
| remote | string | yes | published, inferred, absent | — |
| salary | string | yes | published, inferred, absent | — |
| seniority | string | yes | published, inferred, absent | — |
| source_type | string | yes | published, inferred, absent | `inferred` when the value is the provider's default class; `published` when the row itself was classified upstream. TODAY THIS IS ALWAYS `inferred`: the row-level column is null on every live listing, so the provider's class is the only path taken. `published` is reserved for when per-row classification lands. Do not build a filter that expects it to appear. |

| `field_sources` key | Describes | Can be `published`? |
| --- | --- | --- |
| `remote` | `remote`, `remote_inferred` | Yes — a minority of sources state it. |
| `employment_type` | `employment_type` | Yes. |
| `category` | `category`, `category_confidence` | **No** — always inferred. |
| `seniority` | `seniority` | **No** — always inferred. |
| `salary` | `salary_min`, `salary_max`, `salary_currency`, `salary_period`, `salary_source` | Yes, when `salary_source` is `structured`. |
| `location` | `country`, `city`, `location` | Yes. |
| `posted_at` | `posted_at` | Yes. |
| `description` | `description`, `has_description` | Yes. |
| `source_type` | `source_type` | Reserved — always `inferred` today. |

**See also**

- [field_sources — per-field provenance](./ledger-provenance.md#field-sources)
- [Why require_fields=category is an error](./ledger-provenance.md#the-422)
- [require_fields — the honest subset](./ledger-provenance.md#require-fields)

<a id="salary-fields"></a>

## 3. The salary fields

Six fields describing money, one of which is a normalised comparison figure that is null whenever normalising would have required a guess.

### 3.1 In depth

- **`salary_min`, `salary_max`** — The figures as stated, in the source's own currency and period. `salary_max` may equal `salary_min` for a single figure.
- **`salary_currency`** — The currency as stated.
- **`salary_period`** — The period as stated — annual, monthly, hourly, and so on.
- **`salary_source`** — `structured` (a field the source published) or `parsed_description` (a real figure we read out of the advert text). Absent when there is no salary.
- **`salary_min_annual_eur`** — `salary_min` converted to an annual EUR figure so rows can be compared. **Null** when the period or currency is unrecognised.

The null in that last row is the important part. Reading an hourly rate as an annual salary is wrong by a factor of about 2080, so an unrecognised period produces no figure rather than a wrong one. `min_salary` and `max_salary` filter on this column, which is why they select a narrow slice.

### 3.2 Exact contract

Conversion uses indicative rates published on `/v1/meta/freshness`, not a settlement rate. For anything financial, take `salary_min`, `salary_currency` and `salary_period` and convert them yourself with a rate you control.

> **No modelled salary reaches this API** — A salary estimator exists elsewhere in the group. The query that builds this ledger wraps every salary column in a condition that refuses to emit an estimated figure, so there is no parameter, plan or endpoint through which one can arrive. `parsed_description` is a real number a human wrote, which we located and normalised — that is the furthest this goes.

**See also**

- [Salary, specifically](./ledger-provenance.md#salary-provenance)
- [Salary](./api-parameters.md#params-salary)
- [require_fields — the honest subset](./ledger-provenance.md#require-fields)

<a id="company-fields"></a>

## 4. The company row

Identity, website, sectors, and two different counts of open roles that mean different things.

### 4.1 In depth

`open_roles` is roles retrievable from **this API** — never a number the API cannot honour. `own_site_roles` is vacancies we watched on the company's own careers page under a source we do not redistribute. A company can have zero of the first and many of the second; those are the “discovered” companies, returned only with `include_discovered=true`, and they are evidence a company is hiring rather than inventory you can buy.

`website` is present only where we fetched the domain and found the company's own name on it — `website_verified` carries when. An absent website means we could not prove one, not that the company has none.

### 4.2 Exact contract


| field | type | always present | allowed values | meaning |
| --- | --- | --- | --- | --- |
| careers_url | string | no | — | — |
| country | string | no | — | — |
| first_seen | string | no | — | — |
| industry | string | no | — | — |
| logo | string | no | — | — |
| name | string | no | — | — |
| open_roles | integer | no | — | Roles retrievable from THIS API. Never a number the API cannot honour. |
| org_type | string | no | — | — |
| own_site_roles | integer | no | — | Vacancies seen on the company own careers page. |
| sectors | array | no | — | — |
| slug | string | no | — | — |
| source_types | array | no | — | `aggregator` and `agency` are reserved and match no rows today; see the source_type enum. |
| website | string | no | — | — |
| website_verified | string | no | — | When we fetched this domain and found the company own name on it. |

`source_types` is an array and can contain the reserved values `aggregator` and `agency`, which match no rows today — the same reservation as on the job row's `source_type`.

**See also**

- [GET /v1/companies](./endpoints-companies.md#endpoint-companies)
- [Company slugs are not yet guaranteed stable](./endpoints-companies.md#slug-instability)
- [Company — slug and domain](./api-parameters.md#params-company)

<a id="nullability"></a>

## 5. Nullability, in one table

Which fields are guaranteed, which are omitted when empty, which are explicitly null, and which appear only on request.

### 5.1 In depth

| Behaviour | Fields | How to handle it |
| --- | --- | --- |
| **Always present** | `id`, `slug`, `title`, `company`, `company_slug`, `remote_inferred`, `last_verified_at`, `status`, `source`, `source_type`, `provider_type`, `has_description`, `field_sources` | Safe to read without a guard. |
| **Present but nullable** | `category_confidence`, `closed_at`, `closed_reason`, `salary_min_annual_eur` | Check for null, not for absence. |
| **Omitted when empty** | `company_logo`, `category`, `country`, `city`, `location`, `remote`, `employment_type`, `seniority`, all `salary_*`, `posted_at`, `apply_url`, `upstream_expired_at`, `first_seen_at` | Check for absence. In most languages a missing key and a null read the same; if yours distinguishes them, treat both as “no value”. |
| **Only on request** | `description` | Needs `include_description=true`, and only appears on rows where `has_description` is true. |
| **Only on some responses** | `completeness` (page level) | Only when you passed `require_fields`. |

### 5.2 Exact contract

`apply_url` is in the “omitted when empty” list for completeness, but in practice it is on effectively every live row — a row without one is exactly the shape the quality gate holds back. Check the current figure on [the coverage report](./ledger-coverage.md#coverage-fields-section) rather than trusting this sentence.

For a language with strict types, the safe modelling is: required fields non-optional, everything else optional, and `field_sources` a required struct of nine required enums. Do not model the enums as closed — a new value is a compatible change and your parser should not fail on one.

**See also**

- [The job row](./api-fields.md#job-fields)
- [Per-field completeness](./ledger-coverage.md#coverage-fields-section)
- [Versioning and how changes are made](./api-overview.md#versioning)

---

## Where to go next

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

- [API overview](./api-overview.md) — Hosts, versioning, caching, CORS and the HTTP conventions every endpoint follows. Read the hosts section first — getting it wrong costs people hours.
- [Authentication](./api-authentication.md) — One header, three accepted spellings, and a deliberate refusal to tell an attacker which kind of wrong a wrong key is.
- [Keyless access](./api-keyless.md) — What /public/* gives you with no account: real rows, one page at a time, bounded so that evaluating is free and extracting is not.
- [Rate limits](./api-rate-limits.md) — Two independent ceilings — requests per minute and per day — plus a monthly record allowance that is a licence rather than a throttle. They fail differently.
- [Pagination](./api-pagination.md) — Keyset cursors, not offsets. One rule matters more than the rest: hand next_cursor back exactly as you received it.

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
