# Listings

The four endpoints that return job rows: the live list, one listing, the closure list, and the cheap id-only closure feed.

**What this covers:** GET /v1/jobs; GET /v1/jobs/{id}; GET /v1/jobs/closed; GET /v1/jobs/expired.

**Assumed knowledge:** [Authentication](./api-authentication.md), [pagination](./api-pagination.md).

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

## 1. GET /v1/jobs

The main endpoint. Live listings, newest first, keyset-paged, with about thirty-five filters.

### 1.1 In depth

Sorted by `posted_at DESC NULLS LAST, id DESC`. The `id` component makes the ordering total, so rows sharing a posting date cannot swap places between two requests and be counted twice or skipped. Rows with no `posted_at` sort last rather than being excluded.

Offsets are not offered here. At this scale rows shift constantly and an offset double-counts when they do; the cursor asks for “rows after this one”, which stays correct however much moved. [Pagination in full](./api-pagination.md#cursor-basics).

The keyless mirror is `/public/jobs`, which returns the same rows with a 50-row cap, no cursor, and the paid-only parameters ignored. It is the right thing to point a browser at.

### 1.2 Exact contract

| parameter | type | required | default | allowed values | range | comma-separated | description |
| --- | --- | --- | --- | --- | --- | --- | --- |
| limit | integer | no | 25 | — | 1–200 | no | — |
| cursor | string | no | — | — | — | no | The next_cursor from the previous page. |
| status | string | no | live | live, closed, any | — | no | `live` (default) returns open vacancies. `closed` returns roles that have left their source. `any` returns both; every row carries `status`. Paid endpoints only. |
| include_poster_type | string | no | — | staffing, jobboard, all | — | yes | Re-admits vacancies whose poster is a staffing agency or a job board. Excluded by DEFAULT: the posting is real and the apply link is the poster's own, but the poster is not the employer, and one agency with 18,000 listings can flood a category until search stops being useful. Comma-separated. Paid endpoints only. An unrecognised value is a 422. |
| quality | string | no | — | all | — | no | `all` re-admits rows we have GATED -- reversible doubt, such as a future `posted_at` or a missing apply URL. It never re-admits rows we have REMOVED: those breach the employer-direct guarantee and no query parameter may opt back into it. Each returned row carries its verdict, its rule id and the matched host. Paid endpoints only. |
| category | string | no | — | — | — | yes | Comma-separated. uncategorised selects rows with no confident classification. |
| country | string | no | — | — | — | yes | Comma-separated ISO-3166 alpha-2. |
| city | string | no | — | — | — | no | — |
| state | string | no | — | — | — | yes | Comma-separated two-letter US state codes, e.g. `OH` or `OH,TX`. Absent where we could not establish the state from the source; deliberately absent for ambiguous city names, so this filter under-reports rather than placing a job in the wrong state. |
| remote | string | no | — | — | — | no | `remote`, `hybrid`, `on_site`, or `not_stated`. |
| employment_type | string | no | — | — | — | yes | Comma-separated; `not_stated` selects rows with none. |
| seniority | string | no | — | — | — | yes | Comma-separated; `not_stated` selects rows with none. |
| provider | string | no | — | — | — | yes | Comma-separated list of the exact source systems to include, e.g. `greenhouse,lever,workday`. This is the ATS or board a vacancy came from, finer than source_type which buckets them. A name we do not publish is a 422, never a silently empty page. Up to 12 values. |
| exclude_provider | string | no | — | — | — | no | Same vocabulary as `provider`, removed instead of kept. |
| source_type | string | no | — | — | — | yes | Comma-separated provenance filter. The legacy spellings `employer_ats`, `government` and `direct` are still accepted and map onto `ats`, `public_agency` and `career_site`. Filter values are matched case-insensitively: `category=engineering` and `category=Engineering` are the same query. Enumerate the legal values with /v1/meta/facets. |
| exclude_source_type | string | no | — | — | — | no | — |
| company | string | no | — | — | — | yes | Comma-separated company slugs. |
| exclude_category | string | no | — | — | — | no | — |
| exclude_country | string | no | — | — | — | no | — |
| remote_confirmed | boolean | no | — | — | — | no | `true` returns only listings whose remote status the SOURCE stated — 373,133 of 3,642,748 live rows (10.2%). Without it you also receive the 3,274,165 (89.9%) where we inferred it from the location text, the title, or the presence of a named workplace city. |
| has_salary | string | no | — | true, structured, any | — | no | `true` (and `structured`) returns only rows whose salary the SOURCE published — the meaning this parameter has always had, kept so that adding derived salaries does not change the results of a query you already ship. `any` also includes figures we read out of the advert text (`salary_source: parsed_description`, reported as `inferred`). AI estimates are never published under any value. |
| has_description | boolean | no | — | — | — | no | `true` returns only the 2,966,216 live rows (81.4%) that carry a description. |
| require_fields | string | no | — | — | — | yes | Comma-separated. Returns only rows where EVERY named field is `published` in `field_sources` — a value the source carried, never one we derived. The response then also contains a `completeness` object saying how many live rows carry each of them. This is the answer to "only 6.0% of your rows have a salary". They do — and `require_fields=salary` returns 220,169 rows of which 100% carry a figure an employer actually wrote, with no estimate anywhere in the response. Check the per-country split at `/public/coverage/countries` before you spend a record. `category` and `seniority` are refused with 422: both are read off the job title by our classifier, so they are `inferred` by construction and no row can ever satisfy them. An empty page would look like a coverage problem; the error says what it is. `source_type` is accepted and currently matches nothing — the per-row classification exists in the schema and no live row carries one yet. The `completeness` block reports that as a count rather than leaving you to infer it from an empty page. |
| include_description | boolean | no | — | — | — | no | Return the full advert text in a `description` field. Off by default: descriptions average 2,581 bytes, so a 200-row page would be a 516 KB response nobody asked for. With this on, `limit` may not exceed 50 — a larger request is refused with 422 rather than quietly clamped. |
| posted_after | string | no | — | — | — | no | Date or RFC3339 timestamp. |
| verified_after | string | no | — | — | — | no | Only rows re-confirmed at their source since this instant. |
| q | string | no | — | — | — | no | Full-text over title, company name and location — NOT the description. That is deliberate, not a limitation: use `description_contains` for the advert body. Stemming is off (`simple` dictionary), so `q=engineer` does not match `engineering`. |
| title | string | no | — | — | — | no | Full-text over the job title only, e.g. `?title=engineer`. It ANDs with every other filter, including `?description_contains=kubernetes` over the advert body — but the two full-text filters together are the most expensive query this API can be asked, because both indexes are GIN and the matching rows still have to be fetched to be ordered by posted_at. Send them together on a narrow country or category, not on the whole ledger. |
| title_exclude | string | no | — | — | — | no | Drop rows whose title matches these words. Same matching as `title`. |
| description_contains | string | no | — | — | — | no | Full-text over the advert body. Only ever matches rows that have one, so it implies `has_description=true`. |
| company_domain | string | no | — | — | — | yes | Comma-separated bare domains, e.g. `stripe.com,figma.com` — no scheme and no path. The join key you already have in a CRM. 37.2% of companies carry a domain; the rest can never match this filter. |
| exclude_company_domain | string | no | — | — | — | no | Same vocabulary as company_domain, removed instead of kept. |
| min_salary | number | no | — | — | — | no | Lower bound on `salary_min_annual_eur`. Selects ONLY rows with structured salary we could normalise — 2.0% of the ledger — so this is a narrow filter by nature, not a broken one. |
| max_salary | number | no | — | — | — | no | Upper bound on salary_min_annual_eur. Same 2.0% caveat as min_salary. |

_34 parameters for `GET /v1/jobs`, generated from `https://jobopportunitiesapi.org/openapi.json`. The spec is served by the running API and is the contract._

| code | meaning |
| --- | --- |
| 200 | A page of listings. |
| 401 | Missing, unknown, revoked or expired key. All four are reported identically. |
| 402 | The plan's monthly RECORD allowance is spent. Distinct from 429: a 429 means slow down and retry, a 402 means the licence is used up until the 1st of the month. Do not retry. Every metered response carries `X-RateLimit-Records-Remaining`, so this is visible long before it happens. |
| 422 | A parameter we will not guess at: an unknown source_type or provider, an unparseable timestamp, a malformed cursor. Note the deliberate exceptions, which are clamps rather than errors: `limit` is capped at the plan maximum (a non-numeric value falls back to the default), and comma-separated lists are truncated to the per-parameter maximum shown on each. Unknown query parameters are ignored. |
| 429 | Over your plan's per-minute or per-day limit. `Retry-After` says how long. |

**Records charged:** one per row returned. `limit=200` costs 200 records and one request. See [the record meter](./account-record-meter.md#meter-what-counts).

### 1.3 Worked examples

A first page

```console
$ curl -s -H "Authorization: Bearer $JOA_KEY" \
  'https://api.jobopportunitiesapi.org/v1/jobs?country=FR&limit=2'
{
  "data": [
    {
      "id": "3427ad3f-5f06-4653-8df4-a6dc04a999e5",
      "slug": "boulanger-h-f-3427ad3f",
      "title": "BOULANGER (H/F)",
      "company": "Groupement Mousquetaires",
      "company_slug": "groupement-mousquetaires",
      "company_logo": "https://supabase-erioun.tzekos.eu/storage/v1/object/public/company-logos/logos/groupement-mousquetaires.png",
      "category": "Hospitality",
      "category_confidence": 0.95,
      "country": "FR",
      "city": "Le Mans",
      "location": "Le Mans, Pays de la Loire, France",
      "remote": "on_site",
      "remote_inferred": false,
      "posted_at": "2026-09-10T06:14:16Z",
      "first_seen_at": "2026-09-10T06:23:26Z",
      "last_verified_at": "2026-09-10T07:23:38Z",
      "status": "live",
      "closed_at": null,
      "closed_reason": null,
      "apply_url": "https://jobs.smartrecruiters.com/GroupementMousquetaires/bc1f0277-6c56-44cb-9d24-b48559cc34f9",
      "source": "smartrecruiters",
      "source_type": "ats",
      "provider_type": "employer_ats",
      "has_description": true,
      "field_sources": {
        "remote": "published",
        "employment_type": "absent",
        "category": "inferred",
        "seniority": "absent",
        "salary": "absent",
        "location": "inferred",
        "posted_at": "published",
        "description": "published",
        "source_type": "inferred"
      }
    },
    {
… 42 more lines
```

_The response shown comes from the keyless mirror of the same query, because this page is built without a key. The rows are identical. Real response, fetched from `/public/jobs?country=FR&limit=2` when this file was built (10 September 2026, 07:29 UTC)._

Walk every page of a filtered slice

```bash
A=https://api.jobopportunitiesapi.org
H="Authorization: Bearer $JOA_KEY"
cursor=''
while : ; do
  page=$(curl -s -H "$H" --get "$A/v1/jobs" \
          --data-urlencode 'country=IE' \
          --data-urlencode 'limit=200' \
          ${cursor:+--data-urlencode "cursor=$cursor"})
  echo "$page" | jq -c '.data[] | {id, title, company}'
  [ "$(echo "$page" | jq -r '.has_more')" = 'true' ] || break
  cursor=$(echo "$page" | jq -r '.next_cursor')
done
```

**See also**

- [The complete table](./api-parameters.md#params-jobs-full)
- [next_cursor is opaque — this is the rule that bites](./api-pagination.md#cursor-opacity)
- [GET /v1/jobs/{id}](./endpoints-jobs.md#endpoint-job-by-id)

<a id="endpoint-job-by-id"></a>

## 2. GET /v1/jobs/{id}

One listing, with its description. Takes either the uuid or the public slug.

### 2.1 In depth

Unlike the list endpoint, this one includes the advert body by default — you are asking for one row, so the size argument does not apply. It costs one record.

A closed listing returns 404 unless you pass `include_closed=true`, in which case it comes back with `status: "closed"` and its `closed_at` and `closed_reason` populated. That default is the safe one: a mirror fetching a row it already knows about should not silently start serving a dead vacancy.

### 2.2 Exact contract

| parameter | type | required | default | allowed values | range | comma-separated | description |
| --- | --- | --- | --- | --- | --- | --- | --- |
| id | string | yes | — | — | — | no | The uuid or the public slug. |
| include_closed | boolean | no | — | — | — | no | Return the row even if it has been delisted. It carries `status="closed"`. |

_2 parameters for `GET /v1/jobs/{id}`, generated from `https://jobopportunitiesapi.org/openapi.json`. The spec is served by the running API and is the contract._

| code | meaning |
| --- | --- |
| 200 | The listing. |
| 401 | Missing, unknown, revoked or expired key. All four are reported identically. |
| 402 | The plan's monthly RECORD allowance is spent. Distinct from 429: a 429 means slow down and retry, a 402 means the licence is used up until the 1st of the month. Do not retry. Every metered response carries `X-RateLimit-Records-Remaining`, so this is visible long before it happens. |
| 404 | No such row. |
| 429 | Over your plan's per-minute or per-day limit. `Retry-After` says how long. |

The keyless mirror is `/public/jobs/{slug}`, which always includes the description. Both accept the uuid and the slug; the uuid is the stable identity and the slug is a readable alias built from the title plus the first eight characters of the uuid.

### 2.3 Worked examples

Fetch a row by uuid, then the same row by slug

```bash
A=https://api.jobopportunitiesapi.org
slug=$(curl -s "$A/public/jobs?limit=1" | jq -r '.data[0].slug')
id=$(curl -s   "$A/public/jobs?limit=1" | jq -r '.data[0].id')
curl -s "$A/public/jobs/$slug" | jq '{title: .data.title, has_description: .data.has_description}'
curl -s "$A/public/jobs/$id"   | jq '{title: .data.title}'
```

**See also**

- [Identity: ids, slugs and domains](./ledger-data-model.md#ids-and-slugs)
- [The job row](./api-fields.md#job-fields)
- [/public/jobs and /public/jobs/{id}](./endpoints-public.md#public-jobs)

<a id="endpoint-jobs-closed"></a>

## 3. GET /v1/jobs/closed

Roles that have left their source, most recently closed first, with every filter /v1/jobs takes.

### 3.1 In depth

This is the full-row view of closures: the same job objects, with `status: "closed"`, `closed_at` and `closed_reason` populated. Use it when you want the content of what closed — for a chart of which categories a company stopped hiring for, say.

When you only need to mark rows stale in your own store, this is the expensive way to do it. `/v1/jobs/expired` returns ids, dates and reasons only, is about ninety per cent smaller, and charges the same one record per row — so it is cheaper in bandwidth and time, though not in records.

### 3.2 Exact contract

| parameter | type | required | default | allowed values | range | comma-separated | description |
| --- | --- | --- | --- | --- | --- | --- | --- |
| limit | integer | no | 25 | — | 1–200 | no | — |
| cursor | string | no | — | — | — | no | — |
| closed_after | string | no | — | — | — | no | — |
| closed_before | string | no | — | — | — | no | — |
| closed_reason | string | no | — | expired_upstream, not_seen | — | no | — |
| category | string | no | — | — | — | yes | Comma-separated. uncategorised selects rows with no confident classification. |
| country | string | no | — | — | — | yes | Comma-separated ISO-3166 alpha-2. |
| city | string | no | — | — | — | no | — |
| state | string | no | — | — | — | yes | Comma-separated two-letter US state codes, e.g. `OH` or `OH,TX`. Absent where we could not establish the state from the source; deliberately absent for ambiguous city names, so this filter under-reports rather than placing a job in the wrong state. |
| remote | string | no | — | — | — | no | `remote`, `hybrid`, `on_site`, or `not_stated`. |
| employment_type | string | no | — | — | — | yes | Comma-separated; `not_stated` selects rows with none. |
| seniority | string | no | — | — | — | yes | Comma-separated; `not_stated` selects rows with none. |
| provider | string | no | — | — | — | yes | Comma-separated list of the exact source systems to include, e.g. `greenhouse,lever,workday`. This is the ATS or board a vacancy came from, finer than source_type which buckets them. A name we do not publish is a 422, never a silently empty page. Up to 12 values. |
| exclude_provider | string | no | — | — | — | no | Same vocabulary as `provider`, removed instead of kept. |
| source_type | string | no | — | — | — | yes | Comma-separated provenance filter. The legacy spellings `employer_ats`, `government` and `direct` are still accepted and map onto `ats`, `public_agency` and `career_site`. Filter values are matched case-insensitively: `category=engineering` and `category=Engineering` are the same query. Enumerate the legal values with /v1/meta/facets. |
| exclude_source_type | string | no | — | — | — | no | — |
| company | string | no | — | — | — | yes | Comma-separated company slugs. |
| exclude_category | string | no | — | — | — | no | — |
| exclude_country | string | no | — | — | — | no | — |
| remote_confirmed | boolean | no | — | — | — | no | `true` returns only listings whose remote status the SOURCE stated — 373,133 of 3,642,748 live rows (10.2%). Without it you also receive the 3,274,165 (89.9%) where we inferred it from the location text, the title, or the presence of a named workplace city. |
| has_salary | string | no | — | true, structured, any | — | no | `true` (and `structured`) returns only rows whose salary the SOURCE published — the meaning this parameter has always had, kept so that adding derived salaries does not change the results of a query you already ship. `any` also includes figures we read out of the advert text (`salary_source: parsed_description`, reported as `inferred`). AI estimates are never published under any value. |
| has_description | boolean | no | — | — | — | no | `true` returns only the 2,966,216 live rows (81.4%) that carry a description. |
| require_fields | string | no | — | — | — | yes | Comma-separated. Returns only rows where EVERY named field is `published` in `field_sources` — a value the source carried, never one we derived. The response then also contains a `completeness` object saying how many live rows carry each of them. This is the answer to "only 6.0% of your rows have a salary". They do — and `require_fields=salary` returns 220,169 rows of which 100% carry a figure an employer actually wrote, with no estimate anywhere in the response. Check the per-country split at `/public/coverage/countries` before you spend a record. `category` and `seniority` are refused with 422: both are read off the job title by our classifier, so they are `inferred` by construction and no row can ever satisfy them. An empty page would look like a coverage problem; the error says what it is. `source_type` is accepted and currently matches nothing — the per-row classification exists in the schema and no live row carries one yet. The `completeness` block reports that as a count rather than leaving you to infer it from an empty page. |
| include_description | boolean | no | — | — | — | no | Return the full advert text in a `description` field. Off by default: descriptions average 2,581 bytes, so a 200-row page would be a 516 KB response nobody asked for. With this on, `limit` may not exceed 50 — a larger request is refused with 422 rather than quietly clamped. |
| posted_after | string | no | — | — | — | no | Date or RFC3339 timestamp. |
| verified_after | string | no | — | — | — | no | Only rows re-confirmed at their source since this instant. |
| q | string | no | — | — | — | no | Full-text over title, company name and location — NOT the description. That is deliberate, not a limitation: use `description_contains` for the advert body. Stemming is off (`simple` dictionary), so `q=engineer` does not match `engineering`. |
| title | string | no | — | — | — | no | Full-text over the job title only, e.g. `?title=engineer`. It ANDs with every other filter, including `?description_contains=kubernetes` over the advert body — but the two full-text filters together are the most expensive query this API can be asked, because both indexes are GIN and the matching rows still have to be fetched to be ordered by posted_at. Send them together on a narrow country or category, not on the whole ledger. |
| title_exclude | string | no | — | — | — | no | Drop rows whose title matches these words. Same matching as `title`. |
| description_contains | string | no | — | — | — | no | Full-text over the advert body. Only ever matches rows that have one, so it implies `has_description=true`. |
| company_domain | string | no | — | — | — | yes | Comma-separated bare domains, e.g. `stripe.com,figma.com` — no scheme and no path. The join key you already have in a CRM. 37.2% of companies carry a domain; the rest can never match this filter. |
| exclude_company_domain | string | no | — | — | — | no | Same vocabulary as company_domain, removed instead of kept. |
| min_salary | number | no | — | — | — | no | Lower bound on `salary_min_annual_eur`. Selects ONLY rows with structured salary we could normalise — 2.0% of the ledger — so this is a narrow filter by nature, not a broken one. |
| max_salary | number | no | — | — | — | no | Upper bound on salary_min_annual_eur. Same 2.0% caveat as min_salary. |

_34 parameters for `GET /v1/jobs/closed`, generated from `https://jobopportunitiesapi.org/openapi.json`. The spec is served by the running API and is the contract._

**Plan:** paid keys only. **Records charged:** one per row.

### 3.3 Worked examples

What closed in Ireland recently

```bash
curl -s -H "Authorization: Bearer $JOA_KEY" \
  'https://api.jobopportunitiesapi.org/v1/jobs/closed?country=IE&limit=5' \
  | jq -r '.data[] | "\(.closed_at)  \(.closed_reason)  \(.company) — \(.title)"'
```

**See also**

- [Closed roles](./ledger-data-model.md#closed-roles)
- [GET /v1/jobs/expired](./endpoints-jobs.md#endpoint-jobs-expired)
- [Detecting closures cheaply](./recipes-sync.md#recipe-closures)

<a id="endpoint-jobs-expired"></a>

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

### 4.1 In depth

Keyset-paged exactly like `/v1/changes`: pass `since` on the first call and the returned `next_since` from then on. `next_since` is a cursor of the same character as `next_cursor` — a timestamp and a uuid — and is subject to the same rule: hand it back verbatim, never parse it.

An empty page echoes your cursor back rather than returning null, so a polling loop keeps working once it is current. You do not need a special case for “caught up”.

### 4.2 Exact contract

| parameter | type | required | default | allowed values | range | comma-separated | description |
| --- | --- | --- | --- | --- | --- | --- | --- |
| since | string | yes | — | — | — | no | RFC3339 timestamp, or a `next_since` cursor from a previous call. |
| limit | integer | no | 1000 | — | 1–10000 | no | 1-10000, default 1000. |

_2 parameters for `GET /v1/jobs/expired`, generated from `https://jobopportunitiesapi.org/openapi.json`. The spec is served by the running API and is the contract._

The response, from a real call

```json
{
  "count": 2,
  "data": [
    { "id": "02c7da8e-188a-4e89-b754-1e0969078b0e",
      "closed_at": "2026-08-22T19:04:56.842089Z",
      "closed_reason": "not_seen" },
    { "id": "037eac30-ad91-4f43-a0d7-cd6be3bcddc9",
      "closed_at": "2026-08-22T19:04:56.842089Z",
      "closed_reason": "not_seen" }
  ],
  "next_since": "2026-08-22T19:04:56.842089Z|037eac30-ad91-4f43-a0d7-cd6be3bcddc9"
}
```

> **That next_since looks parseable. It is not.** — The pipe-separated form is an implementation detail of the keyset ordering and it will change. Store the whole string; send the whole string.

**Plan:** requires `delta_feed`. A plan without it gets a 403 with `X-JOA-Required-Feature: delta_feed`. **Records charged:** one per id returned.

### 4.3 Worked examples

Mark your own rows stale, then remember where you got to

```bash
A=https://api.jobopportunitiesapi.org
since=$(cat .joa-expired-cursor 2>/dev/null || echo '2026-08-21T00:00:00Z')
page=$(curl -s -H "Authorization: Bearer $JOA_KEY" --get "$A/v1/jobs/expired" \
        --data-urlencode "since=$since" --data-urlencode 'limit=1000')
echo "$page" | jq -r '.data[] | "\(.id)\t\(.closed_reason)"'
echo "$page" | jq -r '.next_since' > .joa-expired-cursor
```

**See also**

- [The sync loop, written correctly](./endpoints-changes.md#changes-loop)
- [Closed roles](./ledger-data-model.md#closed-roles)
- [Resuming an interrupted pull](./api-pagination.md#resumable-pulls)

---

## Where to go next

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

- [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.
- [Keyless endpoints](./endpoints-public.md) — Everything under /public/*, what each returns, and which of them have no keyed equivalent at all.

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
