# Job Opportunities API — machine-readable documentation

Every page of https://jobopportunitiesapi.org/docs, as plain Markdown, served as `text/markdown; charset=utf-8` with no JavaScript and no redirects. Each file is independently useful: it opens by saying what it covers and what it assumes, and it ends with the files most likely needed next.

**API base:** `https://api.jobopportunitiesapi.org`  
**OpenAPI:** `https://jobopportunitiesapi.org/openapi.json` · `https://jobopportunitiesapi.org/openapi.yaml` (keyless)  
**Keyless data:** `https://api.jobopportunitiesapi.org/public/*` — real rows, no account, see [api-keyless.md](./api-keyless.md)  
**Get a key:** https://jobopportunitiesapi.org/login (passwordless; the free plan needs no card)  
**Built:** 12 September 2026, 02:34 UTC

If you are an agent and you are going to read exactly one more file, read [BUILD-A-SITE.md](./BUILD-A-SITE.md). It is self-contained.

---

## Start here

_First call, conventions, and how to read this site._

### [Overview](./overview.md)

What the Job Opportunities API is, what it deliberately is not, and the ten minutes of reading that will save you the most time.

- **What the Job Opportunities API is** — A ledger of job openings taken from employers' own career pages, their applicant tracking systems, and government feeds — readable free, licensed as an API.
- **What it is not** — Not an aggregator, not a job board, not a salary benchmark, and not the largest catalogue available. Each of those is a deliberate loss.
- **Every endpoint, in one table** — Twelve keyed endpoints under /v1 and ten keyless ones under /public. This table is generated from the live specification, so it is never behind the API.
- **What to read, in what order** — Developers should read the API group first. Buyers should read the ledger group first. Programs should fetch /docs/ai/BUILD-A-SITE.md and nothing else.

### [Quickstart](./quickstart.md)

From nothing to a real response in one command, and to an authenticated one in about a minute. No card at any point.

- **Step one — a real response, with no account** — Everything under /public/* answers without a key. This is real data, not a sandbox: the same rows the paid endpoints return.
- **Step two — get a key** — Sign in with your email at /login, click through the link it sends, and create a free key on the dashboard. No password, no card.
- **Step three — your first authenticated call** — Send the key as a bearer token to api.jobopportunitiesapi.org. Everything under /v1 uses exactly this header.
- **Step four — the three things to get right before you ship** — Treat next_cursor as opaque, read field_sources before trusting a field, and handle 402 differently from 429. Everything else is detail.

### [If you are a program](./for-agents.md)

This documentation has a plain-Markdown mirror with no JavaScript, no redirects and no browser required. Here is where it is and what is in it.

- **The machine-readable mirror** — Every page here also exists as Markdown under /docs/ai/, served as text/markdown, rendered from the same source as the HTML so the two cannot drift.
- **What is in the mirror** — One file per documentation page, plus an index, a paste-whole build brief, and a dated changelog so you can tell how old your copy is.
- **The rules that matter most to a program** — Seven behaviours that separate an integration that works from one that looks like it works. Each is stated once, here, in the imperative.
- **The paste-whole brief** — BUILD-A-SITE.md is the canonical integration brief. If you are being asked to build something against this API, that file is the whole answer.

### [How to read this site](./conventions.md)

Three tiers on every concept, a copyable link on every section, and a rule about numbers that explains why almost nothing here is typed by hand.

- **The three tiers** — Every documented concept has a one-line summary that is always visible, and two expandable tiers under it: the prose, then the exact contract.
- **Linking to one section** — Every section has a § button that copies its permanent link. There is also a shorthand: /docs?s=<section-id> resolves to whichever page owns that id.
- **Why almost no number on this site is typed by hand** — Every figure here is rendered from the endpoint that owns it, printed with the instant it was measured, next to a link to fetch it yourself.
- **Telling us a page is wrong** — Every page has a thumbs-up and a thumbs-down at the foot. A thumbs-down opens a box, and what you write is read by the person who maintains these pages.

## The ledger

_What the data is, where it comes from, and what it is not._

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

- **“Employer-direct” — what it means and what it excludes** — A row is employer-direct when it came from the employer's own careers page, their applicant tracking system, or a government employment feed — and applies at their URL.
- **A ledger, not a feed** — Rows are not deleted when a role closes. They are marked closed, with a date and a reason, and stay queryable — so the data answers questions about time.
- **Who this suits, and who it does not** — It suits anyone who needs to say where a row came from. It does not suit anyone who needs the largest possible catalogue.

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

- **The three populations** — Every row in the ledger is exactly one of live, withheld or closed. Add them up and you get ledger_rows, with nothing left over.
- **Withheld rows — held, not served** — Rows we keep but do not serve, for three reasons: a broken employer-direct promise, reversible doubt, or a verified employer opt-out.
- **Closed roles** — When a role leaves its source it is marked closed with a date and a reason, and stays queryable forever. Closures are kept, not deleted.
- **Checking the equation yourself** — One keyless request returns all four figures. Add the first three and compare with the fourth — if they ever disagree, that is a defect worth reporting.
- **Identity: ids, slugs and domains** — A listing's uuid is permanent. Its slug is a readable alias. A company's slug is readable but is not currently guaranteed stable — key on the domain where you can.

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

- **field_sources — per-field provenance** — Every job row carries a field_sources object marking each field published (the source said so), inferred (we derived it) or absent (there is none).
- **require_fields — the honest subset** — Ask for only the rows where every named field was published by the source. This is the answer to “only a small share of your rows have a salary”.
- **Why require_fields=category is an error** — category and seniority are inferred by construction, so no row can ever satisfy them. The API says so with a 422 instead of returning an empty page.
- **Salary, specifically** — Published when the source stated it in a field, inferred when we read a real figure out of the advert text. Never a model's estimate, under any parameter.

### [Sourcing and refusals](./ledger-sourcing.md)

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

- **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.
- **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.
- **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.
- **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.
- **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.

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

- **The coverage report** — One keyless endpoint returns the whole picture: the three populations, per-field completeness, the source breakdown, and the sources we refuse.
- **Per-field completeness** — How many live rows carry each field, as a count and a share, with a sentence saying what the gap is rather than leaving you to guess.
- **Per-country coverage** — How much data sits behind each country code: live rows, how many carry a description, how many carry a published salary, and how many employers.
- **Freshness — measured_at, stale, and the refresh cycle** — The ledger is refreshed on a schedule, not on a webhook. Every report says when it was measured and whether that is older than it should be.
- **/public/stats and why it looks like it disagrees** — It does not disagree — it publishes a broader live_listings that includes gated rows, and names the narrower one employer_direct_live. Coverage is canonical.

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

