Job Opportunities API

Check the data. Then trust it.

api

Parameters

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

Last verified 2026-10-01 · Assumes: Authentication and pagination. · Markdown copy

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 (country 60, city 12, category 12); a longer list is a 422 too_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. limit is 1 to 200 (1 to 50 with include_description=true); inside that range a value above your plan's page cap is reduced to the cap and flagged in X-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=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.

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 200

1-200 (default 25). Anything else is a 422 bad_limit, not clamped.

cursorstring

The 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-separated

Comma-separated ISO-3166 alpha-2, at most 60 codes (more is a 422 too_many_values, never truncated).

citystringcomma-separated

Comma-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-separated

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_countrystring

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.

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
ISOLiveWith a descriptionDescribedWith a published salaryEmployers
US1,391,8841,332,21595.7%488,13877,320
DE180,303164,48291.2%25,55924,415
GB97,40695,20397.7%28,72213,279
IN88,05777,37487.9%5773,622
CA67,55165,98597.7%26,4205,386
FR59,22857,74997.5%11,7693,810
NL40,96739,09695.4%18,8866,320
SE22,43822,25899.2%5994,826
AU21,76021,42298.4%2,0472,172
MX19,32717,78892%4191,653
ES18,58317,67995.1%2,1182,451
SG15,48315,19298.1%2691,669
CN15,27713,07785.6%1021,216
IT14,27713,98898%3,5731,614
BE12,45311,22490.1%8572,067
MY12,24611,68895.4%89967
PL12,15311,67296%9811,652
BR12,12011,32893.5%2611,358
JP11,72211,30296.4%1,9101,389
PH11,41811,05896.8%4681,232
CH11,34310,87495.9%6082,015
AT9,2618,82395.3%5,2971,867
AE8,7098,24994.7%187980
IE7,6987,45696.9%1,1521,274
PT5,8475,10387.3%392871
TH5,6875,43195.5%90701
NO5,5735,32995.6%2751,113
TW5,3595,18296.7%45568
ZA5,0234,87697.1%190829
ID4,8604,71297%49601
HU4,7794,15887%79521
VN4,6544,25991.5%68558
RO4,6454,28892.3%195758
HK4,1824,03896.6%39708
DK3,8573,75097.2%160834
SA3,8063,52692.6%65596
GR3,7963,64295.9%241592
FI3,7323,29588.3%455744
CO3,6823,55896.6%208748
KR3,1293,03296.9%30654
NZ3,1163,04897.8%287613
CZ3,0542,87394.1%171611
AR2,5702,48396.6%132638
IL2,5512,49497.8%30494
EG2,4942,36394.7%40415
PE2,0281,97397.3%140289
CL1,9871,90695.9%102324
UA1,9791,85593.7%53326
TR1,9521,78791.5%28458
BG1,8941,76193%115380
LT1,7811,72797%911249
LU1,4111,37397.3%99355
MA1,4101,34495.3%15237
SK1,3391,28996.3%493273
CR1,1481,08794.7%21275
QA1,0481,03799%9143
PK1,04499595.3%71246
RS98894395.4%42297
CY82580097%78199
NG72070497.8%5177
PR70765592.6%50133
GE68667598.4%30196
HR67465897.6%43201
MT65463196.5%64191
JO64760994.1%70103
LV63661997.3%286156
LK60159599%587
PA58257097.9%23177
EC54752796.3%1997
DO53753098.7%11113
GT53548991.4%15108
KE52348893.3%11185
EE51850797.9%161167
TN45242894.7%21121
SI42637888.7%69113
KZ36736398.9%11114
RU36525168.8%1375
MO32725778.6%433
MV31130899%014
UY31030598.4%588
GH29427894.6%16114
KW28127196.4%591
TZ27126798.5%253
MU25424998%547
OM24720884.2%862
SV24724097.2%4068
BH23321893.6%256
AM23021593.5%680
HN20319495.6%1864
BD20119094.5%887
SR20119798%1221
VE20120099.5%1857
LI19218998.4%7333
CI17717196.6%160
KY17617498.9%2249
LB16816698.8%364
CD146146100%1045
NI14214098.6%349
ME14113897.9%013
PY14114099.3%324
DZ13212897%343
MD13212594.7%1447
MK13212897%1070
BS12911790.7%240
JM12812496.9%856
PG128128100%032
UG12412399.2%148
NP12311593.5%141
UZ12311391.9%643
AZ11911697.5%454
BA118118100%353
ZM11311299.1%043
ET11210896.4%346
NE112112100%5629
KH10910798.2%244
JE108108100%937
AL10710598.1%762
AW107107100%09
IQ104104100%129
XK10210098%2026
BM1009999%329
BB979597.9%420
MZ898696.6%036
MM8888100%128
MG848398.8%124
GI7878100%4419
GG7777100%130
CM767396.1%133
SN757194.7%136
FJ727198.6%116
RW726894.4%134
IS7171100%530
MC6464100%324
NA6464100%027
PF6363100%311
BO6161100%130
GU595694.9%628
GY575393%011
LA5757100%429
SC5757100%012
AO5656100%024
SS554683.6%013
TT555294.5%022
IM535094.3%723
SO4949100%121
SL4646100%2315
BY4242100%119
BJ414097.6%116
IR4141100%013
SY3838100%526
SZ3737100%17
MW3636100%120
GF3434100%214
BW3333100%017
SD332781.8%219
BZ302583.3%010
MQ3030100%317
GP2929100%314
TC2929100%03
BN2828100%013
TD262492.3%016
HT2525100%011
IO2525100%93
LY2525100%08
RE2525100%416

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 sendLabelLive rows
USUS1,391,884
DEDE180,303
GBGB97,406
ININ88,057
CACA67,551
FRFR59,228
NLNL40,967
SESE22,438
AUAU21,760
MXMX19,327
ESES18,583
SGSG15,483
CNCN15,277
ITIT14,277
BEBE12,453
MYMY12,246
PLPL12,153
BRBR12,120
JPJP11,722
PHPH11,418
CHCH11,343
ATAT9,261
AEAE8,709
IEIE7,698
PTPT5,847
THTH5,687
NONO5,573
TWTW5,359
ZAZA5,023
IDID4,860
HUHU4,779
VNVN4,654
RORO4,645
HKHK4,182
DKDK3,857
SASA3,806
GRGR3,796
FIFI3,732
COCO3,682
KRKR3,129
NZNZ3,116
CZCZ3,054
ARAR2,570
ILIL2,551
EGEG2,494
PEPE2,028
CLCL1,987
UAUA1,979
TRTR1,952
BGBG1,894
LTLT1,781
LULU1,411
MAMA1,410
SKSK1,339
CRCR1,148
QAQA1,048
PKPK1,044
RSRS988
CYCY825
NGNG720
PRPR707
GEGE686
HRHR674
MTMT654
JOJO647
LVLV636
LKLK601
PAPA582
ECEC547
DODO537
GTGT535
KEKE523
EEEE518
TNTN452
SISI426
KZKZ367
RURU365
MOMO327
MVMV311
UYUY310
GHGH294
KWKW281
TZTZ271
MUMU254
OMOM247
SVSV247
BHBH233
AMAM230
HNHN203
BDBD201
SRSR201
VEVE201
LILI192
CICI177
KYKY176
LBLB168
CDCD146
NINI142
MEME141
PYPY141
DZDZ132
MDMD132
MKMK132
BSBS129
JMJM128
PGPG128
UGUG124
NPNP123
UZUZ123
AZAZ119
BABA118
ZMZM113
ETET112
NENE112
KHKH109
JEJE108
ALAL107
AWAW107
IQIQ104
XKXK102
BMBM100
BBBB97
MZMZ89
MMMM88
MGMG84
GIGI78
GGGG77
CMCM76
SNSN75
FJFJ72
RWRW72
ISIS71
MCMC64
NANA64
PFPF63
BOBO61
GUGU59
GYGY57
LALA57
SCSC57
AOAO56
SSSS55
TTTT55
IMIM53
SOSO49
SLSL46
BYBY42
BJBJ41
IRIR41
SYSY38
SZSZ37
MWMW36
GFGF34
BWBW33
SDSD33
BZBZ30
MQMQ30
GPGP29
TCTC29
BNBN28
TDTD26
HTHT25
IOIO25
LYLY25
RERE25
GAGA24
GDGD22
GNGN22
PSPS22
KNKN21
MNMN21
VIVI21
DJDJ20
TGTG20
CUCU19
CWCW19
AQAQ18
WSWS18
ADAD17
LCLC17
AFAF16
GMGM16
MLML16
BFBF15
YTYT15
ZWZW15
CFCF14
TJTJ14
TOTO13
YEYE13
KPKP12
SBSB12
GLGL11
KGKG11
MPMP11
GQGQ10
SXSX10
LSLS9
MHMH9
SMSM9
FMFM8
BIBI7
KIKI7
NCNC7
VGVG7
AIAI6
AXAX6
CGCG6
FOFO6
VAVA6
VUVU6
AGAG5
CVCV5
LRLR5
MSMS5
TMTM5
ASAS4
PWPW4
SHSH4
VCVC4
CCCC3
MRMR3
TLTL3
BTBT2
CXCX2
GSGS2
KMKM2
UMUM2
BLBL1
BQBQ1
DMDM1
ERER1
FKFK1
GWGW1
PMPM1
STST1

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-separated

