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

The same parameter set is accepted by /v1/jobs, /v1/jobs/closed and /v1/export, with the differences noted on each endpoint's own page. /v1/companies has its own smaller set.

Exact contractTypes, defaults, ranges, errors, edge cases

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

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; the keyless surface caps at 50. A limit above your maximum is clamped quietly rather than refused.

Exact contractTypes, defaults, ranges, errors, edge cases
limitintegerdefault 251–200
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.

Exact contractTypes, defaults, ranges, errors, edge cases
countrystringcomma-separated

Comma-separated ISO-3166 alpha-2.

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

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 how many live rows sit behind each. It is not a list of the world's countries — it is what this data holds today, including dependencies and territories with their own code, and it moves as employers post and roles close.

Every country in the ledger — 171 rows, sorted by live listings
ISOLiveWith a descriptionDescribedWith a published salaryEmployers
US1,583,9211,199,96775.8%186,103105,037
DE169,230158,66893.8%7,85029,175
GB110,64295,67286.5%5,71911,710
IN72,34057,94780.1%9875,268
FR63,76159,78993.8%2,7784,142
CA60,03254,53190.8%4,7446,110
NL50,43446,50292.2%4,9947,123
SE31,57630,78297.5%515,507
AU23,02320,54889.2%2853,200
ES21,74118,51285.1%5042,967
BE16,50214,52288%1452,337
HU14,4242,92820.3%20482
IT14,36213,04190.8%3211,823
CH14,18213,05392%1082,259
MX14,10011,43981.1%1981,749
BR12,9239,86676.3%2981,555
PL12,90010,89484.4%2791,907
SG12,6309,92478.6%971,636
PH11,4229,79685.8%2761,291
MY10,2038,80486.3%7251,491
AE10,0828,98589.1%7651,173
AT10,0549,42993.8%3,1152,091
JP9,9538,51485.5%991,837
CN9,8227,38375.2%631,186
HR8,7238,48697.3%201,163
IE8,5887,42086.4%3111,440
NO8,3727,72492.3%1391,296
ZA7,0706,48991.8%664986
PT7,0126,23388.9%114904
DK5,5715,12692%41829
GR5,5155,17393.8%54654
FI5,1554,43286%73940
ID4,9944,32486.6%12906
TH4,7633,90281.9%61636
CO4,7234,02285.2%82894
RO4,4763,94188%35762
SA4,4573,96689%19617
NZ4,1013,76191.7%29576
HK3,7272,91378.2%160735
EG3,7083,23787.3%44513
TW3,6022,75076.3%29521
VN3,5472,91182.1%20547
CZ3,3312,70581.2%40608
LT3,0602,81692%85262
AR3,0312,56184.5%91683
GE2,9952,04368.2%22612
KR2,8592,34982.2%17600
UA2,7642,54992.2%31363
BG2,7112,40788.8%75412
PE2,4591,84475%15322
IL2,3351,98184.8%22396
CL2,1371,78183.3%9340
TR1,8491,49380.7%8431
MA1,7771,59689.8%4241
UN1,6361,61698.8%478349
LU1,6171,40586.9%22361
NG1,5271,49097.6%4262
PK1,4511,39295.9%46252
QA1,3681,14383.6%7168
CR1,21388272.7%8235
SK1,15094682.3%26217
RS1,1451,05792.3%11338
MT1,1311,06394%40230
CY1,0941,04795.7%28237
LK91584792.6%4107
EE89985495%17201
GT81771887.9%16109
LV77571492.1%33165
KE76170392.4%7218
EC67259788.8%587
JO63149578.4%0127
TN62652684%5116
PA57344677.8%2146
KZ52247691.2%9115
LB45433674%5145
DO44038487.3%0103
SI42433378.5%5115
PR41227566.7%875
AZ38336795.8%167226
KW38034691.1%086
MV36034194.7%017
UY35028982.6%0107
BD33830790.8%5125
MU32532299.1%035
BH32326682.4%186
LI31931799.4%036
RU30529697%391
OM28721273.9%181
SV28226493.6%7267
TZ26926698.9%346
AM26824491%176
AL25223492.9%36103
HN23020990.9%1771
SR22418281.3%125
GH22320792.8%891
VE21119994.3%463
BM20018693%012
SW197197100%099
MD18716688.8%050
IQ18617594.1%042
NI18417896.7%047
NP15511674.8%134
AD14614095.9%21131
MK14413090.3%267
FJ14010877.1%021
DZ14011481.4%038
ET13610980.1%046
JM12711187.4%748
SO125125100%120
BA12011595.8%054
AO1168371.6%020
XK11311299.1%027
KH1118778.4%446
BO11010292.7%035
MZ10810395.4%025
BY106106100%023
GI10510499%027
UG101101100%035
CI101101100%036
MM986869.4%131
CD989799%130
JE919098.9%119
MO906370%018
TT898595.5%019
IM8686100%219
BS867587.2%225
ME866575.6%018
SN847994%031
SP8181100%014
CM8080100%026
UZ797594.9%031
GG7676100%015
AW766281.6%06
MC746182.4%132
IS725981.9%033
KY705984.3%117
PG695884.1%020
LA685073.5%234
ZM666293.9%027
PF666090.9%29
MQ646398.4%018
NA635993.7%018
IR625588.7%116
HO6161100%03
PY615386.9%031
RW584882.8%024
GY575291.2%016
SC5252100%07
BB504590%013
MG504896%016
RE494898%125
SZ4646100%06
SL444193.2%012
GA4141100%012
JA4040100%016
BW3939100%018
NE373491.9%424
LY363597.2%013
TD3434100%011
BU332987.9%022
SD322887.5%113
WS322887.5%06
AF302480%014
PO3030100%07
GP2828100%013
GU272696.3%115
HT262596.2%112
ZW2626100%013
LR2626100%07
SM251560%06
SY252496%016