- **Removing your site** — Go to /optout, enter your domain and an email address at that domain, and return the six-digit code. The removal takes effect immediately.
- **How it propagates** — Opted-out rows leave every endpoint at once — live, closed and the change feeds — and no parameter re-admits them.
- **Corrections and takedowns for a single listing** — One wrong row does not need a domain-wide opt-out. Write to us with the listing URL and what is wrong with it.

## API

_Hosts, auth, limits, paging, errors, and every parameter._

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

- **Two hosts, and which one to send requests to** — Send API requests to api.jobopportunitiesapi.org. The website host is edge-protected and may answer a non-browser client with a challenge that reads as a 403.
- **Versioning and how changes are made** — The version is in the path: /v1. Fields are added, never silently repurposed, and breaking changes would take a new path rather than a flag.
- **CORS and calling from a browser** — Access-Control-Allow-Origin is * on the API, so client-side JavaScript can call it directly — but a key in browser JavaScript is a published key.
- **Caching, and what the response headers tell you** — Keyless endpoints are edge-cached for five to sixty minutes. Keyed endpoints are not cached at all. cf-cache-status says which you got.
- **HTTP conventions** — GET only, JSON in, JSON out, UTF-8 throughout, RFC3339 timestamps in UTC, and unknown query parameters are ignored rather than refused.

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

- **The header** — Authorization: Bearer <your key>, on every /v1 request. Nothing at all on /public — a key there is neither required nor useful.
- **Three accepted spellings, and why** — Bearer <key>, bearer <key> and the bare <key> with no scheme are all accepted. Bearer is canonical; the tolerance exists for integration platforms.
- **What a rejected key looks like** — Missing, unknown, revoked and expired keys all return the same 401 with the same body. The API will not tell a prober which kind of wrong they have.
- **Creating, naming, rotating and revoking** — Keys are managed on the dashboard. Rotating replaces the secret and keeps everything else; revoking is permanent and the row is kept for audit.

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

