# The ledger browser

Every control on the home page, the job page and the company pages — what each one sends to the API, and what each label on a row actually asserts.

**What this covers:** The home page — the ledger itself; The filter panel, including Allow and Exclude; A job page; Companies and a company page.

**Assumed knowledge:** Nothing. This is the website, not the API.

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

---

> **Every filtered view is a real URL** — The filter controls are plain links carrying the whole query string — no JavaScript, no client state. So any view you can reach by clicking, you can share, bookmark, or reproduce as an API call by changing the host and the path.

---

<a id="site-home"></a>

## 1. The home page — the ledger itself

A filter panel on the left, a table of openings on the right, a search box above it and a Load more at the foot.

### 1.1 In depth

| Control | What it does | Query parameter |
| --- | --- | --- |
| **Search** box | Full text over title, company and location. Not the advert body. | `q` |
| **Search** button | Submits the form, preserving every active filter as hidden fields. | — |
| **+ Add a listing** | Goes to `/post`, the employer submission form. | — |
| **Load more →** | The next page. Carries the cursor. | `cursor` |
| **Clear all** | Drops every filter and returns to `/`. Shown only when a filter is active. | — |
| A row's **title** | Opens `/job/<slug>`. | — |
| A **column header** | Labels only — the table does not sort. Order is newest first, always. | — |

The row count under the table is the live figure from the coverage report, not the number of rows on screen — deliberately, because it is the number you could reproduce by paging the API.

### 1.2 Exact contract

- **Page size** — 50 rows, fixed.
- **Ordering** — Newest first, by the API's own ordering — `posted_at DESC NULLS LAST, id DESC`.
- **Canonical URL** — Every filtered view declares the bare `/` as canonical, because a filtered view is the same page with a different slice.
- **Crawlers** — `robots.txt` asks them not to walk the facet space at all. The canonical is the second line of defence.
- **Data source** — Server-rendered from `/public/jobs`. The browser never talks to the API directly.
- **Cache** — The page is regenerated every 300 seconds.

