# Parameters

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

**What this covers:** How to read these tables; Paging; Place — country, city, state; Country codes; Classification — category, seniority, employment type, remote; Provenance and serving — source, provider, quality, poster type, status; Company — slug and domain; Text — q, title, description_contains; Salary; Time — posted_after and verified_after; Controlled vocabularies — never hard-code these; The complete table.

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

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

---

> **Nothing on this page is typed by hand** — Every parameter table is generated from [/openapi.json](https://jobopportunitiesapi.org/openapi.json) when this page is built. The spec is rendered by the running API, so the table describes the deployment you are talking to. Where this page and the spec disagree, the spec is right and this is a bug.

---

<a id="params-how-to-read"></a>

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

### 1.1 In depth

- **comma-separated** — The parameter takes a list: `?country=DE,AT,CH`. No spaces. Each list has a maximum length and a longer list is truncated rather than refused.
- **default** — What the API uses when you omit the parameter. An omitted filter means “do not filter on this”, not “filter on the default”.
- **range** — Inclusive bounds. `limit` is clamped to the plan maximum rather than refused.
- **paid endpoints only** — The parameter is honoured on `/v1/*` and ignored on `/public/*`. It is not an error to send it keylessly; it simply does nothing.
- **Case** — Values are matched case-insensitively — `category=engineering` and `category=Engineering` are the same query. Parameter **names** are case-sensitive.
- **Combining** — Every filter ANDs with every other. There is no OR across different parameters; a comma-separated list is the OR within one.

The same parameter set is accepted by `/v1/jobs`, `/v1/jobs/closed` and `/v1/export`, with the differences noted on each endpoint's own page. `/v1/companies` has [its own smaller set](#params-company).

### 1.2 Exact contract

Unknown parameters are ignored rather than refused. That is deliberate: a newer client sending a parameter an older deployment has not heard of should degrade to a broader query, not to an error. The cost is that a typo in a parameter **name** is silent — `?catgeory=Engineering` returns everything — whereas a typo in a parameter **value** for a controlled vocabulary is a 422. Check the shape of your result set the first time you add a filter.

**See also**

- [The complete table](./api-parameters.md#params-jobs-full)
- [Controlled vocabularies — never hard-code these](./api-parameters.md#filter-vocabularies)
- [HTTP conventions](./api-overview.md#conventions-http)

<a id="params-paging"></a>

## 2. Paging

limit sets the page size; cursor continues from the previous page. Both are documented in full under Pagination.

### 2.1 In depth

`limit` costs records, not requests: a page of 200 charges 200 records against your monthly allowance and one request against your rate limit. Ask for what you will actually use — the most common avoidable expense on this API is a loop that requests 200 rows and reads the first ten.

The maximum page size is a **plan property**, not a constant. Every purchasable plan currently allows 200; the keyless surface caps at 50. A `limit` above your maximum is clamped quietly rather than refused.

> **Your page-size ceiling is published as max_page_size** — It appears on `/public/plans` for every plan, and on `/v1/me` inside `limits` for your own key:

```json
{ "limits": { "per_day": 100000, "per_minute": 600, "max_page_size": 200 } }
```

A `limit` above your ceiling is **clamped, not refused** — the request succeeds and returns fewer rows than you asked for. So it is still worth asserting on the length you actually received rather than the one you requested. (Published 2026-08-23; before that date the ceiling could only be observed by counting `data.length`.)

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

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

> **One case where limit is refused rather than clamped** — With `include_description=true`, `limit` may not exceed 50 and a larger request is a 422. Descriptions average about 2.5 KB, so a 200-row page would be a half-megabyte response — and a silently clamped page there would make a caller think they had read everything.

**See also**

- [How paging works](./api-pagination.md#cursor-basics)
- [next_cursor is opaque — this is the rule that bites](./api-pagination.md#cursor-opacity)
- [What counts as a record](./account-record-meter.md#meter-what-counts)

<a id="params-place"></a>

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

### 3.1 In depth

Location arrives as free text from most sources — “Clermont-Ferrand, Auvergne-Rhône-Alpes, France” — and is resolved into structured fields where it can be. `location` keeps the original string on every row that had one; `country`, `city` and `state` are the resolved parts.

Resolution under-reports on purpose. Where a city name is ambiguous the structured field is left empty rather than filled with a guess, because a job placed in the wrong country is worse than a job placed in no country. That is why filtering on `city` returns fewer rows than searching the free-text location would, and why the free-text is still there for you to search yourself.

### 3.2 Exact contract

| parameter | type | required | default | allowed values | range | comma-separated | description |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 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. |
| exclude_country | string | no | — | — | — | no | — |

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

There is no “country is null” filter. To work with unlocated rows, fetch without a country filter and inspect `field_sources.location` — or use `exclude_country` with the codes you do not want, which keeps the unlocated rows in the result.

**See also**

- [Country codes](./api-parameters.md#country-codes)
- [Per-country coverage](./ledger-coverage.md#countries-list)
- [The job row](./api-fields.md#job-fields)

<a id="country-codes"></a>

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

### 4.1 In depth

**ISO codes and countries.** The codes are ISO-3166 alpha-2: two letters, `FR` for France, `DE` for Germany, `US` for the United States. Uppercase by convention, though the filter is case-insensitive like every other value here. Several at once are comma-separated and mean OR: `?country=DE,AT,CH` returns roles in any of the three.

Below is every code the ledger currently carries, with how many live rows sit behind each. It is not a list of the world's countries — it is what this data holds today, including dependencies and territories with their own code, and it moves as employers post and roles close.

| country | live | described | described_pct | with_salary | employers |
| --- | --- | --- | --- | --- | --- |
| US | 1,604,087 | 1,233,867 | 76.9% | 181,415 | 104,037 |
| DE | 173,660 | 162,871 | 93.8% | 8,100 | 29,704 |
| GB | 114,651 | 100,057 | 87.3% | 6,086 | 12,017 |
| IN | 73,689 | 59,835 | 81.2% | 830 | 5,142 |
| FR | 64,257 | 60,405 | 94% | 2,874 | 4,133 |
| CA | 61,269 | 56,226 | 91.8% | 4,618 | 6,218 |
| NL | 52,441 | 48,379 | 92.3% | 4,979 | 7,279 |
| SE | 32,625 | 31,851 | 97.6% | 59 | 5,668 |
| AU | 23,543 | 21,218 | 90.1% | 291 | 3,324 |
| ES | 22,015 | 18,836 | 85.6% | 461 | 2,975 |
| BE | 17,093 | 15,070 | 88.2% | 156 | 2,392 |
| CH | 14,645 | 13,545 | 92.5% | 108 | 2,296 |
| IT | 14,302 | 13,063 | 91.3% | 317 | 1,855 |
| MX | 14,103 | 11,731 | 83.2% | 197 | 1,770 |
| HU | 13,780 | 2,989 | 21.7% | 21 | 478 |
| PL | 13,332 | 11,390 | 85.4% | 284 | 1,938 |
| BR | 12,956 | 10,116 | 78.1% | 289 | 1,560 |
| SG | 12,922 | 10,474 | 81.1% | 112 | 1,640 |
| PH | 11,833 | 10,274 | 86.8% | 287 | 1,307 |
| AT | 10,573 | 9,943 | 94% | 3,191 | 2,136 |
| AE | 10,338 | 9,270 | 89.7% | 823 | 1,175 |
| JP | 10,256 | 8,894 | 86.7% | 109 | 1,894 |
| MY | 10,036 | 8,682 | 86.5% | 528 | 1,356 |
| CN | 10,034 | 7,745 | 77.2% | 68 | 1,203 |
| HR | 9,878 | 9,629 | 97.5% | 21 | 1,212 |
| IE | 8,903 | 7,822 | 87.9% | 322 | 1,464 |
| NO | 8,551 | 7,887 | 92.2% | 154 | 1,314 |
| ZA | 7,215 | 6,677 | 92.5% | 672 | 995 |
| PT | 7,083 | 6,353 | 89.7% | 119 | 918 |
| GR | 5,830 | 5,490 | 94.2% | 59 | 673 |
| DK | 5,716 | 5,299 | 92.7% | 48 | 840 |
| ID | 5,199 | 4,547 | 87.5% | 13 | 905 |
| FI | 5,111 | 4,394 | 86% | 71 | 903 |
| TH | 4,887 | 4,088 | 83.7% | 66 | 647 |
| CO | 4,749 | 4,088 | 86.1% | 86 | 908 |
| SA | 4,641 | 4,168 | 89.8% | 28 | 616 |
| RO | 4,615 | 4,105 | 88.9% | 39 | 769 |
| NZ | 4,165 | 3,844 | 92.3% | 34 | 578 |
| HK | 3,847 | 3,079 | 80% | 183 | 758 |
| EG | 3,816 | 3,341 | 87.6% | 10 | 481 |
| TW | 3,702 | 2,915 | 78.7% | 29 | 525 |
| VN | 3,583 | 2,997 | 83.6% | 21 | 559 |
| CZ | 3,358 | 2,749 | 81.9% | 40 | 619 |
| LT | 3,093 | 2,828 | 91.4% | 91 | 261 |
| GE | 3,090 | 2,213 | 71.6% | 31 | 628 |
| AR | 3,090 | 2,653 | 85.9% | 91 | 685 |
| KR | 2,926 | 2,445 | 83.6% | 17 | 616 |
| UA | 2,856 | 2,633 | 92.2% | 34 | 371 |
| BG | 2,817 | 2,533 | 89.9% | 79 | 417 |
| PE | 2,517 | 1,913 | 76% | 10 | 325 |
| IL | 2,372 | 2,052 | 86.5% | 23 | 400 |
| CL | 2,109 | 1,766 | 83.7% | 9 | 337 |
| TR | 1,854 | 1,541 | 83.1% | 8 | 430 |
| UN | 1,805 | 1,785 | 98.9% | 533 | 373 |
| MA | 1,805 | 1,628 | 90.2% | 4 | 244 |
| LU | 1,662 | 1,460 | 87.8% | 24 | 358 |
| NG | 1,587 | 1,549 | 97.6% | 7 | 280 |
| PK | 1,512 | 1,454 | 96.2% | 46 | 258 |
| QA | 1,394 | 1,176 | 84.4% | 14 | 167 |
| CR | 1,196 | 904 | 75.6% | 8 | 238 |
| RS | 1,194 | 1,112 | 93.1% | 13 | 345 |
| MT | 1,181 | 1,112 | 94.2% | 38 | 233 |
| CY | 1,158 | 1,110 | 95.9% | 30 | 247 |
| SK | 1,145 | 946 | 82.6% | 27 | 216 |
| EE | 935 | 894 | 95.6% | 17 | 201 |
| LK | 921 | 857 | 93.1% | 4 | 103 |
| GT | 848 | 744 | 87.7% | 16 | 108 |
| LV | 773 | 715 | 92.5% | 35 | 171 |
| KE | 712 | 655 | 92% | 7 | 212 |
| EC | 689 | 616 | 89.4% | 5 | 91 |
| JO | 661 | 515 | 77.9% | 0 | 129 |
| TN | 636 | 542 | 85.2% | 5 | 117 |
| PA | 577 | 472 | 81.8% | 3 | 143 |
| KZ | 546 | 499 | 91.4% | 10 | 129 |
| LB | 457 | 360 | 78.8% | 5 | 144 |
| DO | 452 | 396 | 87.6% | 1 | 106 |
| SI | 417 | 341 | 81.8% | 6 | 114 |
| KW | 411 | 377 | 91.7% | 0 | 87 |
| AZ | 405 | 388 | 95.8% | 178 | 235 |
| PR | 394 | 274 | 69.5% | 8 | 75 |
| MV | 362 | 342 | 94.5% | 0 | 17 |
| UY | 359 | 300 | 83.6% | 0 | 107 |
| BD | 357 | 324 | 90.8% | 5 | 129 |
| LI | 356 | 354 | 99.4% | 0 | 36 |
| BH | 336 | 275 | 81.8% | 3 | 88 |
| MU | 325 | 322 | 99.1% | 0 | 38 |
| RU | 306 | 297 | 97.1% | 3 | 93 |
| OM | 297 | 222 | 74.7% | 1 | 81 |
| SV | 283 | 265 | 93.6% | 78 | 66 |
| AM | 281 | 258 | 91.8% | 1 | 78 |
| TZ | 274 | 271 | 98.9% | 3 | 41 |
| AL | 259 | 241 | 93.1% | 37 | 104 |
| HN | 233 | 214 | 91.8% | 19 | 69 |
| GH | 228 | 213 | 93.4% | 8 | 93 |
| SR | 228 | 181 | 79.4% | 1 | 23 |
| VE | 218 | 205 | 94% | 4 | 65 |
| SW | 211 | 211 | 100% | 0 | 101 |
| NI | 198 | 193 | 97.5% | 1 | 48 |
| BM | 192 | 155 | 80.7% | 0 | 12 |
| MD | 190 | 169 | 88.9% | 0 | 53 |
| IQ | 189 | 179 | 94.7% | 0 | 41 |
| AD | 168 | 162 | 96.4% | 22 | 148 |
| MK | 150 | 135 | 90% | 2 | 67 |
| FJ | 145 | 112 | 77.2% | 0 | 22 |
| DZ | 141 | 114 | 80.9% | 0 | 40 |
| NP | 136 | 113 | 83.1% | 1 | 33 |
| ET | 136 | 111 | 81.6% | 0 | 46 |
| SO | 128 | 128 | 100% | 1 | 22 |
| JM | 125 | 111 | 88.8% | 5 | 47 |
| AO | 121 | 85 | 70.2% | 0 | 19 |
| BA | 120 | 115 | 95.8% | 0 | 54 |
| XK | 120 | 119 | 99.2% | 0 | 26 |
| BO | 112 | 105 | 93.8% | 0 | 35 |
| KH | 110 | 87 | 79.1% | 4 | 44 |
| MZ | 106 | 101 | 95.3% | 0 | 24 |
| GI | 104 | 103 | 99% | 0 | 26 |
| UG | 104 | 104 | 100% | 0 | 34 |
| CI | 101 | 101 | 100% | 0 | 35 |
| MM | 100 | 71 | 71% | 1 | 31 |
| CD | 100 | 99 | 99% | 1 | 29 |
| BY | 98 | 98 | 100% | 0 | 21 |
| TT | 90 | 87 | 96.7% | 0 | 19 |
| BS | 89 | 80 | 89.9% | 1 | 23 |
| IM | 89 | 89 | 100% | 2 | 18 |
| ME | 86 | 66 | 76.7% | 0 | 18 |
| JE | 84 | 83 | 98.8% | 1 | 19 |
| SP | 83 | 83 | 100% | 0 | 14 |
| AW | 83 | 69 | 83.1% | 0 | 6 |
| SN | 82 | 77 | 93.9% | 0 | 29 |
| UZ | 81 | 77 | 95.1% | 0 | 34 |
| MO | 78 | 55 | 70.5% | 0 | 16 |
| CM | 77 | 77 | 100% | 0 | 25 |
| GG | 77 | 77 | 100% | 0 | 15 |
| LA | 72 | 52 | 72.2% | 2 | 35 |
| MC | 71 | 59 | 83.1% | 1 | 30 |
| IS | 71 | 60 | 84.5% | 0 | 33 |
| KY | 71 | 60 | 84.5% | 1 | 17 |
| PG | 69 | 59 | 85.5% | 0 | 19 |
| MQ | 67 | 66 | 98.5% | 1 | 20 |
| PF | 66 | 60 | 90.9% | 1 | 9 |
| ZM | 65 | 61 | 93.8% | 0 | 25 |
| IR | 63 | 56 | 88.9% | 1 | 15 |
| GY | 62 | 57 | 91.9% | 0 | 17 |
| NA | 61 | 56 | 91.8% | 0 | 19 |
| PY | 61 | 54 | 88.5% | 0 | 32 |
| RW | 56 | 46 | 82.1% | 0 | 22 |
| HO | 51 | 51 | 100% | 0 | 2 |
| RE | 51 | 50 | 98% | 1 | 25 |
| MG | 47 | 45 | 95.7% | 0 | 14 |
| BB | 47 | 41 | 87.2% | 0 | 13 |
| SL | 47 | 44 | 93.6% | 0 | 12 |
| SZ | 46 | 46 | 100% | 0 | 5 |
| SC | 46 | 46 | 100% | 0 | 7 |
| BW | 44 | 44 | 100% | 0 | 16 |
| GA | 43 | 43 | 100% | 0 | 12 |
| JA | 43 | 43 | 100% | 0 | 16 |
| NE | 37 | 34 | 91.9% | 4 | 24 |
| LY | 36 | 35 | 97.2% | 0 | 12 |
| TD | 35 | 34 | 97.1% | 0 | 12 |
| WS | 33 | 29 | 87.9% | 0 | 6 |
| PO | 33 | 33 | 100% | 0 | 7 |
| SD | 32 | 27 | 84.4% | 1 | 12 |
| AF | 30 | 24 | 80% | 0 | 14 |
| GU | 28 | 25 | 89.3% | 1 | 16 |
| LR | 27 | 27 | 100% | 0 | 7 |
| SY | 26 | 25 | 96.2% | 0 | 17 |
| PS | 26 | 26 | 100% | 0 | 11 |
| GP | 26 | 26 | 100% | 0 | 13 |
| ZW | 25 | 25 | 100% | 0 | 12 |
| KG | 25 | 23 | 92% | 0 | 13 |
| CF | 25 | 24 | 96% | 0 | 7 |
| SM | 25 | 16 | 64% | 0 | 6 |
| HT | 25 | 24 | 96% | 1 | 12 |

_173 ISO country codes with live rows. This is not a list of the world's countries; it is what the ledger currently carries._

_Measured 9 September 2026, 01:52 UTC. Generated from `GET https://api.jobopportunitiesapi.org/public/coverage/countries` (no key required). These figures move — the endpoint supersedes this file._

The same vocabulary, with live counts, is enumerable in code from [`/public/facets`](https://api.jobopportunitiesapi.org/public/facets) (keyless) or `/v1/meta/facets`. That is the form to use in a program, because it is the list the API will accept **today** rather than the list it accepted when this page was built.

| value | label | live rows |
| --- | --- | --- |
| US | US | 1,604,089 |
| DE | DE | 173,660 |
| GB | GB | 114,651 |
| IN | IN | 73,689 |
| FR | FR | 64,257 |
| CA | CA | 61,269 |
| NL | NL | 52,441 |
| SE | SE | 32,625 |
| AU | AU | 23,543 |
| ES | ES | 22,015 |
| BE | BE | 17,093 |
| CH | CH | 14,645 |
| IT | IT | 14,302 |
| MX | MX | 14,103 |
| HU | HU | 13,780 |
| PL | PL | 13,332 |
| BR | BR | 12,956 |
| SG | SG | 12,922 |
| PH | PH | 11,833 |
| AT | AT | 10,573 |
| AE | AE | 10,338 |
| JP | JP | 10,256 |
| MY | MY | 10,036 |
| CN | CN | 10,034 |
| HR | HR | 9,878 |
| IE | IE | 8,903 |
| NO | NO | 8,551 |
| ZA | ZA | 7,215 |
| PT | PT | 7,083 |
| GR | GR | 5,830 |
| DK | DK | 5,716 |
| ID | ID | 5,199 |
| FI | FI | 5,111 |
| TH | TH | 4,887 |
| CO | CO | 4,749 |
| SA | SA | 4,641 |
| RO | RO | 4,615 |
| NZ | NZ | 4,165 |
| HK | HK | 3,847 |
| EG | EG | 3,816 |
| TW | TW | 3,702 |
| VN | VN | 3,583 |
| CZ | CZ | 3,358 |
| LT | LT | 3,093 |
| AR | AR | 3,090 |
| GE | GE | 3,090 |
| KR | KR | 2,926 |
| UA | UA | 2,856 |
| BG | BG | 2,817 |
| PE | PE | 2,517 |
| IL | IL | 2,372 |
| CL | CL | 2,109 |
| TR | TR | 1,854 |
| UN | UN | 1,805 |
| MA | MA | 1,805 |
| LU | LU | 1,662 |
| NG | NG | 1,587 |
| PK | PK | 1,512 |
| QA | QA | 1,394 |
| CR | CR | 1,196 |
| RS | RS | 1,194 |
| MT | MT | 1,181 |
| CY | CY | 1,158 |
| SK | SK | 1,145 |
| EE | EE | 935 |
| LK | LK | 921 |
| GT | GT | 848 |
| LV | LV | 773 |
| KE | KE | 712 |
| EC | EC | 689 |
| JO | JO | 661 |
| TN | TN | 636 |
| PA | PA | 577 |
| KZ | KZ | 546 |
| LB | LB | 457 |
| DO | DO | 452 |
| SI | SI | 417 |
| KW | KW | 411 |
| AZ | AZ | 405 |
| PR | PR | 394 |
| MV | MV | 362 |
| UY | UY | 359 |
| BD | BD | 357 |
| LI | LI | 356 |
| BH | BH | 336 |
| MU | MU | 325 |
| RU | RU | 306 |
| OM | OM | 297 |
| SV | SV | 283 |
| AM | AM | 281 |
| TZ | TZ | 274 |
| AL | AL | 259 |
| HN | HN | 233 |
| SR | SR | 228 |
| GH | GH | 228 |
| VE | VE | 218 |
| SW | SW | 211 |
| NI | NI | 198 |
| BM | BM | 192 |
| MD | MD | 190 |
| IQ | IQ | 189 |
| AD | AD | 168 |
| MK | MK | 150 |
| FJ | FJ | 145 |
| DZ | DZ | 141 |
| ET | ET | 136 |
| NP | NP | 136 |
| SO | SO | 128 |
| JM | JM | 125 |
| AO | AO | 121 |
| BA | BA | 120 |
| XK | XK | 120 |
| BO | BO | 112 |
| KH | KH | 110 |
| MZ | MZ | 106 |
| UG | UG | 104 |
| GI | GI | 104 |
| CI | CI | 101 |
| CD | CD | 100 |
| MM | MM | 100 |
| BY | BY | 98 |
| TT | TT | 90 |
| BS | BS | 89 |
| IM | IM | 89 |
| ME | ME | 86 |
| JE | JE | 84 |
| SP | SP | 83 |
| AW | AW | 83 |
| SN | SN | 82 |
| UZ | UZ | 81 |
| MO | MO | 78 |
| CM | CM | 77 |
| GG | GG | 77 |
| LA | LA | 72 |
| IS | IS | 71 |
| MC | MC | 71 |
| KY | KY | 71 |
| PG | PG | 69 |
| MQ | MQ | 67 |
| PF | PF | 66 |
| ZM | ZM | 65 |
| IR | IR | 63 |
| GY | GY | 62 |
| PY | PY | 61 |
| NA | NA | 61 |
| RW | RW | 56 |
| RE | RE | 51 |
| HO | HO | 51 |
| MG | MG | 47 |
| SL | SL | 47 |
| BB | BB | 47 |
| SZ | SZ | 46 |
| SC | SC | 46 |
| XX | XX | 46 |
| BW | BW | 44 |
| GA | GA | 43 |
| JA | JA | 43 |
| NE | NE | 37 |
| LY | LY | 36 |
| TD | TD | 35 |
| PO | PO | 33 |
| WS | WS | 33 |
| SD | SD | 32 |
| AF | AF | 30 |
| GU | GU | 28 |
| LR | LR | 27 |
| PS | PS | 26 |
| SY | SY | 26 |
| GP | GP | 26 |
| SM | SM | 25 |
| ZW | ZW | 25 |
| CF | CF | 25 |
| KG | KG | 25 |
| HT | HT | 25 |
| GN | GN | 24 |
| GF | GF | 24 |
| YE | YE | 24 |
| FM | FM | 23 |
| VA | VA | 23 |
| AQ | AQ | 23 |
| BJ | BJ | 22 |
| CU | CU | 21 |
| BZ | BZ | 21 |
| MW | MW | 21 |
| GM | GM | 20 |
| SS | SS | 20 |
| AX | AX | 20 |
| TU | TU | 18 |
| ML | ML | 18 |
| TC | TC | 17 |
| LS | LS | 16 |
| MH | MH | 15 |
| MR | MR | 14 |
| GQ | GQ | 14 |
| TO | TO | 14 |
| VI | VI | 14 |
| XI | XI | 13 |
| EU | EU | 13 |
| MN | MN | 13 |
| GD | GD | 12 |
| CV | CV | 12 |
| NC | NC | 12 |
| SX | SX | 12 |
| YT | YT | 11 |
| HQ | HQ | 10 |
| SB | SB | 10 |
| KN | KN | 10 |
| BU | BU | 10 |
| CW | CW | 10 |
| TA | TA | 9 |
| BI | BI | 9 |
| BN | BN | 8 |
| BT | BT | 8 |
| BQ | BQ | 7 |
| VG | VG | 7 |
| ST | ST | 7 |
| VU | VU | 7 |
| TJ | TJ | 7 |
| SU | SU | 6 |
| DJ | DJ | 6 |
| BF | BF | 6 |
| TG | TG | 6 |
| CG | CG | 5 |
| TM | TM | 5 |
| FO | FO | 4 |
| KI | KI | 4 |
| GW | GW | 4 |
| GL | GL | 3 |
| TL | TL | 3 |
| SH | SH | 3 |
| DC | DC | 2 |
| SF | SF | 2 |
| LC | LC | 2 |
| PW | PW | 2 |
| NY | NY | 2 |
| AS | AS | 2 |
| LE | LE | 1 |
| MF | MF | 1 |
| BL | BL | 1 |
| AG | AG | 1 |
| ZZ | ZZ | 1 |
| KP | KP | 1 |
| OR | OR | 1 |
| DM | DM | 1 |
| AN | AN | 1 |
| MP | MP | 1 |
| VC | VC | 1 |
| FL | FL | 1 |
| UM | UM | 1 |
| ER | ER | 1 |

_250 values, generated from `GET https://api.jobopportunitiesapi.org/public/facets` (no key required). Enumerate it at run time rather than hard-coding this list._


### 4.2 Exact contract

**Technical detail — how the filter actually works.**

| parameter | type | required | default | allowed values | range | comma-separated | description |
| --- | --- | --- | --- | --- | --- | --- | --- |
| country | string | no | — | — | — | yes | Comma-separated ISO-3166 alpha-2. |
| exclude_country | string | no | — | — | — | no | — |

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

The `country` column on a listing is a resolved value, not a copy of anything the source sent. Sources supply a free-text location; a resolver maps that to an ISO code and, where it can, a city and a US state. When the text is ambiguous — a city name that exists in several countries, with nothing else to disambiguate it — the column is left null. It is present on roughly three quarters of live rows.

A row with a null `country` is unreachable by `?country=`. It is **not** excluded by `?exclude_country=`, because it does not match the code you are excluding — so the two parameters are not complements of each other. `?country=US` and `?exclude_country=US` do not partition the ledger; the unlocated rows are in neither of the first and in the second.

| What you want | Send | Note |
| --- | --- | --- |
| One country | `?country=DE` | Case-insensitive. |
| Several countries | `?country=DE,AT,CH` | Comma-separated, no spaces. OR within the list. |
| Everywhere except one | `?exclude_country=US` | Keeps rows with no country at all. |
| A country and a category | `?country=DE&category=Engineering` | Different parameters AND together. |
| A US state | `?state=OH` or `?state=OH,TX` | US only. No equivalent for other countries' subdivisions. |
| A city | `?city=Berlin` | Resolved city, thinner coverage than country. |
| Rows with no country | not directly addressable | Fetch unfiltered and read `field_sources.location`. |

Interactions worth knowing. `country` composes with everything, including `require_fields` — `?require_fields=salary&country=DE` is a reasonable and common query, and the per-country table above is where you check whether it will return enough rows to be useful. Country-locked keys (the single-country SKU) are restricted to one code and are exempt from the record meter for job rows; on such a key a request for another country returns nothing and every response carries `X-JOA-Country-Lock`. See [country-locked keys](./account-record-meter.md#country-locked).

Performance: `country` is one of the cheapest filters here and narrowing by it is the first thing to try when a query is slow enough to risk the 503 timeout — particularly if you are also using `q`, `title` or `description_contains`, which are the most expensive things this API can be asked.

### 4.3 Worked examples

Openings in one country

```console
# Keyless — no account needed. Swap FR for any code in the list above.
$ curl -s 'https://api.jobopportunitiesapi.org/public/jobs?country=FR&limit=2' \
  | jq '.data[] | {title, company, city, country}'
{
  "data": [
    {
      "id": "9afe8190-32c7-4529-9dc0-fd89ada74f41",
      "slug": "project-lead-engineer-hvdc-9afe8190",
      "title": "Project Lead Engineer HVDC",
      "company": "hitachi",
      "company_slug": "hitachi",
      "company_logo": "https://supabase-erioun.tzekos.eu/storage/v1/object/public/company-logos/logos/hitachi.png",
      "category": "Engineering",
      "category_confidence": 0.8,
      "country": "FR",
      "city": "Massy",
      "location": "Massy, Île-de-France, France",
      "remote": "on_site",
      "remote_inferred": true,
      "seniority": "Lead",
      "posted_at": "2026-09-09T01:03:16Z",
      "first_seen_at": "2026-09-09T01:10:28Z",
      "last_verified_at": "2026-09-09T03:54:58Z",
      "status": "live",
      "closed_at": null,
      "closed_reason": null,
      "apply_url": "https://hitachi.wd1.myworkdayjobs.com/hitachi/job/Massy-le-de-France-France/Project-Lead-Engineer-HVDC_R0143929",
      "source": "workday",
      "source_type": "ats",
      "provider_type": "employer_ats",
      "has_description": false,
      "field_sources": {
        "remote": "inferred",
… 53 more lines
```

_Real response, fetched from `/public/jobs?country=FR&limit=2` when this file was built (9 September 2026, 04:37 UTC)._

Three countries at once

```console
$ curl -s 'https://api.jobopportunitiesapi.org/public/jobs?country=DE,AT,CH&limit=3' \
  | jq -r '.data[] | "\(.country)  \(.company)  \(.title)"'
{
  "data": [
    {
      "id": "bdd2a85b-aefd-4e32-9fa7-9a26bad0f6ac",
      "slug": "head-of-treasury-payments-technology-bdd2a85b",
      "title": "Head of Treasury & Payments Technology",
      "company": "trafigura",
      "company_slug": "trafigura",
      "company_logo": "https://supabase-erioun.tzekos.eu/storage/v1/object/public/company-logos/logos/trafigura.png",
      "category": "Finance",
      "category_confidence": 0.65,
      "country": "CH",
      "city": "Geneva",
      "location": "Geneva, Switzerland",
      "remote": "on_site",
      "remote_inferred": true,
      "seniority": "Director",
      "posted_at": "2026-09-09T01:03:16Z",
      "first_seen_at": "2026-09-09T01:10:28Z",
      "last_verified_at": "2026-09-09T04:04:39Z",
      "status": "live",
      "closed_at": null,
      "closed_reason": null,
      "apply_url": "https://trafigura.wd3.myworkdayjobs.com/trafiguracareersite/job/Geneva-Switzerland/Head-of-Treasury---Payments-Technology_R-018663",
… 97 more lines
```

_Real response, fetched from `/public/jobs?country=DE,AT,CH&limit=3` when this file was built (9 September 2026, 04:37 UTC)._

Every country code the ledger carries, sorted by volume

```bash
curl -s https://api.jobopportunitiesapi.org/public/coverage/countries \
  | jq -r '.countries[] | "\(.country)\t\(.live)"' \
  | sort -k2 -rn | head -20
```

Everywhere except the United States, on a key

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

**See also**

- [Per-country coverage](./ledger-coverage.md#countries-list)
- [Place — country, city, state](./api-parameters.md#params-place)
- [Controlled vocabularies — never hard-code these](./api-parameters.md#filter-vocabularies)
- [The country-locked plan](./account-record-meter.md#country-locked)

<a id="params-classification"></a>

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

### 5.1 In depth

`category` and `seniority` are inferred from the job title by a classifier. They are useful and they are not quotations, which is why `field_sources` reports both as `inferred` on every row and why `require_fields=category` is [a deliberate 422](./ledger-provenance.md#the-422).

`remote` is the interesting one. A small minority of sources state a remote status in a field; for the rest we read it from the location text, the title, or the presence of a named workplace city. Both kinds are returned, and `remote_inferred` on every row says which you have. `remote_confirmed=true` restricts the result to the stated ones — a much smaller and much more defensible set.

`employment_type` and `seniority` both accept the literal value `not_stated`, which selects rows carrying none. That is a different question from omitting the filter, and it is the only way to ask it.

### 5.2 Exact contract

| parameter | type | required | default | allowed values | range | comma-separated | description |
| --- | --- | --- | --- | --- | --- | --- | --- |
| category | string | no | — | — | — | yes | Comma-separated. uncategorised selects rows with no confident classification. |
| 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. |
| exclude_category | string | no | — | — | — | no | — |
| remote_confirmed | boolean | no | — | — | — | no | `true` returns only listings whose remote status the SOURCE stated — 374,410 of 3,708,181 live rows (10.1%). Without it you also receive the 3,344,972 (90.2%) where we inferred it from the location text, the title, or the presence of a named workplace city. |

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

| value | label | live rows |
| --- | --- | --- |
| Engineering | Engineering | 448,018 |
| Healthcare | Healthcare | 426,680 |
| Operations & Admin | Operations & Admin | 378,337 |
| Sales | Sales | 291,393 |
| Skilled Technician | Skilled Technician | 223,841 |
| Retail | Retail | 208,092 |
| Hospitality | Hospitality | 190,846 |
| uncategorised | Uncategorised | 183,311 |
| Finance | Finance | 179,984 |
| Logistics & Transport | Logistics & Transport | 110,896 |
| Marketing | Marketing | 98,649 |
| Customer Support | Customer Support | 96,484 |
| Education | Education | 86,014 |
| Manufacturing | Manufacturing | 85,170 |
| HR & Recruiting | HR & Recruiting | 72,136 |
| Data & Analytics | Data & Analytics | 66,364 |
| Consulting & Strategy | Consulting & Strategy | 63,433 |
| Construction & Trades | Construction & Trades | 52,947 |
| Legal & Compliance | Legal & Compliance | 48,332 |
| Product | Product | 37,965 |
| Design | Design | 37,225 |
| Security | Security | 31,154 |
| Science & Research | Science & Research | 29,164 |
| Procurement | Procurement | 20,200 |
| Safety & Environment | Safety & Environment | 14,625 |

_25 values, generated from `GET https://api.jobopportunitiesapi.org/public/facets` (no key required). Enumerate it at run time rather than hard-coding this list._

The `category` vocabulary, with live counts.

| value | label | live rows |
| --- | --- | --- |
| not_stated | Not stated | 1,915,771 |
| Manager | Manager | 456,953 |
| Entry | Entry | 323,637 |
| Senior | Senior | 259,933 |
| Mid | Mid | 164,012 |
| Lead | Lead | 140,859 |
| Director | Director | 106,384 |
| Intern | Intern | 87,052 |
| Executive | Executive | 26,658 |

_9 values, generated from `GET https://api.jobopportunitiesapi.org/public/facets` (no key required). Enumerate it at run time rather than hard-coding this list._


| value | label | live rows |
| --- | --- | --- |
| not_stated | Not stated | 3,227,843 |
| Full-time | Full-time | 190,420 |
| Contract | Contract | 31,735 |
| Part-time | Part-time | 14,270 |
| Temporary | Temporary | 11,029 |
| Internship | Internship | 5,962 |

_6 values, generated from `GET https://api.jobopportunitiesapi.org/public/facets` (no key required). Enumerate it at run time rather than hard-coding this list._


| value | label | live rows |
| --- | --- | --- |
| on_site | On Site | 2,980,918 |
| remote | Remote | 270,409 |
| hybrid | Hybrid | 221,676 |
| not_stated | Not stated | 8,256 |

_4 values, generated from `GET https://api.jobopportunitiesapi.org/public/facets` (no key required). Enumerate it at run time rather than hard-coding this list._


`category=uncategorised` selects rows with no confident classification — the ones where the classifier scored below 0.6 and no category was published. Every row that does carry a category also carries `category_confidence`, which is therefore either null or at least 0.6.

**See also**

- [field_sources — per-field provenance](./ledger-provenance.md#field-sources)
- [Controlled vocabularies — never hard-code these](./api-parameters.md#filter-vocabularies)
- [The job row](./api-fields.md#job-fields)

<a id="params-provenance-filters"></a>

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

### 6.1 In depth

- **`source_type`** — The provenance bucket — `ats`, `career_site`, `public_agency`. The legacy spellings `employer_ats`, `government` and `direct` are accepted and mapped. `aggregator` and `agency` are reserved and match nothing.
- **`provider`** — The exact system: `greenhouse`, `workday`, `company_site`. Finer than `source_type`. Up to twelve values; an unknown one is a 422.
- **`status`** — `live` (default), `closed`, or `any`. Paid endpoints only.
- **`quality`** — `all` re-admits rows held back by reversible doubt. It never re-admits rows removed for breaching the employer-direct guarantee. Paid endpoints only.
- **`include_poster_type`** — Re-admits staffing-agency and job-board postings, which are excluded by default. Paid endpoints only.
- **`require_fields`** — Only rows where every named field was **published** by the source. Adds a `completeness` block.

The distinction between `quality` and `include_poster_type` is worth holding onto. `quality=all` is about our confidence in a row — a future `posted_at`, a missing apply URL. `include_poster_type` is about who posted it. Different questions, different parameters, and neither of them can re-admit a row that fails the employer-direct promise.

### 6.2 Exact contract

| parameter | type | required | default | allowed values | range | comma-separated | description |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 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. |
| 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 | — |
| 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.1% of your rows have a salary". They do — and `require_fields=salary` returns 225,345 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. |

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

| value | label | live rows |
| --- | --- | --- |
| career_site | Company career site | 1,786,805 |
| ats | Employer ATS | 1,683,268 |
| public_agency | Public employment agency | 11,187 |

_3 values, generated from `GET https://api.jobopportunitiesapi.org/public/facets` (no key required). Enumerate it at run time rather than hard-coding this list._


With `quality=all`, each re-admitted row carries its verdict, the id of the rule that matched and the host that triggered it — so you can judge the gate's decision rather than take it on trust. That is the intended use: not to get more rows, but to audit which rows were held and why.

**See also**

- [Withheld rows — held, not served](./ledger-data-model.md#withheld)
- [Staffing agencies and job boards](./ledger-sourcing.md#poster-type)
- [Every provider, with its live row count](./ledger-sourcing.md#providers-list)
- [require_fields — the honest subset](./ledger-provenance.md#require-fields)

<a id="params-company"></a>

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

### 7.1 In depth

`company` takes slugs — the same values that appear as `company_slug` on a job row and in `/company/<slug>` URLs on the website. `company_domain` takes bare domains: `stripe.com`, not `https://stripe.com/careers`, though a value with a scheme and a path is tolerated and reduced to the bare domain.

Prefer the domain where you have one, for two reasons. It is the identifier your own systems already hold, so no mapping table is needed; and company slugs are not currently guaranteed stable across refreshes. About 37% of companies carry a domain, so “where you have one” is doing real work in that sentence.

> **A domain we do not carry is not an error** — `?company_domain=nosuchcompany.example` returns an empty page with a 200, not a 422. Domains are not a controlled vocabulary — there is no list to check against — so an unknown one is a legitimate query with no matches.

### 7.2 Exact contract

| parameter | type | required | default | allowed values | range | comma-separated | description |
| --- | --- | --- | --- | --- | --- | --- | --- |
| company | string | no | — | — | — | yes | Comma-separated company slugs. |
| 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. |

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

| parameter | type | required | default | allowed values | range | comma-separated | description |
| --- | --- | --- | --- | --- | --- | --- | --- |
| limit | integer | no | 25 | — | 1–200 | no | — |
| offset | integer | no | — | — | 0–100000 | no | — |
| cursor | string | no | — | — | — | no | The next_cursor from the previous page. Overrides offset. |
| country | string | no | — | — | — | no | — |
| org_type | string | no | — | — | — | no | — |
| source_type | string | no | — | — | — | no | — |
| has_website | boolean | no | — | — | — | no | Only companies whose domain we fetched and found their own name on. An absent website means we could not prove one, not that they have none. |
| include_discovered | boolean | no | — | — | — | no | Also return the companies we watched hiring on their own careers page under a source we do not redistribute. They arrive with `open_roles: 0` and a non-zero `own_site_roles`, and `careers_url` points at the page we saw them on. Useful for enrichment; not inventory. |
| q | string | no | — | — | — | no | — |

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

### 7.3 Worked examples

Watch three employers by domain — the shape of a CRM enrichment job

```bash
curl -s -H "Authorization: Bearer $JOA_KEY" --get \
  https://api.jobopportunitiesapi.org/v1/jobs \
  --data-urlencode 'company_domain=stripe.com,figma.com,linear.app' \
  --data-urlencode 'limit=10' \
  | jq -r '.data[] | "\(.company)\t\(.title)"'
```

**See also**

- [Company slugs are not yet guaranteed stable](./endpoints-companies.md#slug-instability)
- [GET /v1/companies](./endpoints-companies.md#endpoint-companies)
- [Hiring signal for a CRM](./recipes-build.md#recipe-crm-enrichment)

<a id="params-text-search"></a>

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

### 8.1 In depth

- **`q`** — Full text over title, company name and location. **Not** the description.
- **`title`** — Full text over the job title only.
- **`title_exclude`** — Drops rows whose title matches. Same matching as `title`.
- **`description_contains`** — Full text over the advert body. Implies `has_description=true`, because it can only match rows that have one.

That `q` does not search the description is a design decision, not a limitation. The description is two and a half kilobytes of prose per row, and searching it makes a query dramatically more expensive; folding it into the default search would make every simple query pay that cost. So the cheap search is the default and the expensive one is opt-in and named.

> **Stemming is off** — The text index uses the `simple` dictionary, so `q=engineer` does **not** match `engineering`. If you want both, ask for both. This is deliberate — stemming across the languages in this ledger produces more wrong matches than right ones.

### 8.2 Exact contract

| parameter | type | required | default | allowed values | range | comma-separated | description |
| --- | --- | --- | --- | --- | --- | --- | --- |
| has_description | boolean | no | — | — | — | no | `true` returns only the 3,044,043 live rows (82.1%) that carry a description. |
| 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. |
| 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`. |

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

> **Two full-text filters together is the most expensive query this API can be asked** — `title` and `description_contains` are both GIN-indexed, 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 — otherwise you are the likeliest caller to meet the 503 timeout.

**See also**

- [Which text filter to use](./api-filtering.md#text-vs-title-vs-description)
- [Queries that can time out, and how to make them fast](./api-filtering.md#expensive-queries)
- [Every status code](./api-errors.md#error-table)

<a id="params-salary"></a>

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

### 9.1 In depth

`has_salary=true` (and its alias `structured`) returns only rows whose salary the source published. `has_salary=any` also includes figures we read out of the advert text. AI estimates are never included under any value — they are refused by the query that builds the data, not by a filter.

`min_salary` and `max_salary` bound `salary_min_annual_eur`, which is null wherever the period or currency could not be recognised. So they select only rows with a normalisable figure — a narrow filter by nature, not a broken one.

### 9.2 Exact contract

| parameter | type | required | default | allowed values | range | comma-separated | description |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 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. |
| 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. |

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

For a clean set with a real employer-published figure on every row, use `require_fields=salary` rather than `has_salary=true` — it makes the guarantee explicit in the response and adds the `completeness` block. [Detail](./ledger-provenance.md#require-fields).

**See also**

- [Salary, specifically](./ledger-provenance.md#salary-provenance)
- [The salary fields](./api-fields.md#salary-fields)
- [require_fields — the honest subset](./ledger-provenance.md#require-fields)

<a id="params-time"></a>

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

### 10.1 In depth

`posted_after` filters on the source's own posting date, which many sources do not state — so it silently excludes every row with no `posted_at`. `verified_after` filters on `last_verified_at`, which is on every row, and answers “what have you re-confirmed since yesterday”.

> **Neither of these is the right tool for keeping a mirror current** — For that, use [`/v1/changes`](./endpoints-changes.md). It gives you creations, updates, withdrawals and closures in one ordered stream, costs far fewer records than re-reading a listing, and cannot miss a row to a shared timestamp.

### 10.2 Exact contract

| parameter | type | required | default | allowed values | range | comma-separated | description |
| --- | --- | --- | --- | --- | --- | --- | --- |
| posted_after | string | no | — | — | — | no | Date or RFC3339 timestamp. |
| verified_after | string | no | — | — | — | no | Only rows re-confirmed at their source since this instant. |

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

Both accept RFC3339 (`2026-08-01T00:00:00Z`) and `posted_after` also accepts a bare date (`2026-08-01`). An unparseable value is a 422, not an ignored parameter.

If you need every row to carry a date — for a backfill, or a chart — use `first_seen_at`, which is populated on 100% of rows, rather than `posted_at`. Note that `/v1/jobs` orders by `posted_at DESC NULLS LAST, id DESC`, so undated rows sort last rather than being excluded.

**See also**

- [Freshness — measured_at, stale, and the refresh cycle](./ledger-coverage.md#freshness)
- [GET /v1/changes](./endpoints-changes.md#endpoint-changes)
- [An incremental sync](./recipes-sync.md#recipe-incremental-sync)

<a id="filter-vocabularies"></a>

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

### 11.1 In depth

This is the single cheapest mistake to avoid on this API, and it has already cost somebody real time. On 2026-08-15 three provider names left `/public/providers`. The next morning the first external evaluator this product ever had copied `?exclude_provider=eures` out of the documentation and got a 422. The API was right, the vocabulary check was right, and the documentation was the only thing that was wrong.

| Vocabulary | Enumerate from | Key? |
| --- | --- | --- |
| `provider` | `/public/providers` or `/v1/meta/providers` | No / yes |
| `category` | `/public/facets` → `family` | No |
| `country` | `/public/facets` → `country`, or `/public/coverage/countries` | No |
| `city` | `/public/facets` → `city` | No |
| `employment_type` | `/public/facets` → `employment` | No |
| `seniority` | `/public/facets` → `seniority` | No |
| `remote` | `/public/facets` → `remote` | No |
| `source_type` | `/public/facets` → `source_type` | No |
| `plan` | `/public/plans` | No |

Every one of these is keyless, so there is no reason not to read them at start-up and cache them for an hour.

### 11.2 Exact contract

Validate a filter before you spend a record on it

```python
import json, urllib.request

A = "https://api.jobopportunitiesapi.org"

def vocabulary(group):
    """Legal values for one facet, read from the API rather than remembered."""
    with urllib.request.urlopen(f"{A}/public/facets", timeout=30) as r:
        facets = json.load(r)["data"]
    return {o["value"] for o in facets.get(group, [])}

categories = vocabulary("family")
wanted = "Engineering"
if wanted not in categories:
    raise SystemExit(f"{wanted!r} is not a category today; have: {sorted(categories)[:8]} …")
```

**See also**

- [422 — a value we will not guess at](./api-errors.md#error-422)
- [Every provider, with its live row count](./ledger-sourcing.md#providers-list)
- [GET /v1/meta/facets](./endpoints-meta.md#endpoint-meta-facets)

<a id="params-jobs-full"></a>

## 12. The complete table

Every parameter /v1/jobs accepts, in the order the specification lists them, with nothing omitted.

### 12.1 In depth

The sections above group these by what they do, which is how you find one. This is the flat list, which is how you check you have not missed one. Both are generated from the same specification.

### 12.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 — 374,410 of 3,708,181 live rows (10.1%). Without it you also receive the 3,344,972 (90.2%) 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 3,044,043 live rows (82.1%) 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.1% of your rows have a salary". They do — and `require_fields=salary` returns 225,345 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._

**See also**

- [GET /v1/jobs](./endpoints-jobs.md#endpoint-jobs)
- [How to read these tables](./api-parameters.md#params-how-to-read)
- [Twenty questions, and the query for each](./api-filtering.md#filtering-cookbook)

---

## Where to go next

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

- [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.
- [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.
- [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.
- [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.
- [Pagination](./api-pagination.md) — Keyset cursors, not offsets. One rule matters more than the rest: hand next_cursor back exactly as you received it.

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