- **What you get without an account** — Every read shape the API has, against the live ledger, with no key: rows, companies, facets, coverage, providers, freshness, plans and the spec.
- **The limits, exactly** — About two requests a second sustained, a burst of ten, forty a minute, fifty rows a page, and a ten-minute block if you go through all of that.
- **When keyless stops being enough** — The moment you need a second page, a change feed, or more than about forty requests a minute. The free key removes the first and third of those.

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

- **The per-plan limits** — Every plan has a requests-per-minute and a requests-per-day ceiling. Both are on the key, and this table comes from the live price list.
- **The response headers** — Every keyed response carries where you stand on both the request limit and the record meter, so nobody discovers an allowance by being cut off.
- **Backing off correctly** — Respect Retry-After. Retry 429 and 503; do not retry 402, 401, 403 or 422. Jitter your retries so a fleet does not synchronise.
- **Requests versus records** — The request limits protect the service. The record allowance is the licence. They are counted separately and they run out separately.

### [Pagination](./api-pagination.md)

Keyset cursors, not offsets. One rule matters more than the rest: hand next_cursor back exactly as you received it.

- **How paging works** — Every listing response carries next_cursor and has_more. Send the cursor back as ?cursor= to get the next page, and stop when has_more is false.
- **next_cursor is opaque — this is the rule that bites** — It looks like a timestamp and a uuid joined by a pipe. It is not a structure you may read. Pass it back exactly as received, and nothing else.
- **The one endpoint with offsets** — /v1/companies accepts offset as well as cursor, capped at 100,000. Beyond that it is a 422 rather than a silently clamped page.
- **Resuming an interrupted pull** — For a listing pull, store the last next_cursor. For an export, store the last id. For a sync, store next_since. Three different tokens, three different endpoints.

### [Errors](./api-errors.md)

Every status the API emits, its JSON shape, whether it is worth retrying, and what to do about it. Two of them are routinely confused and it is expensive.

- **The shape of an error** — Always JSON, always three fields: a stable error code to branch on, a human message, and a docs link.
- **Every status code** — Nine statuses, each with one meaning. The generated table below comes from the specification; the notes underneath are the parts a spec cannot carry.
- **402 — the licence, not the throttle** — Your plan's monthly record allowance is spent. Retrying will not help until the first of the month. This is the error most often handled wrongly.
- **422 — a value we will not guess at** — An unknown provider or source_type, an unparseable timestamp, a malformed cursor, or a filter that can never match. The body names the value.
- **A retry policy that is correct** — Retry 429 and 503, honouring Retry-After with jitter. Never retry 402, 401, 403, 422 or 500. Cap total attempts.

### [Parameters](./api-parameters.md)

Every query parameter the listing endpoints take, grouped by what it does, generated from the live specification so it cannot drift.

- **How to read these tables** — Name, type, default, allowed values, and whether it takes a comma-separated list. Everything applies to /v1/jobs unless a section says otherwise.
- **Paging** — limit sets the page size; cursor continues from the previous page. Both are documented in full under Pagination.
- **Place — country, city, state** — Three location filters of decreasing coverage. Each is absent wherever the source did not give us enough to fill it with confidence.
- **Country codes** — The country parameter lets you search for a job in a specific country, so you see only openings in that place, using ISO codes.
- **Classification — category, seniority, employment type, remote** — Four filters over what kind of job it is. Two of them are always our reading rather than the employer's statement, and the rows say so.
- **Provenance and serving — source, provider, quality, poster type, status** — Filters over where a row came from and whether it is served by default. This is where the editorial policy is exposed as parameters.
- **Company — slug and domain** — Filter by company slug, or by bare domain. The domain is the join key you already have in a CRM, and it is the stabler of the two.
- **Text — q, title, description_contains** — Three full-text filters over different parts of a row. q deliberately does not search the advert body; description_contains is the one that does.
- **Salary** — has_salary selects rows that have one; min_salary and max_salary bound a normalised annual EUR figure that only a small share of rows carry.
- **Time — posted_after and verified_after** — Two different questions: what was posted since a date, and what was re-confirmed at its source since a date.
- **Controlled vocabularies — never hard-code these** — Providers, categories, source types and country codes are enumerable at run time. A value that has left the list is a 422, not an empty page.
- **The complete table** — Every parameter /v1/jobs accepts, in the order the specification lists them, with nothing omitted.