Comma-separated ISO-3166 alpha-2, at most 60 codes (more is a 422 too_many_values, never truncated).

exclude_countrystring

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.

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 wantSendNote
One country?country=DECase-insensitive.
Several countries?country=DE,AT,CHComma-separated, no spaces. OR within the list.
Everywhere except one?exclude_country=USKeeps rows with no country at all.
A country and a category?country=DE&category=EngineeringDifferent parameters AND together.
A US state?state=OH or ?state=OH,TXUS only. No equivalent for other countries' subdivisions.
A city?city=BerlinResolved city, thinner coverage than country.
Rows with no countrynot directly addressableFetch 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.

Openings in one country
# 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}'
The response below is an illustration: /public/jobs?country=FR&limit=2 could not be read when this page was built, so run the command to see today's.
Three countries at once
$ 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)"'
The response below is an illustration: /public/jobs?country=DE,AT,CH&limit=3 could not be read when this page was built, so run the command to see today's.
Every country code the ledger carries, sorted by volume
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
curl -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-separated

Comma-separated. uncategorised selects rows with no confident classification.

remotestring

remote, hybrid, on_site, or not_stated.

employment_typestringcomma-separated

Comma-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-separated

Comma-separated; not_stated selects rows with none.

exclude_categorystring
remote_confirmedboolean

