Join.com Jobs API.
Pull structured listings, EUR salary ranges, and full markdown descriptions from JOIN-powered European career pages using two public, unauthenticated REST endpoints.
Try the API.
Test Jobs and Feed endpoints against https://connect.jobo.world with live request/response examples, then copy ready-to-use curl commands.
What's in every response.
Data fields, real-world applications, and the companies already running on Join.com.
- Structured Salary Ranges
- Full Markdown Descriptions
- City, Region & Country
- Workplace Type (Onsite/Remote/Hybrid)
- Employment Type & Category
- Recruiter Contact Details
- 01European Job Board Monitoring
- 02Salary Benchmarking
- 03Multilingual Job Aggregation
- 04Startup & Scaleup Talent Sourcing
How to scrape Join.com.
Step-by-step guide to extracting jobs from Join.com-powered career pages—endpoints, authentication, and working code.
import requests
import json
from bs4 import BeautifulSoup
company_slug = "marswalk"
url = f"https://join.com/companies/{company_slug}"
response = requests.get(url, headers={"Accept": "text/html"})
soup = BeautifulSoup(response.text, "html.parser")
# The company ID is embedded in the Next.js __NEXT_DATA__ payload
script_tag = soup.find("script", id="__NEXT_DATA__")
next_data = json.loads(script_tag.string)
company_id = next_data["props"]["pageProps"]["initialState"]["company"]["id"]
print(f"Company ID: {company_id}")import requests
company_id = 98520 # From step 1
url = f"https://join.com/api/public/companies/{company_id}/jobs"
params = {
"locale": "en-us",
"page": 1,
"pageSize": 5, # join.com rejects pageSize >= 6 with HTTP 422
}
response = requests.get(url, params=params)
data = response.json()
jobs = data["items"]
pagination = data["pagination"]
print(f"Found {pagination['rowCount']} total jobs across {pagination['pageCount']} pages")for job in jobs:
# Salary values are returned in cents
salary_from = job.get("salaryAmountFrom", {}).get("amount", 0) / 100
salary_to = job.get("salaryAmountTo", {}).get("amount", 0) / 100
currency = job.get("salaryAmountFrom", {}).get("currency", "EUR")
job_info = {
"id": job["id"],
"idParam": job.get("idParam"),
"title": job["title"],
"location": job.get("city", {}).get("cityName"),
"region": job.get("city", {}).get("regionName"),
"country": job.get("city", {}).get("countryName"),
"workplace_type": job.get("workplaceType"), # ONSITE, REMOTE, HYBRID
"category": job.get("category", {}).get("name"),
"employment_type": job.get("employmentType", {}).get("name"),
"salary_range": f"{salary_from:,.0f} - {salary_to:,.0f} {currency}" if salary_from else None,
"created_at": job.get("createdAt"),
}
print(job_info)import requests
job_id = 15565770 # A listing's numeric "id" from step 2
url = f"https://join.com/api/public/jobs/{job_id}"
response = requests.get(url)
job = response.json()
# description may be null — assemble it from the section fields when it is
description = job.get("description")
if not description:
sections = []
if job.get("intro"): sections.append(job["intro"])
if job.get("tasks"): sections.append(f"## Tasks\n\n{job['tasks']}")
if job.get("requirements"): sections.append(f"## Requirements\n\n{job['requirements']}")
if job.get("benefits"): sections.append(f"## Benefits\n\n{job['benefits']}")
if job.get("outro"): sections.append(job["outro"])
description = "\n\n".join(sections)
full_job = {
"id": job["id"],
"title": job["title"],
"description": description, # markdown
"contact_name": job.get("contactName"),
"contact_email": job.get("contactEmail"),
"status": job.get("status"), # ONLINE, OFFLINE, ARCHIVED
}
print(f"{full_job['title']} — {full_job['status']}")import requests
def fetch_all_jobs(company_id: int, company_slug: str, page_size: int = 5) -> list:
all_jobs = []
page = 1
while True:
url = f"https://join.com/api/public/companies/{company_id}/jobs"
params = {"locale": "en-us", "page": page, "pageSize": page_size}
data = requests.get(url, params=params).json()
for item in data["items"]:
if not item.get("idParam"):
continue
item["job_url"] = f"https://join.com/companies/{company_slug}/jobs/{item['idParam']}"
all_jobs.append(item)
if page >= data["pagination"]["pageCount"]:
break
page += 1
return all_jobs
jobs = fetch_all_jobs(98520, "marswalk")
print(f"Retrieved {len(jobs)} total jobs")Since mid-2026 join.com validates pageSize against a small allow-list; values of 6 or more return 422 with `[{"param":"pageSize","msg":"Invalid value"}]`. Cap pageSize at 5 and paginate with the page parameter.
The numeric company ID that the listings API requires only exists inside the __NEXT_DATA__ script tag on the company page. Fetch the HTML page first and read props.pageProps.initialState.company.id.
salaryAmountFrom and salaryAmountTo are returned in cents. Divide the amount by 100 before formatting, and read the currency from the same object (usually EUR).
When description is null, the content lives in the intro, tasks, requirements, benefits, and outro fields. Concatenate those sections to rebuild the full markdown body.
A job pulled from an expired listing returns HTTP 404 from /api/public/jobs/{id}. Treat that 404 as a removal signal, and use the details status field (ONLINE, OFFLINE, ARCHIVED) to filter inactive postings.
Always send locale=en-us on the listings request so category, employment type, and description language stay consistent across crawls.
- 1Cache the numeric company ID per slug — it is stable across crawls and avoids re-parsing the HTML page.
- 2Request pageSize=5 (the maximum join.com accepts) and iterate the page parameter to the pageCount.
- 3Space requests roughly 200ms apart and cap concurrent detail fetches to avoid 403/429 responses.
- 4Divide every salary amount by 100 to convert from cents to the stated currency.
- 5Always send locale=en-us so field values and descriptions stay consistent.
- 6Treat an HTTP 404 from the details endpoint as a removed job rather than a hard failure.
One endpoint. All Join.com jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=join.com" \
-H "X-Api-Key: YOUR_KEY" Access Join.com
job data today.
One API call. Structured data. No scraping infrastructure to build or maintain — start with the free tier and scale as you grow.