### [Fields](./api-fields.md)

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.

- **The job row** — Identity, employer, classification, place, salary, timestamps, provenance and the apply link. Generated from the specification.
- **field_sources** — Nine fields, each marked published, inferred or absent. Present on every job row, always, with all nine keys.
- **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.
- **The company row** — Identity, website, sectors, and two different counts of open roles that mean different things.
- **Nullability, in one table** — Which fields are guaranteed, which are omitted when empty, which are explicitly null, and which appear only on request.

### [Filtering cookbook](./api-filtering.md)

How the filters combine, how to exclude rather than include, which text filter to reach for, and which queries are expensive enough to time out.

- **Twenty questions, and the query for each** — The queries people actually write, as copyable requests. Every one of them is sent against the live API before this site is allowed to build.
- **How filters combine** — Different parameters AND together. A comma-separated list ORs within one parameter. There is no way to OR across two different parameters.
- **Excluding rather than including** — Five parameters exclude: category, country, source_type, provider and company_domain. There is no general negation.
- **Which text filter to use** — q for a quick search over title, company and location. title for titles only. description_contains for the advert body — and it is the expensive one.
- **Queries that can time out, and how to make them fast** — Two full-text filters together across the whole ledger is the most expensive request this API takes. Narrow it by country or category first.

## Endpoints

_Every route, with its full contract and worked calls._

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

- **GET /v1/jobs** — The main endpoint. Live listings, newest first, keyset-paged, with about thirty-five filters.
- **GET /v1/jobs/{id}** — One listing, with its description. Takes either the uuid or the public slug.
- **GET /v1/jobs/closed** — Roles that have left their source, most recently closed first, with every filter /v1/jobs takes.
- **GET /v1/jobs/expired** — Ids, closure dates and reasons only — the cheap half of the closure feed, and the right way to keep a mirror honest.

### [Companies](./endpoints-companies.md)

The employer directory, the single-company endpoint, and an honest account of why company slugs are not yet stable.

- **GET /v1/companies** — Employers whose live roles this API returns. Every company here has at least one row you can actually fetch.
- **GET /v1/companies/{slug}** — One employer, by slug. Returns the same object the directory returns, for the one row.
- **Company slugs are not yet guaranteed stable** — A company's slug can change between refreshes when duplicate rows compete for the same bare name. Key on company_domain where you have one.

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

- **GET /v1/changes** — Poll this instead of re-reading the ledger. Pass since on the first call and the returned next_since from then on.
- **The four change kinds — and the one that breaks integrations** — created, updated, delisted and withdrawn. The last is newer than most integrations and ignoring it means serving rows we have retracted.
- **The sync loop, written correctly** — Store next_since, poll on your own schedule, upsert on created and updated, retract on delisted and withdrawn, and default conservatively.

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

- **GET /v1/export** — One JSON object per line, not a JSON array, so the stream can be processed as it arrives and a dropped connection costs one line rather than the document.
- **Resuming an interrupted export** — Pass the id of the last row you received as after. Rows are ordered by id, so the stream continues exactly where it stopped.
- **The failure mode that catches people** — If the record allowance runs out mid-stream, the last line of a 200 response is an error object with the resume cursor. Check the final line.

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

- **GET /v1/me** — Your key, its plan, its limits and today's usage. Costs no records, so it is safe to poll and safe to call on start-up.
- **GET /v1/meta/facets** — Every filter value with its live count. This is the list to enumerate at run time instead of hard-coding a vocabulary.
- **GET /v1/meta/freshness** — How recently the ledger was verified, how much of it carries each field, and how many roles closed in the last day and week.
- **GET /v1/meta/providers** — Every source the ledger is built from, with its live row count and its description coverage.

### [Keyless endpoints](./endpoints-public.md)

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

- **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.
- **/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.
- **The coverage family** — Four keyless reports with no keyed equivalent: the canonical coverage report, per country, per employer, and a convenience summary.
- **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.

## Recipes

_Runnable programs for the things people actually build._

### [Client code](./recipes-languages.md)

A working client in curl, Python, JavaScript and Go. No SDK, no dependencies, and every request re-sent against the live API before this page ships.