true 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 sendLabelLive rows
HealthcareHealthcare349,929
EngineeringEngineering274,086
Operations & AdminOperations & Admin261,392
SalesSales213,370
Skilled TechnicianSkilled Technician171,169
uncategorisedUncategorised159,934
RetailRetail151,910
HospitalityHospitality133,041
FinanceFinance111,334
Logistics & TransportLogistics & Transport79,320
Customer SupportCustomer Support66,253
EducationEducation64,600
ManufacturingManufacturing57,892
MarketingMarketing56,928
HR & RecruitingHR & Recruiting42,478
Consulting & StrategyConsulting & Strategy38,418
Construction & TradesConstruction & Trades37,056
Data & AnalyticsData & Analytics36,905
Legal & ComplianceLegal & Compliance28,378
DesignDesign21,041
ProductProduct19,659
Science & ResearchScience & Research19,336
SecuritySecurity19,331
ProcurementProcurement12,534
Safety & EnvironmentSafety & Environment9,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 sendLabelLive rows
not_statedNot stated1,338,881
ManagerManager314,907
EntryEntry243,379
SeniorSenior155,078
MidMid121,831
InternIntern92,841
LeadLead90,061
DirectorDirector64,346
ExecutiveExecutive14,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 sendLabelLive rows
not_statedNot stated1,464,286
Full-timeFull-time781,890
Part-timePart-time144,964
ContractContract24,307
TemporaryTemporary11,452
InternshipInternship9,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 sendLabelLive rows
on_siteOn Site2,159,022
remoteRemote149,909
hybridHybrid121,358
not_statedNot stated5,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), or unknown.
contract_category
The contract kind: permanent, fixed_term, project_based, seasonal, apprenticeship, internship, other, or unknown. permanent returns 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 then employee, and the category describes the contract with the agency), umbrella, or unknown.
working_schedule
full_time, part_time, flexible (either schedule offered), or unknown. Independent of the frozen employment_type field, 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-separated

Comma-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-separated

Comma-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-separated

Comma-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-separated

