api
Parameters
Every query parameter the listing endpoints take, grouped by what it does, generated from the live specification so it cannot drift.
1How 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.
In depthWhy it exists, what it is not, what people get wrong
- comma-separated
- The parameter takes a list:
?country=DE,AT,CH. No spaces. Each list has a maximum number of values (country60,city12,category12); a longer list is a 422too_many_values, never truncated. - 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. A value outside them is a 422 that names the range.
limitis 1 to 200 (1 to 50 withinclude_description=true); inside that range a value above your plan's page cap is reduced to the cap and flagged inX-JOA-Limit-Applied. - paid endpoints only
- The parameter is honoured on
/v1/*only. The keyless row surface that used to silently ignore it no longer exists (see Keyless access). - Case
- Values are matched case-insensitively:
category=engineeringandcategory=Engineeringare 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.
Exact contractTypes, defaults, ranges, errors, edge cases
Unknown parameters are refused, not ignored: ?catgeory=Engineering is a 400 unknown_parameter whose message reads: unknown parameter: 'catgeory'; did you mean 'category'? An ignored filter would return the whole ledger to a caller who believes it is filtered, so the API says so instead. A typo in a parameter value for a controlled vocabulary is a 422 naming the value. Nothing is charged for either.
2Paging
limit sets the page size; cursor continues from the previous page. Both are documented in full under Pagination.
In depthWhy it exists, what it is not, what people get wrong
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. There is no keyless row surface left to compare it to (see Keyless access). A limit above your plan's cap (but within 1 to 200) is reduced to the cap, visibly: the response carries X-JOA-Limit-Applied: <cap>.
Exact contractTypes, defaults, ranges, errors, edge cases
limitintegerdefault 251 to 2001-200 (default 25). Anything else is a 422 bad_limit, not clamped.
cursorstringThe next_cursor from the previous page.
2 parameters, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug. Please say so with the thumbs-down below.
3Place: 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.
In depthWhy it exists, what it is not, what people get wrong
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.
US territories (Puerto Rico, Guam, the US Virgin Islands, American Samoa and the Northern Mariana Islands) carry their own ISO 3166-1 codes (PR, GU, VI, AS, MP) in country. They are not folded into US: filter them explicitly, e.g. country=US,PR.
Exact contractTypes, defaults, ranges, errors, edge cases
countrystringcomma-separatedComma-separated ISO-3166 alpha-2, at most 60 codes (more is a 422 too_many_values, never truncated).
citystringcomma-separatedComma-separated city names, matched exactly but case-insensitively in
every script (city=αθήνα finds Αθήνα); accents must match. A
two-letter term that is a US state code or a country code (what
country= accepts, plus UK for GB) qualifies the other city terms
instead of naming a second city, so city=Exeter, RI returns only
Exeter in Rhode Island: the US state reading wins a collision, and a
qualifier with no city term left (such as city=GB) is still matched
as a city value.
statestringcomma-separatedComma-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_countrystring4 parameters, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug. Please say so with the thumbs-down below.
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.
A ?city= answer can look like one town while it spans countries, so /v1/jobs adds location_ambiguous and location_countries to the page: the country breakdown of everything the search matched, and whether that set sits in more than one known country. See the response fields.
4Country 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.
In depthWhy it exists, what it is not, what people get wrong
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 25 or more live rows, and how many sit behind each (the response's countries_total also counts the codes below that). It is not a list of the world's countries; it is what this data holds today, including dependencies and territories with their own ISO code, and it moves as employers post and roles close. A value that is not an ISO 3166-1 code (plus XK) is refused with a 422, and never appears here.
Every country in the ledger: 165 rows, sorted by live listings
| ISO | Live | With a description | Described | With a published salary | Employers |
|---|---|---|---|---|---|
US | 1,391,884 | 1,332,215 | 95.7% | 488,138 | 77,320 |
DE | 180,303 | 164,482 | 91.2% | 25,559 | 24,415 |
GB | 97,406 | 95,203 | 97.7% | 28,722 | 13,279 |
IN | 88,057 | 77,374 | 87.9% | 577 | 3,622 |
CA | 67,551 | 65,985 | 97.7% | 26,420 | 5,386 |
FR | 59,228 | 57,749 | 97.5% | 11,769 | 3,810 |
NL | 40,967 | 39,096 | 95.4% | 18,886 | 6,320 |
SE | 22,438 | 22,258 | 99.2% | 599 | 4,826 |
AU | 21,760 | 21,422 | 98.4% | 2,047 | 2,172 |
MX | 19,327 | 17,788 | 92% | 419 | 1,653 |
ES | 18,583 | 17,679 | 95.1% | 2,118 | 2,451 |
SG | 15,483 | 15,192 | 98.1% | 269 | 1,669 |
CN | 15,277 | 13,077 | 85.6% | 102 | 1,216 |
IT | 14,277 | 13,988 | 98% | 3,573 | 1,614 |
BE | 12,453 | 11,224 | 90.1% | 857 | 2,067 |
MY | 12,246 | 11,688 | 95.4% | 89 | 967 |
PL | 12,153 | 11,672 | 96% | 981 | 1,652 |
BR | 12,120 | 11,328 | 93.5% | 261 | 1,358 |
JP | 11,722 | 11,302 | 96.4% | 1,910 | 1,389 |
PH | 11,418 | 11,058 | 96.8% | 468 | 1,232 |
CH | 11,343 | 10,874 | 95.9% | 608 | 2,015 |
AT | 9,261 | 8,823 | 95.3% | 5,297 | 1,867 |
AE | 8,709 | 8,249 | 94.7% | 187 | 980 |
IE | 7,698 | 7,456 | 96.9% | 1,152 | 1,274 |
PT | 5,847 | 5,103 | 87.3% | 392 | 871 |
TH | 5,687 | 5,431 | 95.5% | 90 | 701 |
NO | 5,573 | 5,329 | 95.6% | 275 | 1,113 |
TW | 5,359 | 5,182 | 96.7% | 45 | 568 |
ZA | 5,023 | 4,876 | 97.1% | 190 | 829 |
ID | 4,860 | 4,712 | 97% | 49 | 601 |
HU | 4,779 | 4,158 | 87% | 79 | 521 |
VN | 4,654 | 4,259 | 91.5% | 68 | 558 |
RO | 4,645 | 4,288 | 92.3% | 195 | 758 |
HK | 4,182 | 4,038 | 96.6% | 39 | 708 |
DK | 3,857 | 3,750 | 97.2% | 160 | 834 |
SA | 3,806 | 3,526 | 92.6% | 65 | 596 |
GR | 3,796 | 3,642 | 95.9% | 241 | 592 |
FI | 3,732 | 3,295 | 88.3% | 455 | 744 |
CO | 3,682 | 3,558 | 96.6% | 208 | 748 |
KR | 3,129 | 3,032 | 96.9% | 30 | 654 |
NZ | 3,116 | 3,048 | 97.8% | 287 | 613 |
CZ | 3,054 | 2,873 | 94.1% | 171 | 611 |
AR | 2,570 | 2,483 | 96.6% | 132 | 638 |
IL | 2,551 | 2,494 | 97.8% | 30 | 494 |
EG | 2,494 | 2,363 | 94.7% | 40 | 415 |
PE | 2,028 | 1,973 | 97.3% | 140 | 289 |
CL | 1,987 | 1,906 | 95.9% | 102 | 324 |
UA | 1,979 | 1,855 | 93.7% | 53 | 326 |
TR | 1,952 | 1,787 | 91.5% | 28 | 458 |
BG | 1,894 | 1,761 | 93% | 115 | 380 |
LT | 1,781 | 1,727 | 97% | 911 | 249 |
LU | 1,411 | 1,373 | 97.3% | 99 | 355 |
MA | 1,410 | 1,344 | 95.3% | 15 | 237 |
SK | 1,339 | 1,289 | 96.3% | 493 | 273 |
CR | 1,148 | 1,087 | 94.7% | 21 | 275 |
QA | 1,048 | 1,037 | 99% | 9 | 143 |
PK | 1,044 | 995 | 95.3% | 71 | 246 |
RS | 988 | 943 | 95.4% | 42 | 297 |
CY | 825 | 800 | 97% | 78 | 199 |
NG | 720 | 704 | 97.8% | 5 | 177 |
PR | 707 | 655 | 92.6% | 50 | 133 |
GE | 686 | 675 | 98.4% | 30 | 196 |
HR | 674 | 658 | 97.6% | 43 | 201 |
MT | 654 | 631 | 96.5% | 64 | 191 |
JO | 647 | 609 | 94.1% | 70 | 103 |
LV | 636 | 619 | 97.3% | 286 | 156 |
LK | 601 | 595 | 99% | 5 | 87 |
PA | 582 | 570 | 97.9% | 23 | 177 |
EC | 547 | 527 | 96.3% | 19 | 97 |
DO | 537 | 530 | 98.7% | 11 | 113 |
GT | 535 | 489 | 91.4% | 15 | 108 |
KE | 523 | 488 | 93.3% | 11 | 185 |
EE | 518 | 507 | 97.9% | 161 | 167 |
TN | 452 | 428 | 94.7% | 21 | 121 |
SI | 426 | 378 | 88.7% | 69 | 113 |
KZ | 367 | 363 | 98.9% | 11 | 114 |
RU | 365 | 251 | 68.8% | 13 | 75 |
MO | 327 | 257 | 78.6% | 4 | 33 |
MV | 311 | 308 | 99% | 0 | 14 |
UY | 310 | 305 | 98.4% | 5 | 88 |
GH | 294 | 278 | 94.6% | 16 | 114 |
KW | 281 | 271 | 96.4% | 5 | 91 |
TZ | 271 | 267 | 98.5% | 2 | 53 |
MU | 254 | 249 | 98% | 5 | 47 |
OM | 247 | 208 | 84.2% | 8 | 62 |
SV | 247 | 240 | 97.2% | 40 | 68 |
BH | 233 | 218 | 93.6% | 2 | 56 |
AM | 230 | 215 | 93.5% | 6 | 80 |
HN | 203 | 194 | 95.6% | 18 | 64 |
BD | 201 | 190 | 94.5% | 8 | 87 |
SR | 201 | 197 | 98% | 12 | 21 |
VE | 201 | 200 | 99.5% | 18 | 57 |
LI | 192 | 189 | 98.4% | 73 | 33 |
CI | 177 | 171 | 96.6% | 1 | 60 |
KY | 176 | 174 | 98.9% | 22 | 49 |
LB | 168 | 166 | 98.8% | 3 | 64 |
CD | 146 | 146 | 100% | 10 | 45 |
NI | 142 | 140 | 98.6% | 3 | 49 |
ME | 141 | 138 | 97.9% | 0 | 13 |
PY | 141 | 140 | 99.3% | 3 | 24 |
DZ | 132 | 128 | 97% | 3 | 43 |
MD | 132 | 125 | 94.7% | 14 | 47 |
MK | 132 | 128 | 97% | 10 | 70 |
BS | 129 | 117 | 90.7% | 2 | 40 |
JM | 128 | 124 | 96.9% | 8 | 56 |
PG | 128 | 128 | 100% | 0 | 32 |
UG | 124 | 123 | 99.2% | 1 | 48 |
NP | 123 | 115 | 93.5% | 1 | 41 |
UZ | 123 | 113 | 91.9% | 6 | 43 |
AZ | 119 | 116 | 97.5% | 4 | 54 |
BA | 118 | 118 | 100% | 3 | 53 |
ZM | 113 | 112 | 99.1% | 0 | 43 |
ET | 112 | 108 | 96.4% | 3 | 46 |
NE | 112 | 112 | 100% | 56 | 29 |
KH | 109 | 107 | 98.2% | 2 | 44 |
JE | 108 | 108 | 100% | 9 | 37 |
AL | 107 | 105 | 98.1% | 7 | 62 |
AW | 107 | 107 | 100% | 0 | 9 |
IQ | 104 | 104 | 100% | 1 | 29 |
XK | 102 | 100 | 98% | 20 | 26 |
BM | 100 | 99 | 99% | 3 | 29 |
BB | 97 | 95 | 97.9% | 4 | 20 |
MZ | 89 | 86 | 96.6% | 0 | 36 |
MM | 88 | 88 | 100% | 1 | 28 |
MG | 84 | 83 | 98.8% | 1 | 24 |
GI | 78 | 78 | 100% | 44 | 19 |
GG | 77 | 77 | 100% | 1 | 30 |
CM | 76 | 73 | 96.1% | 1 | 33 |
SN | 75 | 71 | 94.7% | 1 | 36 |
FJ | 72 | 71 | 98.6% | 1 | 16 |
RW | 72 | 68 | 94.4% | 1 | 34 |
IS | 71 | 71 | 100% | 5 | 30 |
MC | 64 | 64 | 100% | 3 | 24 |
NA | 64 | 64 | 100% | 0 | 27 |
PF | 63 | 63 | 100% | 3 | 11 |
BO | 61 | 61 | 100% | 1 | 30 |
GU | 59 | 56 | 94.9% | 6 | 28 |
GY | 57 | 53 | 93% | 0 | 11 |
LA | 57 | 57 | 100% | 4 | 29 |
SC | 57 | 57 | 100% | 0 | 12 |
AO | 56 | 56 | 100% | 0 | 24 |
SS | 55 | 46 | 83.6% | 0 | 13 |
TT | 55 | 52 | 94.5% | 0 | 22 |
IM | 53 | 50 | 94.3% | 7 | 23 |
SO | 49 | 49 | 100% | 1 | 21 |
SL | 46 | 46 | 100% | 23 | 15 |
BY | 42 | 42 | 100% | 1 | 19 |
BJ | 41 | 40 | 97.6% | 1 | 16 |
IR | 41 | 41 | 100% | 0 | 13 |
SY | 38 | 38 | 100% | 5 | 26 |
SZ | 37 | 37 | 100% | 1 | 7 |
MW | 36 | 36 | 100% | 1 | 20 |
GF | 34 | 34 | 100% | 2 | 14 |
BW | 33 | 33 | 100% | 0 | 17 |
SD | 33 | 27 | 81.8% | 2 | 19 |
BZ | 30 | 25 | 83.3% | 0 | 10 |
MQ | 30 | 30 | 100% | 3 | 17 |
GP | 29 | 29 | 100% | 3 | 14 |
TC | 29 | 29 | 100% | 0 | 3 |
BN | 28 | 28 | 100% | 0 | 13 |
TD | 26 | 24 | 92.3% | 0 | 16 |
HT | 25 | 25 | 100% | 0 | 11 |
IO | 25 | 25 | 100% | 9 | 3 |
LY | 25 | 25 | 100% | 0 | 8 |
RE | 25 | 25 | 100% | 4 | 16 |
165 countries with 25 or more live rows (236 with at least one: the countries figure of /public/coverage), rendered from /public/coverage/countries, which needs no key. Only ISO 3166-1 codes (plus XK) count as countries. It is not a list of the world’s countries and it moves as employers post and roles close. Measured 27 October 2026, 10:52 UTC.
The same vocabulary, with live counts, is enumerable in code from `/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 to send | Label | Live rows |
|---|---|---|
US | US | 1,391,884 |
DE | DE | 180,303 |
GB | GB | 97,406 |
IN | IN | 88,057 |
CA | CA | 67,551 |
FR | FR | 59,228 |
NL | NL | 40,967 |
SE | SE | 22,438 |
AU | AU | 21,760 |
MX | MX | 19,327 |
ES | ES | 18,583 |
SG | SG | 15,483 |
CN | CN | 15,277 |
IT | IT | 14,277 |
BE | BE | 12,453 |
MY | MY | 12,246 |
PL | PL | 12,153 |
BR | BR | 12,120 |
JP | JP | 11,722 |
PH | PH | 11,418 |
CH | CH | 11,343 |
AT | AT | 9,261 |
AE | AE | 8,709 |
IE | IE | 7,698 |
PT | PT | 5,847 |
TH | TH | 5,687 |
NO | NO | 5,573 |
TW | TW | 5,359 |
ZA | ZA | 5,023 |
ID | ID | 4,860 |
HU | HU | 4,779 |
VN | VN | 4,654 |
RO | RO | 4,645 |
HK | HK | 4,182 |
DK | DK | 3,857 |
SA | SA | 3,806 |
GR | GR | 3,796 |
FI | FI | 3,732 |
CO | CO | 3,682 |
KR | KR | 3,129 |
NZ | NZ | 3,116 |
CZ | CZ | 3,054 |
AR | AR | 2,570 |
IL | IL | 2,551 |
EG | EG | 2,494 |
PE | PE | 2,028 |
CL | CL | 1,987 |
UA | UA | 1,979 |
TR | TR | 1,952 |
BG | BG | 1,894 |
LT | LT | 1,781 |
LU | LU | 1,411 |
MA | MA | 1,410 |
SK | SK | 1,339 |
CR | CR | 1,148 |
QA | QA | 1,048 |
PK | PK | 1,044 |
RS | RS | 988 |
CY | CY | 825 |
NG | NG | 720 |
PR | PR | 707 |
GE | GE | 686 |
HR | HR | 674 |
MT | MT | 654 |
JO | JO | 647 |
LV | LV | 636 |
LK | LK | 601 |
PA | PA | 582 |
EC | EC | 547 |
DO | DO | 537 |
GT | GT | 535 |
KE | KE | 523 |
EE | EE | 518 |
TN | TN | 452 |
SI | SI | 426 |
KZ | KZ | 367 |
RU | RU | 365 |
MO | MO | 327 |
MV | MV | 311 |
UY | UY | 310 |
GH | GH | 294 |
KW | KW | 281 |
TZ | TZ | 271 |
MU | MU | 254 |
OM | OM | 247 |
SV | SV | 247 |
BH | BH | 233 |
AM | AM | 230 |
HN | HN | 203 |
BD | BD | 201 |
SR | SR | 201 |
VE | VE | 201 |
LI | LI | 192 |
CI | CI | 177 |
KY | KY | 176 |
LB | LB | 168 |
CD | CD | 146 |
NI | NI | 142 |
ME | ME | 141 |
PY | PY | 141 |
DZ | DZ | 132 |
MD | MD | 132 |
MK | MK | 132 |
BS | BS | 129 |
JM | JM | 128 |
PG | PG | 128 |
UG | UG | 124 |
NP | NP | 123 |
UZ | UZ | 123 |
AZ | AZ | 119 |
BA | BA | 118 |
ZM | ZM | 113 |
ET | ET | 112 |
NE | NE | 112 |
KH | KH | 109 |
JE | JE | 108 |
AL | AL | 107 |
AW | AW | 107 |
IQ | IQ | 104 |
XK | XK | 102 |
BM | BM | 100 |
BB | BB | 97 |
MZ | MZ | 89 |
MM | MM | 88 |
MG | MG | 84 |
GI | GI | 78 |
GG | GG | 77 |
CM | CM | 76 |
SN | SN | 75 |
FJ | FJ | 72 |
RW | RW | 72 |
IS | IS | 71 |
MC | MC | 64 |
NA | NA | 64 |
PF | PF | 63 |
BO | BO | 61 |
GU | GU | 59 |
GY | GY | 57 |
LA | LA | 57 |
SC | SC | 57 |
AO | AO | 56 |
SS | SS | 55 |
TT | TT | 55 |
IM | IM | 53 |
SO | SO | 49 |
SL | SL | 46 |
BY | BY | 42 |
BJ | BJ | 41 |
IR | IR | 41 |
SY | SY | 38 |
SZ | SZ | 37 |
MW | MW | 36 |
GF | GF | 34 |
BW | BW | 33 |
SD | SD | 33 |
BZ | BZ | 30 |
MQ | MQ | 30 |
GP | GP | 29 |
TC | TC | 29 |
BN | BN | 28 |
TD | TD | 26 |
HT | HT | 25 |
IO | IO | 25 |
LY | LY | 25 |
RE | RE | 25 |
GA | GA | 24 |
GD | GD | 22 |
GN | GN | 22 |
PS | PS | 22 |
KN | KN | 21 |
MN | MN | 21 |
VI | VI | 21 |
DJ | DJ | 20 |
TG | TG | 20 |
CU | CU | 19 |
CW | CW | 19 |
AQ | AQ | 18 |
WS | WS | 18 |
AD | AD | 17 |
LC | LC | 17 |
AF | AF | 16 |
GM | GM | 16 |
ML | ML | 16 |
BF | BF | 15 |
YT | YT | 15 |
ZW | ZW | 15 |
CF | CF | 14 |
TJ | TJ | 14 |
TO | TO | 13 |
YE | YE | 13 |
KP | KP | 12 |
SB | SB | 12 |
GL | GL | 11 |
KG | KG | 11 |
MP | MP | 11 |
GQ | GQ | 10 |
SX | SX | 10 |
LS | LS | 9 |
MH | MH | 9 |
SM | SM | 9 |
FM | FM | 8 |
BI | BI | 7 |
KI | KI | 7 |
NC | NC | 7 |
VG | VG | 7 |
AI | AI | 6 |
AX | AX | 6 |
CG | CG | 6 |
FO | FO | 6 |
VA | VA | 6 |
VU | VU | 6 |
AG | AG | 5 |
CV | CV | 5 |
LR | LR | 5 |
MS | MS | 5 |
TM | TM | 5 |
AS | AS | 4 |
PW | PW | 4 |
SH | SH | 4 |
VC | VC | 4 |
CC | CC | 3 |
MR | MR | 3 |
TL | TL | 3 |
BT | BT | 2 |
CX | CX | 2 |
GS | GS | 2 |
KM | KM | 2 |
UM | UM | 2 |
BL | BL | 1 |
BQ | BQ | 1 |
DM | DM | 1 |
ER | ER | 1 |
FK | FK | 1 |
GW | GW | 1 |
PM | PM | 1 |
ST | ST | 1 |
236 values, rendered from /public/facets (keyless): the same data as /v1/meta/facets. Enumerate it at run time rather than hard-coding this list.
Exact contractTypes, defaults, ranges, errors, edge cases
Technical detail: how the filter actually works.
countrystringcomma-separatedComma-separated ISO-3166 alpha-2, at most 60 codes (more is a 422 too_many_values, never truncated).
exclude_countrystring2 parameters, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug. Please say so with the thumbs-down below.
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.
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.
# Needs a free key now (see Keyless access). Swap FR for any code in the list above. $ curl -s -H "Authorization: Bearer $JOA_KEY" 'https://api.jobopportunitiesapi.org/v1/jobs?country=FR&limit=2' \ | jq '.data[] | {title, company, city, country}'
$ curl -s -H "Authorization: Bearer $JOA_KEY" 'https://api.jobopportunitiesapi.org/v1/jobs?country=DE,AT,CH&limit=3' \
| jq -r '.data[] | "\(.country) \(.company) \(.title)"'
curl -s https://api.jobopportunitiesapi.org/public/coverage/countries \
| jq -r '.countries[] | "\(.country)\t\(.live)"' \
| sort -k2 -rn | head -20curl -s -H "Authorization: Bearer $JOA_KEY" \
'https://api.jobopportunitiesapi.org/v1/jobs?exclude_country=US&limit=5' \
| jq -r '.data[] | "\(.country // "—") \(.title)"'5Classification: 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.
In depthWhy it exists, what it is not, what people get wrong
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.
The classifier's pattern rules cover French, German, Spanish and, as of this writing, Greek and Italian, added specifically to improve recall for titles written in those languages rather than defaulting them to uncategorised.
country_confidence=high keeps only jobs that have a country that is not low-trust: published by the source, set by a rule of ours, or agreed by the gazetteer and a language model (measured precision 99.5%). country_confidence=low returns only the jobs whose country a language model alone inferred (measured precision about 95%). Omit it to receive everything; read field_sources.country to see each row's basis. Any other value is a 422 bad_country_confidence.
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. employment_type matches exactly the value the response shows, including the employment type read from the posting page itself (since 6 October 2026; it previously matched only the harvested value, about a sixth as many rows). full_time and part_time are accepted as spellings of full-time and part-time. Coverage, measured 23 October 2026: 39.3% of served rows carry an employment type; the rest are not_stated because the source publishes none. A contract-duration word given without hours (Permanent, Regular, CDI, Employee) is not read as Full-time.
The valid values, stated plainly so neither you nor your model has to guess: seniority: Intern, Entry, Mid, Senior, Lead, Manager, Executive, Director, not_stated. employment_type: Full-time, Part-time, Contract, Internship, Temporary, not_stated. remote: remote, hybrid, on_site, not_stated. These four lists are the MCP tool descriptions verbatim.
The category list, inline so neither you nor your model has to guess: Healthcare, Engineering, Operations & Admin, Sales, uncategorised, Skilled Technician, Retail, Hospitality, Finance, Logistics & Transport, Customer Support, Marketing, Education, Manufacturing, HR & Recruiting, Data & Analytics, Consulting & Strategy, Construction & Trades, Legal & Compliance, Design, Product, Science & Research, Security, Procurement, Safety & Environment. This is the MCP tool description verbatim. q is free-text search; category is this fixed list. An LLM reaching for category=engineer wants q=engineer or category=Engineering. All facet filters match case-insensitively (values are lower-cased and surrounding whitespace trimmed before matching), so lowercase lists work exactly as well; you do not need to preserve our casing.
Exact contractTypes, defaults, ranges, errors, edge cases
categorystringcomma-separatedComma-separated. uncategorised selects rows with no confident classification.
remotestringremote, hybrid, on_site, or not_stated.
employment_typestringcomma-separatedComma-separated: full-time, part-time, contract, temporary, internship
(case-insensitive; full_time / part_time are accepted too); not_stated
selects rows with none. Matches exactly the employment_type value the
response shows, including values read from the posting page by the
verification sweep (since 6 Oct 2026; before that only the harvested
value was matched, about a sixth as many rows).
Coverage, measured 23 Oct 2026: 39.3% of served rows carry a value
(942,302 of 2,399,121); the rest say not_stated, because the source
publishes no employment type. A contract-duration word alone
(Permanent, Regular, CDI, Employee) is not read as Full-time.
senioritystringcomma-separatedComma-separated; not_stated selects rows with none.
exclude_categorystringremote_confirmedbooleantrue returns only listings whose remote status the SOURCE stated:
284,334 of 2,437,027 live rows (11.7%).
Without it you also receive the 2,147,930
(88.1%) where we inferred it from the location text, the
title, or the presence of a named workplace city.
6 parameters, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug. Please say so with the thumbs-down below.
| Value to send | Label | Live rows |
|---|---|---|
Healthcare | Healthcare | 349,929 |
Engineering | Engineering | 274,086 |
Operations & Admin | Operations & Admin | 261,392 |
Sales | Sales | 213,370 |
Skilled Technician | Skilled Technician | 171,169 |
uncategorised | Uncategorised | 159,934 |
Retail | Retail | 151,910 |
Hospitality | Hospitality | 133,041 |
Finance | Finance | 111,334 |
Logistics & Transport | Logistics & Transport | 79,320 |
Customer Support | Customer Support | 66,253 |
Education | Education | 64,600 |
Manufacturing | Manufacturing | 57,892 |
Marketing | Marketing | 56,928 |
HR & Recruiting | HR & Recruiting | 42,478 |
Consulting & Strategy | Consulting & Strategy | 38,418 |
Construction & Trades | Construction & Trades | 37,056 |
Data & Analytics | Data & Analytics | 36,905 |
Legal & Compliance | Legal & Compliance | 28,378 |
Design | Design | 21,041 |
Product | Product | 19,659 |
Science & Research | Science & Research | 19,336 |
Security | Security | 19,331 |
Procurement | Procurement | 12,534 |
Safety & Environment | Safety & Environment | 9,849 |
25 values, rendered from /public/facets (keyless): the same data as /v1/meta/facets. Enumerate it at run time rather than hard-coding this list.
The category vocabulary, with live counts.
| Value to send | Label | Live rows |
|---|---|---|
not_stated | Not stated | 1,338,881 |
Manager | Manager | 314,907 |
Entry | Entry | 243,379 |
Senior | Senior | 155,078 |
Mid | Mid | 121,831 |
Intern | Intern | 92,841 |
Lead | Lead | 90,061 |
Director | Director | 64,346 |
Executive | Executive | 14,819 |
9 values, rendered from /public/facets (keyless): the same data as /v1/meta/facets. Enumerate it at run time rather than hard-coding this list.
| Value to send | Label | Live rows |
|---|---|---|
not_stated | Not stated | 1,464,286 |
Full-time | Full-time | 781,890 |
Part-time | Part-time | 144,964 |
Contract | Contract | 24,307 |
Temporary | Temporary | 11,452 |
Internship | Internship | 9,244 |
6 values, rendered from /public/facets (keyless): the same data as /v1/meta/facets. Enumerate it at run time rather than hard-coding this list.
| Value to send | Label | Live rows |
|---|---|---|
on_site | On Site | 2,159,022 |
remote | Remote | 149,909 |
hybrid | Hybrid | 121,358 |
not_stated | Not stated | 5,854 |
4 values, rendered from /public/facets (keyless): the same data as /v1/meta/facets. 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.
6Contract: engagement, contract category, agency, working schedule
Four filters over the contractual arrangement a posting states. Values exist only where the posting or its source states them; unknown is the honest, filterable answer for the rest, which at launch is most rows.
In depthWhy it exists, what it is not, what people get wrong
engagement- The relationship:
employee(an employment contract with some employer of record, including temp-agency and zero-hours work),vendor(a commercial/self-employed arrangement: freelance, B2B, 1099, C2C, Brazilian PJ),other(a stated hybrid national form that everyday understanding puts on neither side), orunknown. contract_category- The contract kind:
permanent,fixed_term,project_based,seasonal,apprenticeship,internship,other, orunknown.permanentreturns only postings that STATE permanence (CDI, unbefristet, tempo indeterminato, …), never a default reading of “Full-time”. agency- Who the employer of record is:
direct,agency(a temporary-work agency; the engagement is thenemployee, and the category describes the contract with the agency),umbrella, orunknown. working_schedulefull_time,part_time,flexible(either schedule offered), orunknown. Independent of the frozenemployment_typefield, which is unchanged.
engagement and contract_category are arrays on the row, because a real class of adverts offers two arrangements: Brazilian “CLT ou PJ”, Polish “UoP lub B2B”. The main option comes first and a filter matches either element. Most arrays have exactly one element.
engagement=other deserves a word, because it is a per-country decision, not a catch-all. Some national contract forms are genuinely hybrid: a Polish umowa zlecenie (mandate contract) or umowa o dzieło (contract for a specific work) is a civil-law contract commonly used for ordinary jobs; an Italian co.co.co. and a Spanish TRADE are dependent self-employment; a French portage salarial and a UK umbrella arrangement are legally employment but economically freelancing; a French stage or Brazilian estágio is a statutory non-employment internship. Everyday understanding puts none of these cleanly on the employee or the vendor side, so JOA reports them as other and keeps the raw national term internally rather than mislabelling them. Forms that everyday understanding DOES settle are mapped that way: a German Werkstudent or Minijob is a job, so it is employee (with contract_category=other); a US 1099 role is vendor.
Nothing here is ever inferred from silence. A posting that states no contract kind is unknown, not “probably permanent”: US postings rarely state one, German and French postings often do, and the per-field counts on /public/coverage say exactly how much is stated versus established at any moment.
Exact contractTypes, defaults, ranges, errors, edge cases
engagementstringcomma-separatedComma-separated (OR, up to 8, case-insensitive): employee, vendor,
other, or unknown (alias not_stated). The employment RELATIONSHIP
the posting states: employee is payroll employment under the posting
country's law (including temp-agency and zero-hours work); vendor is
a commercial/self-employed arrangement (freelance, B2B, 1099, C2C,
Brazilian PJ); other is a stated hybrid national form that everyday
understanding puts on neither side (Polish umowa zlecenie, Italian
co.co.co., umbrella/portage arrangements, statutory non-employment
internships, volunteering). The raw national term is retained
internally and refines these over time. The row value is an ARRAY
(dual-option adverts such as "CLT ou PJ" carry both options, main one
first); the filter matches any element. unknown selects rows where
the posting does not establish the relationship (nothing is ever
inferred from silence), so at launch most rows are unknown. An
unrecognised value is a 422 naming the legal list.
contract_categorystringcomma-separatedComma-separated (OR, up to 8, case-insensitive): permanent,
fixed_term, project_based, seasonal, apprenticeship,
internship, other, or unknown (alias not_stated). The contract
KIND the posting states. contract_category=permanent returns only
postings that STATE permanence (CDI, unbefristet, tempo indeterminato,
contrato indefinido, ...). A posting that merely says "Full-time" is
unknown, because most employers do not state the contract kind and
this API does not guess it. The row value is an ARRAY (dual-option
adverts); the filter matches any element. other is a stated form none
of the named values can represent (zero-hours, casual, per diem,
Minijob, Werkstudent, ...), never a dumping ground for uncertainty.
agencystringcomma-separatedComma-separated (OR, case-insensitive): direct, agency,
umbrella, or unknown (alias not_stated). Who the employer of
record is: the advertiser itself, a temporary-work agency (the
engagement is then employee, because EU and national law make agency workers
employees of the agency, and contract_category describes the
contract WITH the agency), or an umbrella / portage company. unknown
selects rows where it is not established.
working_schedulestringcomma-separatedComma-separated (OR, case-insensitive): full_time, part_time,
flexible, or unknown (alias not_stated). The stated schedule;
flexible covers adverts offering either schedule. Distinct from the
frozen employment_type field, which this API keeps unchanged.
4 parameters, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug. Please say so with the thumbs-down below.
Comma = OR within a parameter, up to 8 values, case-insensitive; not_stated is accepted as an alias of unknown for symmetry with the older facets. An unrecognised value is a 422 naming the legal list. field_sources carries published / inferred / absent per field, require_fields accepts all four names (published-only), and the <field>_confidence keys appear only on inferred values.
7Provenance 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.
In depthWhy it exists, what it is not, what people get wrong
source_type- The provenance bucket:
ats,career_site,public_agency. The legacy spellingsemployer_ats,governmentanddirectare accepted and mapped.aggregatorandagencyare reserved and match nothing. provider- The exact system:
greenhouse,workday,company_site. Finer thansource_type. Up to twelve values; an unknown one is a 422. statuslive(default),closed, orany. Paid endpoints only.qualityallre-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
completenessblock.
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, or an advert whose own posted_at is more than 24 months old. Since 13 Oct 2026 those stale adverts are a default-hidden class of their own (stale_posting; rows with no posted_at are never gated). 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.
Exact contractTypes, defaults, ranges, errors, edge cases
statusstringdefault liveliveclosedanylive (default) returns open vacancies. closed returns roles that
have left their source. any returns both; every row carries
status. Paid endpoints only.
include_poster_typestringcomma-separatedstaffingjobboardallRe-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 thousands of listings (18,000 in one case, measured 19 Aug 2026) can flood a category until search stops being useful. Comma-separated. Paid endpoints only. An unrecognised value is a 422.
qualitystringallall 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.
providerstringcomma-separatedComma-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_providerstringSame vocabulary as provider, removed instead of kept.
source_typestringcomma-separatedComma-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_typestringrequire_fieldsstringcomma-separatedComma-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 12.6% of your rows have a
salary". They do, and require_fields=salary returns
307,796 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, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug. Please say so with the thumbs-down below.
| Value to send | Label | Live rows |
|---|---|---|
ats | Employer ATS | 1,614,542 |
career_site | Company career site | 807,829 |
public_agency | Public employment agency | 13,772 |
3 values, rendered from /public/facets (keyless): the same data as /v1/meta/facets. 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.
8Company: 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.
In depthWhy it exists, what it is not, what people get wrong
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.
Exact contractTypes, defaults, ranges, errors, edge cases
companystringcomma-separatedComma-separated company slugs.
company_domainstringcomma-separatedComma-separated bare domains, e.g. stripe.com,figma.com: no scheme
and no path, at most 100 (more is a 422 too_many_values, never
truncated). 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_domainstringSame vocabulary as company_domain, removed instead of kept.
3 parameters, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug. Please say so with the thumbs-down below.
limitintegerdefault 251 to 200offsetinteger0 to 100000cursorstringThe next_cursor from the previous page. Overrides offset.
countrystringorg_typestringsource_typestringhas_websitebooleanOnly 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_discoveredbooleanAlso 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.
qstring9 parameters, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug. Please say so with the thumbs-down below.
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)"'9Text: 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.
In depthWhy it exists, what it is not, what people get wrong
q- Full text over title, company name and location. Not the description. q is free-text search; for the fixed job-family list use
category(see Classification). 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.
Exact contractTypes, defaults, ranges, errors, edge cases
has_descriptionbooleantrue returns only the 2,318,210 live rows (95.1%) that carry a description.
include_descriptionbooleanReturn the full advert text in a description field. Off by default:
descriptions averaged 2,581 bytes (measured 5 Aug 2026), 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.
Personal email addresses in the text are replaced with
[personal contact removed]; company mailboxes and phone numbers are
kept (see the description field).
qstringFull-text over title, company name and location, NOT the description.
That is deliberate, not a limitation: use description_contains for
the advert body. Terms are ANDed. Without extended syntax a term
matches whole words only (no stemming): q=engineer does not match
engineering. Extended syntax, applied whenever it is used:
- "customer support": exact phrase, adjacent and in order.
- entwickler*: prefix match, which finds entwicklerin and the German
gender-inclusive entwickler/in (one token to the tokenizer).
Inside an extended query, terms of 5+ letters are prefix-matched
automatically (remote finds remotely); quote a term for an
exact match.
- developer OR entwickler: either term. OR binds LOOSER than
adjacency (2026-10-25): a b OR c d is (a b) OR (c d), so four
word-pair alternatives written one after the other really are four
alternatives. Until that date OR bound tighter and the same query
read as a AND (b OR c) AND d.
- (a OR b) c: parentheses group. They decide what an OR binds to.
Around plain words with no OR inside they change nothing.
- -berlin: exclude rows matching this term. The dash must touch the
term; a free-standing dash is punctuation.
A value the grammar cannot honour is refused with 422
query_syntax (an unmatched parenthesis, an empty group, an OR
with no term on one side, a bare AND or NOT), never read as
something else. Nothing is charged.
Matching is case-insensitive in every script and accent-insensitive
(2026-10-23): λογιστής, Λογιστής and ΛΟΓΙΣΤΗΣ are one query,
and developpeur finds Développeur as développeur does.
Localized syntax examples, one per customer language: German
entwickler* and the exact phrase "software entwickler"; French
développeur* and "service client"; Spanish
desarrollador* and "marketing digital"; Dutch verpleegkundige*
(prefix finds the plural). Works the same in every language the
ledger carries.
titlestringFull-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.
Extended syntax (2026-10-07): quoted phrases ("software entwickler"),
prefix (entwickler* or any bare word of 5+ letters), OR, and
-word exclusions work exactly as they do on q, matched against
the title only. Parentheses group and OR binds last (2026-10-25),
as on q: a b OR c d is (a b) OR (c d). A plain value keeps the old AND-of-words behaviour.
Case-insensitive in every script and accent-insensitive, as q
(2026-10-23): title=ärztin, title=Ärztin and title=arztin
are one query.
title_excludestringDrop rows whose title matches these words. Same matching, and the
same extended syntax (phrases, prefix, OR, exclusions,
parentheses), as title.
description_containsstringFull-text over the advert body. Only ever matches rows that have one,
so it implies has_description=true.
Extended syntax (2026-10-07): quoted phrases ("customer support"),
prefix (kundenbetreuung* or any bare word of 5+ letters), OR, and
-word exclusions work exactly as they do on q, with parentheses
grouping and OR binding last (2026-10-25). A phrase matches
the words in that order, anywhere in the body. Broad phrases over
millions of adverts are the most expensive query this API can be
asked; prefer a country or category alongside. A plain value keeps
the old AND-of-words behaviour. Case-insensitive in every script
(2026-10-23) for words the advert writes in lower case, Capitalised
or in capitals. An accented term also finds the unaccented spelling;
an unaccented term does not yet find accented advert text.
6 parameters, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug. Please say so with the thumbs-down below.
10Salary
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.
In depthWhy it exists, what it is not, what people get wrong
has_salary=true (and its alias structured) returns only rows whose salary the source published: the row's own structured salary, or a figure our verification pass read straight off the source's own page. has_salary=any also includes figures we read out of the advert text, and it keeps every extract figure too, so it stays a superset of true. 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. They compare every normalisable figure, published or read out of the advert text (field_sources.salary and salary_source say which); add has_salary=true to compare only salaries the source published.
Annual salaries below a per-country floor (about a fifth of the lowest published full-time salaries; e.g. 6,400 EUR in the US, 5,800 in Germany) are withheld: they are benefits or monthly figures mislabelled as annual pay.
Exact contractTypes, defaults, ranges, errors, edge cases
has_salarystringtruestructuredanytrue (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_salarynumberLower bound on salary_min_annual_eur. Only rows whose salary could be
normalised to an annual EUR figure can match (the current share is on
/public/coverage, field salary_eur), so this is a narrow filter by
nature, not a broken one. It compares every normalisable figure,
published by the source or read out of the advert text
(field_sources.salary and salary_source say which); add
has_salary=true to compare only salaries the source published.
max_salarynumberUpper bound on salary_min_annual_eur. Same scope as min_salary.
3 parameters, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug. Please say so with the thumbs-down below.
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.
11Time: listed_after, posted_after and verified_after
Three different questions: what reached this API since a date, what the employer posted since a date, and what was re-confirmed at its source since a date.
In depthWhy it exists, what it is not, what people get wrong
listed_after filters on the moment a row entered this API: a new row, or one that came back after a withdrawal or a closure, each counted from its latest entry. It is the filter for “what is new since my last run”, on every plan. 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 positively re-confirmed at its source since yesterday” (the posting's own page or ATS API record found live, or the posting listed in a fresh read of the employer's job list; otherwise its first harvest).
Exact contractTypes, defaults, ranges, errors, edge cases
posted_afterstringDate or RFC3339 timestamp.
verified_afterstringOnly rows whose last_verified_at (the last positive confirmation at the source, see the Job schema) is at or after this instant.
listed_afterstringOnly rows that entered this API after this instant (date or RFC3339 timestamp), as recorded by the served-set ledger behind /v1/changes: new rows, and rows that came back after a withdrawal or re-opened after a closure, each counted from its latest entry. Use it to poll for new jobs, on any plan. posted_after cannot do that: posted_at is the employer's own date and is often days older than the moment a job reaches the API. Rows served since before 2026-10-23 06:11 UTC count from their first_seen_at. Poll with listed_after = <start of your previous poll> minus a few minutes and de-duplicate on id. On /v1/jobs/closed it selects closed rows that had entered after this instant.
3 parameters, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug. Please say so with the thumbs-down below.
All three accept RFC3339 (2026-08-01T00:00:00Z) or a bare date (2026-08-01). An unparseable value is a 422 (bad_listed_after, bad_posted_after, bad_verified_after), 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.
12Controlled 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.
In depthWhy it exists, what it is not, what people get wrong
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 |
engagement | fixed: employee, vendor, other, unknown; counts at /public/facets → engagement | No |
contract_category | fixed 7-value list plus unknown; counts at /public/facets → contract_category | No |
agency | fixed: direct, agency, umbrella, unknown | No |
working_schedule | fixed: full_time, part_time, flexible, unknown | 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.
Exact contractTypes, defaults, ranges, errors, edge cases
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]} …")13The complete table
Every parameter /v1/jobs accepts, in the order the specification lists them, with nothing omitted.
In depthWhy it exists, what it is not, what people get wrong
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.
Exact contractTypes, defaults, ranges, errors, edge cases
limitintegerdefault 251 to 2001-200 (default 25). Anything else is a 422 bad_limit, not clamped.
cursorstringThe next_cursor from the previous page.
collapsestringcompanyEmployer flood control, opt-in (24 Oct 2026). company returns at
most ONE row per employer per request: the newest row the walk reaches
for that company in the published order, with the roles behind it read
and hidden. Every returned row then carries
company_more_roles. Without this parameter the response is exactly
what it has always been.
The rows behind a collapsed row are NOT deferred: next_cursor
resumes after the last row the request decided on, so a hidden row
never returns on a later page. A collapsed page therefore carries at
most limit rows and often fewer (one employer flooding the window
collapses to one row). Ordering is never changed: the published
posted_at DESC NULLS LAST, id DESC contract holds. Counts are
untouched: include_total and every count surface still count
matching ROWS, not employers. Only the rows actually returned are
charged as records.
max_per_companyinteger1 to 200Employer flood control, opt-in (24 Oct 2026): at most N rows of one
employer per request (1-200). Rows beyond N are read and hidden where
they stand, never deferred to a later page, so a page carries at most
limit rows and often fewer. Counts (include_total, the facet
chips) still count every matching row. max_per_company=1 agrees
with collapse=company and may be sent with it; any other value
together with collapse=company is a 422
flood_params_conflict, because the two contradict each other.
Unlike collapse=company it adds no field to the rows.
statusstringdefault liveliveclosedanylive (default) returns open vacancies. closed returns roles that
have left their source. any returns both; every row carries
status. Paid endpoints only.
include_poster_typestringcomma-separatedstaffingjobboardallRe-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 thousands of listings (18,000 in one case, measured 19 Aug 2026) can flood a category until search stops being useful. Comma-separated. Paid endpoints only. An unrecognised value is a 422.
qualitystringallall 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.
categorystringcomma-separatedComma-separated. uncategorised selects rows with no confident classification.
familystringAlias for category (the facet key in /v1/meta/facets is named
family; the filter parameter was category). Both names are
accepted everywhere category is.
countrystringcomma-separatedComma-separated ISO-3166 alpha-2, at most 60 codes (more is a 422 too_many_values, never truncated).
citystringcomma-separatedComma-separated city names, matched exactly but case-insensitively in
every script (city=αθήνα finds Αθήνα); accents must match. A
two-letter term that is a US state code or a country code (what
country= accepts, plus UK for GB) qualifies the other city terms
instead of naming a second city, so city=Exeter, RI returns only
Exeter in Rhode Island: the US state reading wins a collision, and a
qualifier with no city term left (such as city=GB) is still matched
as a city value.
statestringcomma-separatedComma-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.
remotestringremote, hybrid, on_site, or not_stated.
employment_typestringcomma-separatedComma-separated: full-time, part-time, contract, temporary, internship
(case-insensitive; full_time / part_time are accepted too); not_stated
selects rows with none. Matches exactly the employment_type value the
response shows, including values read from the posting page by the
verification sweep (since 6 Oct 2026; before that only the harvested
value was matched, about a sixth as many rows).
Coverage, measured 23 Oct 2026: 39.3% of served rows carry a value
(942,302 of 2,399,121); the rest say not_stated, because the source
publishes no employment type. A contract-duration word alone
(Permanent, Regular, CDI, Employee) is not read as Full-time.
engagementstringcomma-separatedComma-separated (OR, up to 8, case-insensitive): employee, vendor,
other, or unknown (alias not_stated). The employment RELATIONSHIP
the posting states: employee is payroll employment under the posting
country's law (including temp-agency and zero-hours work); vendor is
a commercial/self-employed arrangement (freelance, B2B, 1099, C2C,
Brazilian PJ); other is a stated hybrid national form that everyday
understanding puts on neither side (Polish umowa zlecenie, Italian
co.co.co., umbrella/portage arrangements, statutory non-employment
internships, volunteering). The raw national term is retained
internally and refines these over time. The row value is an ARRAY
(dual-option adverts such as "CLT ou PJ" carry both options, main one
first); the filter matches any element. unknown selects rows where
the posting does not establish the relationship (nothing is ever
inferred from silence), so at launch most rows are unknown. An
unrecognised value is a 422 naming the legal list.
contract_categorystringcomma-separatedComma-separated (OR, up to 8, case-insensitive): permanent,
fixed_term, project_based, seasonal, apprenticeship,
internship, other, or unknown (alias not_stated). The contract
KIND the posting states. contract_category=permanent returns only
postings that STATE permanence (CDI, unbefristet, tempo indeterminato,
contrato indefinido, ...). A posting that merely says "Full-time" is
unknown, because most employers do not state the contract kind and
this API does not guess it. The row value is an ARRAY (dual-option
adverts); the filter matches any element. other is a stated form none
of the named values can represent (zero-hours, casual, per diem,
Minijob, Werkstudent, ...), never a dumping ground for uncertainty.
agencystringcomma-separatedComma-separated (OR, case-insensitive): direct, agency,
umbrella, or unknown (alias not_stated). Who the employer of
record is: the advertiser itself, a temporary-work agency (the
engagement is then employee, because EU and national law make agency workers
employees of the agency, and contract_category describes the
contract WITH the agency), or an umbrella / portage company. unknown
selects rows where it is not established.
working_schedulestringcomma-separatedComma-separated (OR, case-insensitive): full_time, part_time,
flexible, or unknown (alias not_stated). The stated schedule;
flexible covers adverts offering either schedule. Distinct from the
frozen employment_type field, which this API keeps unchanged.
senioritystringcomma-separatedComma-separated; not_stated selects rows with none.
providerstringcomma-separatedComma-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_providerstringSame vocabulary as provider, removed instead of kept.
source_typestringcomma-separatedComma-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_typestringcompanystringcomma-separatedComma-separated company slugs.
exclude_categorystringexclude_countrystringlanguagestringcomma-separatedISO 639-1 two-letter language code of the posting text (e.g. de, fr, en).
Comma-separated, up to 12 languages (OR).
language_excludestringcomma-separatedExclude postings in these ISO 639-1 language codes. Comma-separated, up to 12 languages. Rows with undetected language are kept.
country_confidencestringhighlowServer-side filter on how trustworthy the job's country is (see field_sources.country).
high keeps only rows that have a country that is NOT low-trust: published, or inferred
by a rule or by gazetteer and language model agreeing (measured precision about 99.5%).
low returns only the llm_inferred rows, whose country a language model alone read out of
the location text (measured precision about 95%). Omit it to receive everything. Any other
value is a 422 bad_country_confidence and no records are billed.
compensation_typestringcommission_onlyServer-side filter for commission-only postings (db/370, DQ rule v5).
The only legal value is commission_only; any other value is a 422
bad_compensation_type and no records are billed. Informational field
(see the compensation_type description on the Job schema).
exclude_compensation_typestringcommission_onlyExclude postings the commission-only rule flagged (db/370). The only
legal value is commission_only, matching the API's existing
exclude_* convention (exclude_category, exclude_country, ...).
remote_confirmedbooleantrue returns only listings whose remote status the SOURCE stated:
284,334 of 2,437,027 live rows (11.7%).
Without it you also receive the 2,147,930
(88.1%) where we inferred it from the location text, the
title, or the presence of a named workplace city.
has_salarystringtruestructuredanytrue (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_descriptionbooleantrue returns only the 2,318,210 live rows (95.1%) that carry a description.
require_fieldsstringcomma-separatedComma-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 12.6% of your rows have a
salary". They do, and require_fields=salary returns
307,796 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_descriptionbooleanReturn the full advert text in a description field. Off by default:
descriptions averaged 2,581 bytes (measured 5 Aug 2026), 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.
Personal email addresses in the text are replaced with
[personal contact removed]; company mailboxes and phone numbers are
kept (see the description field).
posted_afterstringDate or RFC3339 timestamp.
verified_afterstringOnly rows whose last_verified_at (the last positive confirmation at the source, see the Job schema) is at or after this instant.
listed_afterstringOnly rows that entered this API after this instant (date or RFC3339 timestamp), as recorded by the served-set ledger behind /v1/changes: new rows, and rows that came back after a withdrawal or re-opened after a closure, each counted from its latest entry. Use it to poll for new jobs, on any plan. posted_after cannot do that: posted_at is the employer's own date and is often days older than the moment a job reaches the API. Rows served since before 2026-10-23 06:11 UTC count from their first_seen_at. Poll with listed_after = <start of your previous poll> minus a few minutes and de-duplicate on id. On /v1/jobs/closed it selects closed rows that had entered after this instant.
qstringFull-text over title, company name and location, NOT the description.
That is deliberate, not a limitation: use description_contains for
the advert body. Terms are ANDed. Without extended syntax a term
matches whole words only (no stemming): q=engineer does not match
engineering. Extended syntax, applied whenever it is used:
- "customer support": exact phrase, adjacent and in order.
- entwickler*: prefix match, which finds entwicklerin and the German
gender-inclusive entwickler/in (one token to the tokenizer).
Inside an extended query, terms of 5+ letters are prefix-matched
automatically (remote finds remotely); quote a term for an
exact match.
- developer OR entwickler: either term. OR binds LOOSER than
adjacency (2026-10-25): a b OR c d is (a b) OR (c d), so four
word-pair alternatives written one after the other really are four
alternatives. Until that date OR bound tighter and the same query
read as a AND (b OR c) AND d.
- (a OR b) c: parentheses group. They decide what an OR binds to.
Around plain words with no OR inside they change nothing.
- -berlin: exclude rows matching this term. The dash must touch the
term; a free-standing dash is punctuation.
A value the grammar cannot honour is refused with 422
query_syntax (an unmatched parenthesis, an empty group, an OR
with no term on one side, a bare AND or NOT), never read as
something else. Nothing is charged.
Matching is case-insensitive in every script and accent-insensitive
(2026-10-23): λογιστής, Λογιστής and ΛΟΓΙΣΤΗΣ are one query,
and developpeur finds Développeur as développeur does.
Localized syntax examples, one per customer language: German
entwickler* and the exact phrase "software entwickler"; French
développeur* and "service client"; Spanish
desarrollador* and "marketing digital"; Dutch verpleegkundige*
(prefix finds the plural). Works the same in every language the
ledger carries.
titlestringFull-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.
Extended syntax (2026-10-07): quoted phrases ("software entwickler"),
prefix (entwickler* or any bare word of 5+ letters), OR, and
-word exclusions work exactly as they do on q, matched against
the title only. Parentheses group and OR binds last (2026-10-25),
as on q: a b OR c d is (a b) OR (c d). A plain value keeps the old AND-of-words behaviour.
Case-insensitive in every script and accent-insensitive, as q
(2026-10-23): title=ärztin, title=Ärztin and title=arztin
are one query.
title_excludestringDrop rows whose title matches these words. Same matching, and the
same extended syntax (phrases, prefix, OR, exclusions,
parentheses), as title.
description_containsstringFull-text over the advert body. Only ever matches rows that have one,
so it implies has_description=true.
Extended syntax (2026-10-07): quoted phrases ("customer support"),
prefix (kundenbetreuung* or any bare word of 5+ letters), OR, and
-word exclusions work exactly as they do on q, with parentheses
grouping and OR binding last (2026-10-25). A phrase matches
the words in that order, anywhere in the body. Broad phrases over
millions of adverts are the most expensive query this API can be
asked; prefer a country or category alongside. A plain value keeps
the old AND-of-words behaviour. Case-insensitive in every script
(2026-10-23) for words the advert writes in lower case, Capitalised
or in capitals. An accented term also finds the unaccented spelling;
an unaccented term does not yet find accented advert text.
company_domainstringcomma-separatedComma-separated bare domains, e.g. stripe.com,figma.com: no scheme
and no path, at most 100 (more is a 422 too_many_values, never
truncated). 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_domainstringSame vocabulary as company_domain, removed instead of kept.
min_salarynumberLower bound on salary_min_annual_eur. Only rows whose salary could be
normalised to an annual EUR figure can match (the current share is on
/public/coverage, field salary_eur), so this is a narrow filter by
nature, not a broken one. It compares every normalisable figure,
published by the source or read out of the advert text
(field_sources.salary and salary_source say which); add
has_salary=true to compare only salaries the source published.
max_salarynumberUpper bound on salary_min_annual_eur. Same scope as min_salary.
sortstringrelevancesort=relevance orders by where and how the query matches instead of
recency: relevance-scored, band-weighted (title, category, company,
location), tolerant of equivalents and abbreviations. Ordering-only:
scores are comparable within a response, never across queries. Each
row then carries a relevance object {score, tier} (tier one of
exact, synonym, abbreviation, typo). Requires q and an API key;
q must be under 200 bytes; other sort values are rejected.
Deployment-gated. While the ranked mode is not enabled on the
deployment (the case today), any sort value is refused with
422 sort_unavailable and nothing is charged; results are always
newest first. It is never silently ignored.
include_totalbooleandefault falseAdd total_estimate and total_estimate_status to the response: the
number of rows matching the filters, exact up to 10,000 and
page-independent. Costs one extra bounded query (at most 4 seconds;
past that the page is still returned, with
total_estimate_status: timed_out). Not charged as records.
49 parameters, generated from /openapi.json when this page was built. The spec is served from the running API and is the contract; if this table and the spec ever disagree, the spec is right and this is a bug. Please say so with the thumbs-down below.
This page was rendered 27 October 2026, 11:28 UTC. Every figure on it comes from the endpoint named beside it, and every published request is re-sent against the live API before this site is allowed to build. If something here is wrong, the thumbs-down above reaches a person.