- **curl** — The shortest path to a response, and the form to reach for when something is behaving strangely and you want to see the raw bytes.
- **Python** — A paging client in the standard library only — no requests, no httpx, nothing to install. About forty lines.
- **JavaScript and TypeScript** — fetch, an async generator for paging, and a reminder that a key belongs on your server rather than in a browser bundle.
- **Go** — A struct that matches the row, a paging function, and the retry policy from the errors page.

### [Keeping data current](./recipes-sync.md)

Four jobs you will end up writing: a daily country pull, an incremental sync, closure detection, and a bulk export — with the record cost of each.

- **A daily pull for one country** — The simplest useful job: fetch everything in a market once a day, resumably, and write it somewhere.
- **An incremental sync** — Seed once, then poll /v1/changes. You pay for rows that moved, not for rows you look at, and you cannot miss a row to a shared timestamp.
- **Detecting closures cheaply** — /v1/jobs/expired returns ids, dates and reasons only — about ninety per cent smaller than the same page of full rows.
- **A bulk export, done properly** — Stream NDJSON, resume with after, and check the last line — because the error can arrive inside a 200.

### [Things to build](./recipes-build.md)

Three complete builds — a job board, an employer watcher, a CRM enrichment job — and an honest list of which integrations exist today.

- **A job board** — A niche board is a filtered listing, a detail page and an apply link. The keyless surface is enough to start; a key is what lets it page.
- **Watching an employer** — Key on the domain, poll for that company's rows, and diff against what you saw last time. Closures matter as much as openings.
- **Hiring signal for a CRM** — You already have company domains. Ask which of them are hiring, for what, and where — in one request per batch.
- **Integrations that exist, and ones that do not** — OpenAPI and Postman work today, from the published spec. Everything else is a normal HTTP call in whichever tool you use.

## Account & billing

_Plans, the record meter, sign-in, invoices._

### [Plans](./account-plans.md)

What each plan includes, rendered from the live price list, and what actually distinguishes them — which is records and entitlements, not features.

- **The plans** — Every plan gets every REST endpoint. What changes up the ladder is the monthly record allowance, the request ceilings, and two entitlements.
- **The two entitlements** — delta_feed and bulk_export are the only endpoints a plan can lack. Missing one is a 403 that names it in a header.
- **Choosing one** — Estimate records, not requests: rows you will receive per month, including every re-read. That number picks the plan.
- **Grant keys and the free tier** — The free Explore plan needs no card and does not expire. There is also a grant key for research and non-commercial work, by application.

### [The record meter](./account-record-meter.md)

The licence is on rows delivered, not on requests made. This is the most commonly misunderstood mechanic here, and it changes how you write your client.

- **What counts as a record** — One row delivered to you, on any endpoint that returns rows. Metadata endpoints and /v1/me cost nothing.
- **Records versus requests** — Two independent counters. The request limits stop you overwhelming the service; the record allowance is what the plan actually sells.
- **Running out** — A 402 with record_quota_exhausted. It resets on the first of the month, and retrying before then will not help.
- **The country-locked plan** — One country, no record limit on job rows. The restriction is what pays for the exemption, and the two are inseparable.

### [Sign-in and the dashboard](./account-sign-in.md)

Passwordless sign-in, every control on the account page, and what rotating or cancelling a key actually does.

- **Signing in** — Enter an email at /login, follow the link it sends. There is no password to choose, forget or leak.
- **Every control on the dashboard** — One card per key, showing plan, status, dates, records used, request limits and entitlements — with rotate and cancel on the card itself.
- **Rotating a key** — Issues a new secret for the same key. Plan, meter, usage history and entitlements all stay attached — it is not the same as creating a new key.
- **Cancelling** — One control, on the key's own card. No email, no notice period — access continues to the end of the period you paid for.

### [Billing, invoices and status](./account-billing.md)

What happens between clicking buy and the key switching on, where the invoice comes from, and how to tell whether the data behind the API is current.

- **Checkout** — Pick a plan, enter your details, pay by card. The key is created immediately in a pending state and switches on when the payment confirms.
- **Invoices and credit notes** — An invoice is issued for every completed order and reached by an unguessable link, so it works from an email with no sign-in.
- **Fiscal transmission (Greece, myDATA)** — Invoices are transmitted to the Greek tax authority's myDATA platform. It affects what an invoice must contain, not what you have to do.
- **Status — is the data current?** — /status answers whether the ledger is current, measured from the ledger itself, not whether the website replied to a ping.

## The website

_Every page, every control, and what each one does._