Comma-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 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, 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 live
liveclosedany

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_typestringcomma-separated
staffingjobboardall

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

qualitystring
all

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.

providerstringcomma-separated

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_providerstring

Same vocabulary as provider, removed instead of kept.

source_typestringcomma-separated

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_typestring
require_fieldsstringcomma-separated

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 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 sendLabelLive rows
atsEmployer ATS1,614,542
career_siteCompany career site807,829
public_agencyPublic employment agency13,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-separated

Comma-separated company slugs.

company_domainstringcomma-separated

Comma-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_domainstring

Same 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 200
offsetinteger0 to 100000
cursorstring

The next_cursor from the previous page. Overrides offset.

countrystring
org_typestring
source_typestring
has_websiteboolean

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_discoveredboolean

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.

qstring

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

Watch three employers by domain: the shape of a CRM enrichment job
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)"'

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_salarystring
truestructuredany

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_salarynumber

Lower 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_salarynumber

Upper 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_afterstring

Date or RFC3339 timestamp.

verified_afterstring

Only rows whose last_verified_at (the last positive confirmation at the source, see the Job schema) is at or after this instant.

listed_afterstring

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

VocabularyEnumerate fromKey?
provider/public/providers or /v1/meta/providersNo / yes
category/public/facets → familyNo
country/public/facets → country, or /public/coverage/countriesNo
city/public/facets → cityNo
employment_type/public/facets → employmentNo
engagementfixed: employee, vendor, other, unknown; counts at /public/facets → engagementNo
contract_categoryfixed 7-value list plus unknown; counts at /public/facets → contract_categoryNo
agencyfixed: direct, agency, umbrella, unknownNo
working_schedulefixed: full_time, part_time, flexible, unknownNo
seniority/public/facets → seniorityNo
remote/public/facets → remoteNo
source_type/public/facets → source_typeNo
plan/public/plansNo

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
Validate a filter before you spend a record on it
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 200

1-200 (default 25). Anything else is a 422 bad_limit, not clamped.

cursorstring

The next_cursor from the previous page.

collapsestring
company

Employer 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 200

Employer 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 live
liveclosedany

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_typestringcomma-separated
staffingjobboardall

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

qualitystring
all

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.

categorystringcomma-separated

Comma-separated. uncategorised selects rows with no confident classification.

familystring

Alias 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-separated

Comma-separated ISO-3166 alpha-2, at most 60 codes (more is a 422 too_many_values, never truncated).

citystringcomma-separated

Comma-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-separated

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.

remotestring

remote, hybrid, on_site, or not_stated.

employment_typestringcomma-separated

Comma-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-separated

Comma-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-separated

Comma-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-separated

Comma-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-separated

Comma-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-separated

Comma-separated; not_stated selects rows with none.

providerstringcomma-separated

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_providerstring

Same vocabulary as provider, removed instead of kept.

source_typestringcomma-separated

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_typestring
companystringcomma-separated

Comma-separated company slugs.

exclude_categorystring
exclude_countrystring
languagestringcomma-separated

ISO 639-1 two-letter language code of the posting text (e.g. de, fr, en). Comma-separated, up to 12 languages (OR).

language_excludestringcomma-separated

Exclude postings in these ISO 639-1 language codes. Comma-separated, up to 12 languages. Rows with undetected language are kept.

country_confidencestring
highlow

Server-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_typestring
commission_only

Server-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_typestring
commission_only

Exclude 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_confirmedboolean

true 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_salarystring
truestructuredany

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_descriptionboolean

true returns only the 2,318,210 live rows (95.1%) that carry a description.

require_fieldsstringcomma-separated

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 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_descriptionboolean

Return 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_afterstring

Date or RFC3339 timestamp.

verified_afterstring

Only rows whose last_verified_at (the last positive confirmation at the source, see the Job schema) is at or after this instant.

listed_afterstring

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

qstring

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

titlestring

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

Drop rows whose title matches these words. Same matching, and the same extended syntax (phrases, prefix, OR, exclusions, parentheses), as title.

description_containsstring

Full-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-separated

Comma-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_domainstring

Same vocabulary as company_domain, removed instead of kept.

min_salarynumber

Lower 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_salarynumber

Upper bound on salary_min_annual_eur. Same scope as min_salary.

sortstring
relevance

sort=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 false

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