The `*` beside a remote status, and the footnote under the table, are the provenance model surfacing in the UI: the asterisk means we inferred the remote status from the listing text rather than reading it from a field the employer filled in. Salary is shown only where the employer published it. Both are the same distinction the API exposes as [`field_sources`](./ledger-provenance.md#field-sources).

**See also**

- [The filter panel, including Allow and Exclude](./website-ledger.md#site-facets)
- [A job page](./website-ledger.md#site-job-page)
- [/public/jobs and /public/jobs/{id}](./endpoints-public.md#public-jobs)

<a id="site-facets"></a>

## 2. The filter panel, including Allow and Exclude

Seven collapsible groups of chips with live counts, and a pair of mode pills that switch a chip between filtering for a value and filtering it out.

### 2.1 In depth

| Group | Filters on | API parameter |
| --- | --- | --- |
| **Category** | Job family | `category` / `exclude_category` |
| **Country** | ISO country code | `country` / `exclude_country` |
| **City** | Resolved city | `city` |
| **Remote** | Remote status | `remote` |
| **Employment** | Employment type | `employment_type` |
| **Seniority** | Level | `seniority` |
| **Source** | Provenance bucket | `source_type` |

Each chip carries its live row count, taken from the facet endpoint — so the panel never offers a value that would return nothing, and never offers one the API would reject.

The **Allow / Exclude** pills at the top of the panel change what a chip does. In Allow mode a chip filters *for* the value; in Exclude mode it filters it *out*, and shows struck through in the alert colour so an excluded chip cannot be mistaken for an unselected one. Only Category and Country can be excluded, because those are the only exclusions the API supports — for every other group the pills are hidden rather than offering a control that would silently do nothing.

### 2.2 Exact contract

Picking a value on one side clears it from the other. Allowing and excluding the same value produces a query that can never match — the API ANDs them — and the page would then say “No openings match these filters” with no way to see why. This is the one piece of contradiction-detection the UI does that the API does not; the API returns the empty page faithfully.

- **Chip state** — Solid = included. Struck through in red = excluded. Plain = neither.
- **Group header count** — “n on” for included values, “n excluded” for excluded ones.
- **Open groups** — Category is open by default; any group with an active value opens itself.
- **Mode in the URL** — `mode=exclude` is UI state and is **not** forwarded to the API — sending it would be an unknown parameter.
- **Truncation** — Country and City show up to 40 values; other groups up to 30. The full vocabulary is on `/public/facets`.

Every chip is an ordinary `<a>` carrying the whole query string, so the panel works with JavaScript disabled and every filtered view is a real, shareable, crawlable URL. That is also why the mode lives in the URL rather than in client state.

**See also**

- [Excluding rather than including](./api-filtering.md#exclusions)
- [GET /v1/meta/facets](./endpoints-meta.md#endpoint-meta-facets)
- [Controlled vocabularies — never hard-code these](./api-parameters.md#filter-vocabularies)

<a id="site-job-page"></a>

## 3. A job page

One listing: a labelled table of every field, the advert text, and an Apply button that goes to the employer.

### 3.1 In depth

| Row | Shows | When it is empty |
| --- | --- | --- |
| **Company** | Links to the company page. | — |
| **Category** | The inferred category. | *Uncategorised* |
| **Location** | City, else the free-text location, else the country. | — |
| **Remote** | Remote / Hybrid / On-site, with **(inferred)** when we derived it. | — |
| **Employment** | Employment type. | *Not stated* |
| **Level** | Seniority. | *Not stated* |
| **Salary** | The figure as published. | *Not stated by the employer* |
| **Posted** / **First seen** | The source's posting date; or, when there is none, the date we first saw it, labelled as such. | — |
| **Last verified** | When we last confirmed the vacancy exists at its source. | — |
| **Source** | A sentence naming the system: “Employer ATS (greenhouse)”, “The employer's own careers page”, “Public employment agency (…)”. | — |

The **Apply on the employer's site →** button opens `apply_url` in a new tab, with `rel="noopener nofollow"`, beside the line *Applications are handled by the employer, not by us.* There is no application flow here and no candidate account.

### 3.2 Exact contract

Two deliberate absences worth knowing about. The page emits JobPosting structured data, but **without** `validThrough`: no live row carries an upstream expiry date, and inventing one would publish a date the ledger cannot back on the one surface where a wrong expiry actively misleads a jobseeker. Instead a role that ends is delisted and its page stops being served. And when there is no advert text there is no structured-data block at all rather than an empty one.

Where the source stated no posting date, the row is labelled **First seen** with the note *(the employer did not state a posting date)* rather than showing a date under a “Posted” label. That is the same honesty the API expresses by leaving `posted_at` absent and populating `first_seen_at` on every row.

The advert body is shown as received, truncated at 12,000 characters. Job pages are indexable by search engines and closed to answer engines — see [the crawler policy](./website-legal.md#site-robots).

**See also**

- [field_sources — per-field provenance](./ledger-provenance.md#field-sources)
- [The crawler policy](./website-legal.md#site-robots)
- [GET /v1/jobs/{id}](./endpoints-jobs.md#endpoint-job-by-id)

<a id="site-company-pages"></a>

## 4. Companies and a company page

A searchable directory of employers, and a page per employer listing their open roles.

### 4.1 In depth

| Control | What it does |
| --- | --- |
| **Search companies…** + **Search** | Filters the directory by name. |
| **← Back** / **Next →** | Offset paging, 50 at a time. |
| A company **name** | Opens `/company/<slug>`. |
| The **open roles** link on a row | Opens the home page filtered to that company (`/?company=<slug>`). |
| **Visit website** | On a company page, when we have a verified domain. Opens the employer's own site. |
| A role in the table | Opens that job's page. |
| **← All companies** | Back to the directory. |

### 4.2 Exact contract

> **A company URL can change** — Company slugs are not currently guaranteed stable between refreshes — a page that 404s may have moved rather than gone. Search for the company by name, or find it from any of its listings. The same caveat applies to the API: [detail and the workaround](./endpoints-companies.md#slug-instability).

The directory shows employers with roles this site can actually serve, so a count on a company row and the roles you can click through to reconcile. Companies seen hiring only under a source we do not redistribute are not in the website directory at all; they are reachable through the API with `include_discovered=true`.

**See also**

- [GET /v1/companies](./endpoints-companies.md#endpoint-companies)
- [Company slugs are not yet guaranteed stable](./endpoints-companies.md#slug-instability)
- [The company row](./api-fields.md#company-fields)

---

## Where to go next

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

- [Every page and every form](./website-pages.md) — The complete route list, what the header and footer link to, and every form on the site with the fields it collects and where they go.
- [Legal, cookies and crawlers](./website-legal.md) — The legal pages and what each covers, exactly what this site stores in your browser, the AI disclosure, and the crawler policy — including where it differs by bot.

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