### [The ledger browser](./website-ledger.md)

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.

- **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.
- **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.
- **A job page** — One listing: a labelled table of every field, the advert text, and an Apply button that goes to the employer.
- **Companies and a company page** — A searchable directory of employers, and a page per employer listing their open roles.

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

- **Navigation — the header and the footer** — Five links in the header plus an account slot; four columns in the footer covering everything the header cannot.
- **Every route on the site** — Twenty-odd public pages, four account pages, an admin console, and the machine-readable files. This is all of them.
- **Every form, and where it posts** — Six public forms. Each posts to one of the site's own JSON routes, which forwards to the API; most are protected by a bot check.
- **The account surfaces** — Four routes: sign in, verify, dashboard, and the register page that explains the options before you commit to one.

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

- **The legal pages** — Four pages, each answering a different question, all linked from the footer of every page on the site.
- **What this site stores in your browser** — A signed session cookie when you are signed in, a record of your consent choice, and analytics only if you allow them.
- **The AI disclosure** — Two fields are always machine-generated, three are sometimes, and every row says which. No candidate or customer data is ever sent to a model.
- **The crawler policy** — Search engines get the whole ledger. Answer engines get the pages that describe the product but not the two million rows that are the product.

---

## Every file in this directory

| file | covers |
| --- | --- |
| index.md | this file |
| BUILD-A-SITE.md | the paste-whole integration brief: everything needed to build against JOA, in one file |
| CHANGELOG.md | dated changes to this documentation, so you can tell how fresh your copy is |
| overview.md | What the Job Opportunities API is, what it deliberately is not, and the ten minutes of reading that will save you the most time. |
| quickstart.md | From nothing to a real response in one command, and to an authenticated one in about a minute. No card at any point. |
| for-agents.md | This documentation has a plain-Markdown mirror with no JavaScript, no redirects and no browser required. Here is where it is and what is in it. |
| conventions.md | Three tiers on every concept, a copyable link on every section, and a rule about numbers that explains why almost nothing here is typed by hand. |
| 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. |
| 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. |
| 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. |
| ledger-sourcing.md | The three source classes, the providers inside each, the redistribution flag that gates them, and the sources we fetch for ourselves and never republish. |
| 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. |
| 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. |
| 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. |
| api-authentication.md | One header, three accepted spellings, and a deliberate refusal to tell an attacker which kind of wrong a wrong key is. |
| 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. |
| 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. |
| api-pagination.md | Keyset cursors, not offsets. One rule matters more than the rest: hand next_cursor back exactly as you received it. |
| api-errors.md | Every status the API emits, its JSON shape, whether it is worth retrying, and what to do about it. Two of them are routinely confused and it is expensive. |
| api-parameters.md | Every query parameter the listing endpoints take, grouped by what it does, generated from the live specification so it cannot drift. |
| api-fields.md | 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. |
| api-filtering.md | How the filters combine, how to exclude rather than include, which text filter to reach for, and which queries are expensive enough to time out. |
| 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. |
| endpoints-companies.md | The employer directory, the single-company endpoint, and an honest account of why company slugs are not yet stable. |
| 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. |
| 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. |
| 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. |
| endpoints-public.md | Everything under /public/*, what each returns, and which of them have no keyed equivalent at all. |
| recipes-languages.md | A working client in curl, Python, JavaScript and Go. No SDK, no dependencies, and every request re-sent against the live API before this page ships. |
| recipes-sync.md | Four jobs you will end up writing: a daily country pull, an incremental sync, closure detection, and a bulk export — with the record cost of each. |
| recipes-build.md | Three complete builds — a job board, an employer watcher, a CRM enrichment job — and an honest list of which integrations exist today. |
| account-plans.md | What each plan includes, rendered from the live price list, and what actually distinguishes them — which is records and entitlements, not features. |
| account-record-meter.md | The licence is on rows delivered, not on requests made. This is the most commonly misunderstood mechanic here, and it changes how you write your client. |
| account-sign-in.md | Passwordless sign-in, every control on the account page, and what rotating or cancelling a key actually does. |
| account-billing.md | What happens between clicking buy and the key switching on, where the invoice comes from, and how to tell whether the data behind the API is current. |
| website-ledger.md | 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. |
| 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. |
| 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. |

_Every file is also reachable as HTML at `https://jobopportunitiesapi.org/docs/<same path without .md>`._
