Job Opportunities API

Check the data. Then trust it.

website

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.

Last verified 2026-08-22 · Assumes: Nothing. This is the website, not the API. · Markdown copy

1The 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.

In depthWhy it exists, what it is not, what people get wrong
ControlWhat it doesQuery parameter
Search boxFull text over title, company and location. Not the advert body.q
Search buttonSubmits the form, preserving every active filter as hidden fields.
+ Add a listingGoes to /post, the employer submission form.
Load more →The next page. Carries the cursor.cursor
Clear allDrops every filter and returns to /. Shown only when a filter is active.
A row's titleOpens /job/<slug>.
A column headerLabels 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.

Exact contractTypes, defaults, ranges, errors, edge cases
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`.

2The 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.

In depthWhy it exists, what it is not, what people get wrong
GroupFilters onAPI parameter
CategoryJob familycategory / exclude_category
CountryISO country codecountry / exclude_country
CityResolved citycity
RemoteRemote statusremote
EmploymentEmployment typeemployment_type
SeniorityLevelseniority
SourceProvenance bucketsource_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.

Exact contractTypes, defaults, ranges, errors, edge cases

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.

3A job page

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

In depthWhy it exists, what it is not, what people get wrong
RowShowsWhen it is empty
CompanyLinks to the company page.
CategoryThe inferred category.Uncategorised
LocationCity, else the free-text location, else the country.
RemoteRemote / Hybrid / On-site, with (inferred) when we derived it.
EmploymentEmployment type.Not stated
LevelSeniority.Not stated
SalaryThe figure as published.Not stated by the employer
Posted / First seenThe source's posting date; or, when there is none, the date we first saw it, labelled as such.
Last verifiedWhen we last confirmed the vacancy exists at its source.
SourceA 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.

Exact contractTypes, defaults, ranges, errors, edge cases

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.

4Companies and a company page

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

In depthWhy it exists, what it is not, what people get wrong
ControlWhat it does
Search companies… + SearchFilters the directory by name.
← Back / Next →Offset paging, 50 at a time.
A company nameOpens /company/<slug>.
The open roles link on a rowOpens the home page filtered to that company (/?company=<slug>).
Visit websiteOn a company page, when we have a verified domain. Opens the employer's own site.
A role in the tableOpens that job's page.
← All companiesBack to the directory.
Exact contractTypes, defaults, ranges, errors, edge cases

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.

This page was rendered 10 September 2026, 08:05 UTC. Every figure on it comes from the endpoint named beside it, and every published request is re-sent against the live API before this site is allowed to build. If something here is wrong, the thumbs-down above reaches a person.