BambooHR Jobs API.
Extract job listings from thousands of small and mid-market employers through a clean, no-auth JSON API that returns departments, workplace types, and experience levels in structured form.
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 BambooHR.
- Full Job Descriptions
- Department & Employment Type
- Workplace Type Codes
- City / State / Country Locations
- Posted Dates
- Minimum Experience Level
- 01SMB Job Monitoring
- 02Mid-Market Talent Sourcing
- 03Hiring Signal Tracking
- 04Careers Page Validation
How to scrape BambooHR.
Step-by-step guide to extracting jobs from BambooHR-powered career pages—endpoints, authentication, and working code.
import requests
def validate_company(subdomain: str) -> dict | None:
"""Validate a BambooHR company subdomain exists."""
url = f"https://{subdomain}.bamboohr.com/careers/company-info"
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
data = response.json()
return data.get("result")
except requests.RequestException:
return None
# Example usage
company = validate_company("cortina")
if company:
print(f"Company: {company['name']}")
else:
print("Invalid company subdomain")import requests
def fetch_job_listings(subdomain: str) -> list[dict]:
"""Fetch all job listings from a BambooHR company."""
url = f"https://{subdomain}.bamboohr.com/careers/list"
response = requests.get(url, timeout=10)
response.raise_for_status()
data = response.json()
jobs = data.get("result", [])
total_count = data.get("meta", {}).get("totalCount", 0)
print(f"Found {total_count} jobs for {subdomain}")
return jobs
# Example usage
jobs = fetch_job_listings("cortina")
for job in jobs[:3]:
print(f"- {job['jobOpeningName']} ({job['departmentLabel']})")def map_workplace_type(location_type: str | None) -> str:
"""Map BambooHR location type codes to readable values."""
workplace_map = {
"0": "On-site",
"1": "Remote",
"2": "Hybrid",
}
return workplace_map.get(location_type, "Not Specified")
# Parse job listings with workplace type
for job in jobs:
workplace = map_workplace_type(job.get("locationType"))
location = job.get("atsLocation", {})
print(f"{job['jobOpeningName']}: {workplace} - {location.get('city', 'N/A')}, {location.get('state', 'N/A')}")import requests
import time
def fetch_job_details(subdomain: str, job_id: str) -> dict:
"""Fetch detailed information for a specific job."""
url = f"https://{subdomain}.bamboohr.com/careers/{job_id}/detail"
response = requests.get(url, timeout=10)
response.raise_for_status()
data = response.json()
return data.get("result", {}).get("jobOpening", {})
# Fetch details for all jobs (with rate limiting)
for job in jobs[:5]: # Limit to first 5 for example
details = fetch_job_details("cortina", job["id"])
print({
"title": details.get("jobOpeningName"),
"posted": details.get("datePosted"),
"experience": details.get("minimumExperience"),
"compensation": details.get("compensation"),
})
time.sleep(1) # Be respectful with rate limitingimport requests
import time
def scrape_bamboohr_jobs(subdomain: str) -> list[dict]:
"""Complete BambooHR job scraper with error handling."""
base_url = f"https://{subdomain}.bamboohr.com"
# Validate company exists
try:
info_resp = requests.get(f"{base_url}/careers/company-info", timeout=10)
info_resp.raise_for_status()
company_name = info_resp.json().get("result", {}).get("name", subdomain)
except requests.RequestException:
print(f"Company '{subdomain}' not found or invalid")
return []
# Fetch job listings
try:
list_resp = requests.get(f"{base_url}/careers/list", timeout=10)
list_resp.raise_for_status()
listings = list_resp.json().get("result", [])
except requests.RequestException as e:
print(f"Error fetching listings: {e}")
return []
# Fetch details for each job
results = []
for job in listings:
try:
detail_url = f"{base_url}/careers/{job['id']}/detail"
detail_resp = requests.get(detail_url, timeout=10)
detail_resp.raise_for_status()
details = detail_resp.json().get("result", {}).get("jobOpening", {})
results.append({
"external_id": f"{subdomain}:{job['id']}",
"title": job["jobOpeningName"],
"company": company_name,
"department": job.get("departmentLabel"),
"location": job.get("atsLocation", {}),
"workplace_type": map_workplace_type(job.get("locationType")),
"employment_type": job.get("employmentStatusLabel"),
"description": details.get("description"),
"posted_date": details.get("datePosted"),
"experience": details.get("minimumExperience"),
"compensation": details.get("compensation"),
"url": details.get("jobOpeningShareUrl"),
})
time.sleep(0.5) # Rate limiting
except requests.RequestException as e:
print(f"Error fetching job {job['id']}: {e}")
return results
# Run the scraper
jobs = scrape_bamboohr_jobs("cortina")
print(f"Scraped {len(jobs)} complete job listings")Not every company enables BambooHR's public career page, and some use a different subdomain. Validate with /careers/company-info first; a 404 there means the tenant is invalid, not that scraping failed.
Private or gated tenants redirect to a login page rather than serving the JSON feed. Detect an HTML body (leading '<' or a login marker) and treat it as auth-required instead of trying to parse it as jobs.
The /careers/list feed only carries basic fields. datePosted, description, compensation, and minimumExperience live on /careers/{id}/detail, so fetch details per job when you need them.
Responses include both 'location' and 'atsLocation'. Prefer 'atsLocation' for country and state, and fall back to 'location' fields like postalCode when they are present.
Remote roles often leave the 'location' object mostly null. Use locationType '1' to flag remote positions and read atsLocation as a fallback for any available geography.
Salary is optional and arrives as a free-text string when present. Null-check the field and treat it as an opaque display string rather than a parseable range.
- 1Validate subdomains with /careers/company-info before pulling listings
- 2Detect HTML/login responses and treat them as auth-required, not parse errors
- 3Fetch /careers/{id}/detail only when you need descriptions, dates, or pay
- 4Prefer atsLocation over location for the most complete geography
- 5Throttle detail calls and back off on HTTP 429 rather than hammering the API
- 6Cache results and re-scrape on a schedule rather than fetching on every request
One endpoint. All BambooHR jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=bamboohr" \
-H "X-Api-Key: YOUR_KEY" Access BambooHR
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.