# Keyless endpoints

Everything under /public/*, what each returns, and which of them have no keyed equivalent at all.

**What this covers:** The complete list; /public/jobs and /public/jobs/{id}; The coverage family; The spec, the AI brief, plans and status.

**Assumed knowledge:** [Keyless access](./api-keyless.md).

**Canonical HTML:** https://jobopportunitiesapi.org/docs/endpoints/public  
**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="public-endpoints-list"></a>

## 1. The complete list

Fourteen GET endpoints that need no key, plus a handful of POST routes that belong to the website rather than to the data API.

### 1.1 In depth

| Endpoint | Returns | Cache | Keyed twin |
| --- | --- | --- | --- |
| `GET /public/jobs` | A page of listings, max 50 rows, no cursor. | 300s | `/v1/jobs` |
| `GET /public/jobs/{id}` | One listing, description always included. | 600s | `/v1/jobs/{id}` |
| `GET /public/companies` | A page of employers. | 600s | `/v1/companies` |
| `GET /public/companies/{slug}` | One employer. | 600s | `/v1/companies/{slug}` |
| `GET /public/facets` | Every filter vocabulary with live counts. | 300s | `/v1/meta/facets` |
| `GET /public/providers` | Every redistributable source with row counts. | 900s | `/v1/meta/providers` |
| `GET /public/freshness` | Verification recency, field coverage, closures. | 900s | `/v1/meta/freshness` |
| `GET /public/coverage` | **The canonical coverage report.** | 600s | — none |
| `GET /public/coverage/countries` | Per-country coverage. | 900s | — none |
| `GET /public/coverage/employers` | Per-employer coverage. | 600s | — none |
| `GET /public/stats` | Convenience summary. [Read this first](./ledger-coverage.md#stats-vs-coverage). | 300s | — none |
| `GET /public/plans` | The purchasable price list. | 300s | — none |
| `GET /public/openapi.json` / `.yaml` | The machine contract, rendered live. | 3600s | `/v1/openapi.json` |
| `GET /public/ai-prompt` | An integration brief for a model, from the API host. | 600s | — none |
| `GET /public/status` | Service status. | — | — none |
| `GET /health` | Liveness. Not under `/public`. | — | — none |

### 1.2 Exact contract

There are also `POST` routes under `/public/` — `subscribe`, `unsubscribe`, `contact`, `submissions`, `optout/request`, `optout/verify`, `checkout`, `auth/request`, `auth/verify` and the key-management routes. Those belong to the **website**: they are what its forms post to, most are protected by a Turnstile token or a signed session, and none of them is part of the data API. They are documented under [The website](./website-pages.md#site-forms).

> **`/public/sitemap/{kind}/{page}` and `/public/invoice/{token}` also exist** — The first serves the website's sitemap chunks; the second serves a customer's own invoice, addressed by an unguessable token in the URL because the recipient reaches it from a link in an email and has no session. Neither is a data endpoint.

**See also**

- [The limits, exactly](./api-keyless.md#keyless-limits)
- [/public/jobs and /public/jobs/{id}](./endpoints-public.md#public-jobs)
- [Every form, and where it posts](./website-pages.md#site-forms)

<a id="public-jobs"></a>

## 2. /public/jobs and /public/jobs/{id}

The same rows as /v1/jobs, capped at fifty per page, with no cursor and the paid-only parameters ignored rather than refused.

### 2.1 In depth

Same projection, same fields, same provenance block. What differs is the envelope: `limit` above 50 is clamped, `cursor` is a 402, and `has_more` reports `false` whether or not more rows exist — because the surface does not page, not because you have seen everything.

The parameters ignored here are the four marked *paid endpoints only* in [the reference](./api-parameters.md): `status`, `quality`, `include_poster_type`, and closed-row access generally. Sending them is not an error; they simply do nothing, so a keyless request always returns live, employer-direct rows.

> **The detail endpoint always includes the description** — `/public/jobs/{slug}` returns the advert body without `include_description=true`, because you are asking for one row. It accepts the uuid and the slug.

### 2.2 Exact contract

`Access-Control-Allow-Origin: *`, so this is callable from browser JavaScript with no proxy. Combined with the edge cache, a small public site can be built entirely on the client against these two endpoints.

A working list, client-side, no key, no backend

```javascript
const params = new URLSearchParams({ country: 'NL', limit: '20' });
const res = await fetch(`https://api.jobopportunitiesapi.org/public/jobs?${params}`);
const { data } = await res.json();
document.querySelector('#jobs').innerHTML = data.map(j => `
  <li>
    <a href="${j.apply_url}" rel="nofollow noopener">${j.title}</a>
    — ${j.company}${j.city ? `, ${j.city}` : ''}
    ${j.remote_inferred && j.remote ? '<small>remote status inferred</small>' : ''}
  </li>`).join('');
```

### 2.3 Worked examples

A keyless page

```console
$ curl -s 'https://api.jobopportunitiesapi.org/public/jobs?country=NL&limit=2' \
  | jq '{rows: (.data|length), has_more, next_cursor}'
{
  "data": [
    {
      "id": "baf13928-74b9-467d-9130-e7042bc45189",
      "slug": "medewerker-centrale-storingsreceptie-dagdienst-baf13928",
      "title": "Medewerker Centrale storingsreceptie – Dagdienst",
      "company": "stedin",
      "company_slug": "stedin",
      "company_logo": "https://supabase-erioun.tzekos.eu/storage/v1/object/public/company-logos/logos/stedin.png",
      "category_confidence": null,
      "country": "NL",
      "city": "Rotterdam",
… 69 more lines
```

_Note `has_more: false` and `next_cursor: null` — the keyless surface does not page. Real response, fetched from `/public/jobs?country=NL&limit=2` when this file was built (11 September 2026, 04:14 UTC)._

**See also**

- [The limits, exactly](./api-keyless.md#keyless-limits)
- [GET /v1/jobs](./endpoints-jobs.md#endpoint-jobs)
- [A job board](./recipes-build.md#recipe-job-board)

<a id="public-coverage-endpoints"></a>

## 3. The coverage family

Four keyless reports with no keyed equivalent: the canonical coverage report, per country, per employer, and a convenience summary.

### 3.1 In depth

- **`/public/coverage`** — **Canonical.** The three populations, the reconciliation, per-field completeness, the source breakdown, the named excluded sources, and `measured_at` / `age_seconds` / `stale`. Everything on this site defers to it.
- **`/public/coverage/countries`** — Per ISO code: live rows, how many carry a description, how many carry a published salary, how many employers.
- **`/public/coverage/employers`** — The same idea per employer — how much data sits behind each company.
- **`/public/stats`** — A convenience summary with **different definitions**, which is why it looks like it disagrees. [The exact mapping](./ledger-coverage.md#stats-vs-coverage).

These are keyless on purpose and there is no keyed version. A coverage report you need an account to read is a marketing claim; one anybody can fetch is an audit.

### 3.2 Exact contract

| field | rows | definition |
| --- | --- | --- |
| live_listings | 3,404,034 | 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 | 227,783 | 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,664,972 | Roles that came off their source, retained with their closure date and reason. |
| ledger_rows | 9,296,789 | live_listings + withheld_listings + closed_listings. It reconciles exactly. |
| employers | 189,008 | Distinct employers with at least one retrievable row. |
| countries | 249 | Distinct ISO country codes on live rows. |
| posted_last_7d | 195,027 | Live rows posted in the last seven days. |
| stale | false | true means the snapshot is older than its refresh window. |
| age_seconds | 8,522 | Seconds since measured_at. |

Reconciliation: `3,404,034 + 227,783 + 5,664,972 = 9,296,789`, and `ledger_rows` is `9,296,789` — they match exactly.

_Measured 11 September 2026, 01: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 coverage report](./ledger-coverage.md#coverage-report)
- [Per-country coverage](./ledger-coverage.md#countries-list)
- [/public/stats and why it looks like it disagrees](./ledger-coverage.md#stats-vs-coverage)

<a id="public-misc"></a>

## 4. The spec, the AI brief, plans and status

Four small endpoints that describe the product rather than the data, all keyless and all safe to fetch on start-up.

### 4.1 In depth

- **`/public/openapi.json` and `/public/openapi.yaml`** — The machine contract, **rendered by the running API** rather than checked in beside it — so its coverage figures are current and it describes the deployment you are talking to. Also served from the website host at `/openapi.json`, which is the URL people paste.
- **`/public/ai-prompt`** — An integration brief written for a model, served from the API host with no website involved. Useful when a program has the API host and nothing else. The fuller version is [/docs/ai/BUILD-A-SITE.md](./BUILD-A-SITE.md).
- **`/public/plans`** — The purchasable price list: price, request limits, record allowance, and the `delta_feed` / `bulk_export` / `single_country` entitlements. Only active plans appear, so if a plan is not here you cannot buy it.
- **`/public/status`** — Service status, as rendered on [/status](https://jobopportunitiesapi.org/status).

### 4.2 Exact contract

> **The spec never ships with placeholders in it** — It carries `{{PLACEHOLDER}}` tokens that are filled from the ledger when it is served. If the figures cannot be read, the endpoint returns 503 rather than the raw template — because a 503 is not cached and a template with visible braces, cached for an hour, is served to every evaluator who arrives in that hour. This documentation applies the same rule to itself.

| plan | display_name | price | records_per_month | req_per_day | req_per_min | delta_feed | bulk_export | single_country |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| explore | Explore | Free | 1,000 | 5,000 | 30 | false | false | false |
| growth | Growth | €80/month | 60,000 | 100,000 | 120 | true | false | false |
| signal | Signal | €299/month | 400,000 | 400,000 | 300 | true | true | true |
| scale | Scale | €899/month | 2,000,000 | 1,000,000 | 600 | true | true | false |

_Generated from `GET https://api.jobopportunitiesapi.org/public/plans` (no key required). Only purchasable plans appear there._

### 4.3 Worked examples

Everything a program needs to orient itself, keylessly

```bash
A=https://api.jobopportunitiesapi.org
curl -s "$A/public/openapi.json" | jq '{version: .info.version, paths: (.paths|keys|length)}'
curl -s "$A/public/plans"        | jq -r '.data[] | "\(.plan)\t\(.price_cents/100)EUR\t\(.records_per_month) records"'
curl -s "$A/public/coverage"     | jq '{live_listings, measured_at, stale}'
```

**See also**

- [What is in the mirror](./for-agents.md#agents-files)
- [The plans](./account-plans.md#plans-table)
- [Status — is the data current?](./account-billing.md#status-page)

---

## Where to go next

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

- [Listings](./endpoints-jobs.md) — The four endpoints that return job rows: the live list, one listing, the closure list, and the cheap id-only closure feed.
- [Companies](./endpoints-companies.md) — The employer directory, the single-company endpoint, and an honest account of why company slugs are not yet stable.
- [The delta feed](./endpoints-changes.md) — One ordered stream of everything that changed — created, updated, withdrawn and delisted — so you never have to re-read the ledger to find out what moved.
- [Bulk export](./endpoints-export.md) — The whole corpus as a stream of NDJSON, resumable to the exact row, with one failure mode you must handle: the error can arrive as the last line of a 200.
- [Key and metadata](./endpoints-meta.md) — Four endpoints that describe the API rather than return rows: your key, the filter vocabularies, the freshness report and the provider list.

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