171 countries, rendered from /public/coverage/countries, which needs no key. The count is of ISO codes we have live rows for — it is not a list of the world’s countries and it moves as employers post and roles close. Measured 11 September 2026, 22: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,583,921
DEDE169,230
GBGB110,642
ININ72,340
FRFR63,761
CACA60,032
NLNL50,434
SESE31,576
AUAU23,023
ESES21,741
BEBE16,502
HUHU14,424
ITIT14,362
CHCH14,182
MXMX14,100
BRBR12,923
PLPL12,900
SGSG12,630
PHPH11,422
MYMY10,203
AEAE10,082
ATAT10,054
JPJP9,953
CNCN9,822
HRHR8,723
IEIE8,588
NONO8,372
ZAZA7,070
PTPT7,012
DKDK5,571
GRGR5,515
FIFI5,155
IDID4,994
THTH4,763
COCO4,723
RORO4,476
SASA4,457
NZNZ4,101
HKHK3,727
EGEG3,708
TWTW3,602
VNVN3,547
CZCZ3,331
LTLT3,060
ARAR3,031
GEGE2,995
KRKR2,859
UAUA2,764
BGBG2,711
PEPE2,459
ILIL2,335
CLCL2,137
TRTR1,849
MAMA1,777
UNUN1,636
LULU1,617
NGNG1,527
PKPK1,451
QAQA1,368
CRCR1,213
SKSK1,150
RSRS1,145
MTMT1,131
CYCY1,094
LKLK915
EEEE899
GTGT817
LVLV775
KEKE761
ECEC672
JOJO631
TNTN626
PAPA573
KZKZ522
LBLB454
DODO440
SISI424
PRPR412
AZAZ383
KWKW380
MVMV360
UYUY350
BDBD338
MUMU325
BHBH323
LILI319
RURU305
OMOM287
SVSV282
TZTZ269
AMAM268
ALAL252
HNHN230
SRSR224
GHGH223
VEVE211
BMBM200
SWSW197
MDMD187
IQIQ186
NINI184
NPNP155
ADAD146
MKMK144
FJFJ140
DZDZ140
ETET136
JMJM127
SOSO125
BABA120
AOAO116
XKXK113
KHKH111
BOBO110
MZMZ108
BYBY106
GIGI105
CICI101
UGUG101
CDCD98
MMMM98
JEJE91
MOMO90
TTTT89
BSBS86
IMIM86
MEME86
SNSN84
SPSP81
CMCM80
UZUZ79
AWAW76
GGGG76
MCMC74
ISIS72
KYKY70
PGPG69
LALA68
ZMZM66
PFPF66
MQMQ64
NANA63
IRIR62
PYPY61
HOHO61
RWRW58
GYGY57
SCSC52
MGMG50
BBBB50
RERE49
SZSZ46
SLSL44
XXXX44
GAGA41
JAJA40
BWBW39
NENE37
LYLY36
TDTD34
BUBU33
WSWS32
SDSD32
AFAF30
POPO30
GPGP28
GUGU27
LRLR26
HTHT26
ZWZW26
SMSM25
SYSY25
KGKG24
CFCF24
VAVA24
YEYE24
GFGF23
FMFM23
GNGN23
MWMW22
AQAQ22
BJBJ22
PSPS22
CUCU21
BZBZ21
GMGM20
SSSS20
AXAX18
TUTU17
MLML17
LSLS16
TCTC15
TOTO15
MRMR14
EUEU13
MHMH13
MNMN13
XIXI12
SXSX12
GQGQ12
CVCV12
NCNC12
GDGD11
YTYT11
VIVI11
KNKN10
VUVU10
CWCW10
TATA9
BIBI9
HQHQ9
SBSB9
BNBN8
TJTJ8
BTBT8
BQBQ7
STST7
VGVG7
BFBF6
DJDJ6
SUSU6
TGTG5
CGCG5
TMTM5
SHSH5
FOFO4
KIKI4
GWGW4
AGAG3
TLTL3
ASAS3
DCDC3
LCLC2
SFSF2
GLGL2
NYNY2
ERER2
PWPW2
DMDM1
ZZZZ1
FLFL1
LELE1
VCVC1
ANAN1
OROR1
BLBL1
MFMF1
MPMP1
KPKP1
UMUM1

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

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
# Keyless — no account needed. Swap FR for any code in the list above.
$ curl -s 'https://api.jobopportunitiesapi.org/public/jobs?country=FR&limit=2' \
  | jq '.data[] | {title, company, city, country}'
{
  "data": [
    {
      "id": "b8813094-5483-4d42-8749-c15e4c05c230",
      "slug": "fireman-b8813094",
      "title": "Fireman",
      "company": "disney",
      "company_slug": "disney",
      "company_logo": "https://supabase-erioun.tzekos.eu/storage/v1/object/public/company-logos/logos/disney.png",
      "category": "Safety & Environment",
      "category_confidence": 0.95,
      "country": "FR",
      "location": "Marne la Vallee Cedex 4, France",
      "remote": "on_site",
      "remote_inferred": true,
      "posted_at": "2026-09-11T22:06:08Z",
      "first_seen_at": "2026-09-11T22:09:29Z",
      "last_verified_at": "2026-09-11T23:09:47Z",
      "status": "live",
      "closed_at": null,
      "closed_reason": null,
      "apply_url": "https://disney.wd5.myworkdayjobs.com/disneycareerdc/job/Marne-la-Vallee-Cedex-4-France/Fireman_DLP-0000454599",
      "source": "workday",
      "source_type": "ats",
      "provider_type": "employer_ats",
      "has_description": false,
      "field_sources": {
        "remote": "inferred",
        "employment_type": "absent",
        "category": "inferred",
… 50 more lines
Real response, fetched from /public/jobs?country=FR&limit=2 when this page was built (12 September 2026, 00:26 UTC). Run the command yourself and you will get today's rows, not these.
Three countries at once
$ curl -s 'https://api.jobopportunitiesapi.org/public/jobs?country=DE,AT,CH&limit=3' \
  | jq -r '.data[] | "\(.country)  \(.company)  \(.title)"'
{
  "data": [
    {
      "id": "8239d881-d520-4405-85c5-53c6d5c8e1ec",
      "slug": "agentforce-operations-account-executive-8239d881",
      "title": "Agentforce Operations Account Executive",
      "company": "salesforce",
      "company_slug": "salesforce",
      "company_logo": "https://supabase-erioun.tzekos.eu/storage/v1/object/public/company-logos/logos/salesforce.png",
      "category": "Sales",
      "category_confidence": 0.65,
      "country": "DE",
      "city": "Munich",
      "location": "Germany, Munich (5 Locations)",
      "remote": "on_site",
      "remote_inferred": true,
      "posted_at": "2026-09-11T22:06:08Z",
      "first_seen_at": "2026-09-11T22:09:29Z",
      "last_verified_at": "2026-09-11T22:56:13Z",
      "status": "live",
      "closed_at": null,
      "closed_reason": null,
      "apply_url": "https://salesforce.wd12.myworkdayjobs.com/external_career_site/job/Germany---Munich/Agentforce-Operations-Account-Executive_JR359678",
      "source": "workday",
… 95 more lines
Real response, fetched from /public/jobs?country=DE,AT,CH&limit=3 when this page was built (12 September 2026, 00:26 UTC). Run the command yourself and you will get today's rows, not these.
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.

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.

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; not_stated selects rows with none.

senioritystringcomma-separated

Comma-separated; not_stated selects rows with none.

exclude_categorystring
remote_confirmedboolean

true returns only listings whose remote status the SOURCE stated — 377,019 of 3,647,719 live rows (10.3%). Without it you also receive the 3,266,412 (89.5%) 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
EngineeringEngineering433,807
HealthcareHealthcare417,340
Operations & AdminOperations & Admin369,705
SalesSales285,380
Skilled TechnicianSkilled Technician220,023
RetailRetail205,551
uncategorisedUncategorised193,737
HospitalityHospitality186,856
FinanceFinance178,734
Logistics & TransportLogistics & Transport108,666
MarketingMarketing96,891
Customer SupportCustomer Support95,571
EducationEducation84,290
ManufacturingManufacturing83,761
HR & RecruitingHR & Recruiting71,011
Data & AnalyticsData & Analytics64,958
Consulting & StrategyConsulting & Strategy62,233
Construction & TradesConstruction & Trades51,876
Legal & ComplianceLegal & Compliance47,103
ProductProduct37,126
DesignDesign36,332
SecuritySecurity30,337
Science & ResearchScience & Research28,734
ProcurementProcurement19,972
Safety & EnvironmentSafety & Environment14,326

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,883,742
ManagerManager446,536
EntryEntry323,849
SeniorSenior252,916
MidMid161,894
LeadLead137,513
DirectorDirector104,002
InternIntern87,678
ExecutiveExecutive26,190

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 stated3,175,770
Full-timeFull-time186,909
ContractContract30,637
Part-timePart-time13,845
TemporaryTemporary11,118
InternshipInternship6,041

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,944,738
remoteRemote261,821
hybridHybrid212,791
not_statedNot stated4,970

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.

6Provenance 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. 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 18,000 listings 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 6.3% of your rows have a salary". They do — and require_fields=salary returns 229,376 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
career_siteCompany career site1,742,168
atsEmployer ATS1,671,874
public_agencyPublic employment agency10,278

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.

7Company — 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. 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–200
offsetinteger0–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)"'

9Salary

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. has_salary=any also includes figures we read out of the advert text. AI estimates are never included under any value — they are refused by the query that builds the data, not by a filter.

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

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. Selects ONLY rows with structured salary we could normalise — 2.0% of the ledger — so this is a narrow filter by nature, not a broken one.

max_salarynumber

Upper bound on salary_min_annual_eur. Same 2.0% caveat 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.

10Time — posted_after and verified_after

Two different questions: what was posted since a date, and what was re-confirmed at its source since a date.

In depthWhy it exists, what it is not, what people get wrong

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

Exact contractTypes, defaults, ranges, errors, edge cases
posted_afterstring

Date or RFC3339 timestamp.

verified_afterstring

Only rows re-confirmed at their source since this instant.

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.

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

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

11Controlled 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/facetsfamilyNo
country/public/facetscountry, or /public/coverage/countriesNo
city/public/facetscityNo
employment_type/public/facetsemploymentNo
seniority/public/facetsseniorityNo
remote/public/facetsremoteNo
source_type/public/facetssource_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]} …")

12The 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–200
cursorstring

The next_cursor from the previous page.

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 18,000 listings 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.

countrystringcomma-separated

Comma-separated ISO-3166 alpha-2.

citystring
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; not_stated selects rows with none.

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
remote_confirmedboolean

true returns only listings whose remote status the SOURCE stated — 377,019 of 3,647,719 live rows (10.3%). Without it you also receive the 3,266,412 (89.5%) 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,937,340 live rows (80.5%) 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 6.3% of your rows have a salary". They do — and require_fields=salary returns 229,376 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 average 2,581 bytes, so a 200-row page would be a 516 KB response nobody asked for. With this on, limit may not exceed 50 — a larger request is refused with 422 rather than quietly clamped.

posted_afterstring

Date or RFC3339 timestamp.

verified_afterstring

Only rows re-confirmed at their source since 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. Stemming is off (simple dictionary), so q=engineer does not match engineering.

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.

title_excludestring

Drop rows whose title matches these words. Same matching as title.

description_containsstring

Full-text over the advert body. Only ever matches rows that have one, so it implies has_description=true.

company_domainstringcomma-separated

Comma-separated bare domains, e.g. stripe.com,figma.com — no scheme and no path. The join key you already have in a CRM. 37.2% of companies carry a domain; the rest can never match this filter.

exclude_company_domainstring

Same vocabulary as company_domain, removed instead of kept.

min_salarynumber

Lower bound on salary_min_annual_eur. Selects ONLY rows with structured salary we could normalise — 2.0% of the ledger — so this is a narrow filter by nature, not a broken one.

max_salarynumber

Upper bound on salary_min_annual_eur. Same 2.0% caveat as min_salary.

34 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 12 September 2026, 00:26 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.