All platforms

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.

Get API access
BambooHR
Live
80K+jobs indexed monthly
<3haverage discovery time
1hrefresh interval
Companies using BambooHR
Cortina SolutionsRightslineHelcim
Developer tools

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.

Data fields
  • Full Job Descriptions
  • Department & Employment Type
  • Workplace Type Codes
  • City / State / Country Locations
  • Posted Dates
  • Minimum Experience Level
Use cases
  1. 01SMB Job Monitoring
  2. 02Mid-Market Talent Sourcing
  3. 03Hiring Signal Tracking
  4. 04Careers Page Validation
Trusted by
Cortina SolutionsRightslineHelcim
DIY GUIDE

How to scrape BambooHR.

Step-by-step guide to extracting jobs from BambooHR-powered career pages—endpoints, authentication, and working code.

RESTbeginnerUndocumented; throttle requests conservatively and back off on HTTP 429No auth

Validate the company subdomain

Hit the company-info endpoint to confirm a subdomain exists before scraping. It returns the company name and logo, or a 404 for an invalid tenant, so you avoid wasted listing calls.

Step 1: Validate the company subdomain
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")

Fetch all job listings

Call the /careers/list endpoint to retrieve every active job in one request. There is no pagination: the response carries the full result array plus a meta.totalCount you can use as a sanity check.

Step 2: Fetch all job listings
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']})")

Map location type codes

BambooHR encodes workplace type as a numeric string. Map locationType '0' to On-site, '1' to Remote, and '2' to Hybrid. Prefer atsLocation for the most complete country and state values.

Step 3: Map location type codes
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')}")

Fetch job details

The listings feed omits descriptions, compensation, and posted dates. Fetch each job from /careers/{id}/detail to pull the full jobOpening object, and space the calls out to stay a good API citizen.

Step 4: Fetch job details
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 limiting

Build a complete scraper

Combine validation, listings, and details into one resilient pass with per-request error handling and pacing. External IDs follow the scraper's {subdomain}:{jobId} convention so records stay stable across runs.

Step 5: Build a complete scraper
import 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")
Common issues
highCompany subdomain returns 404

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.

mediumEndpoint returns an HTML login page instead of JSON

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.

mediumMissing posted date and description in listings

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.

lowTwo location objects with different data

Responses include both 'location' and 'atsLocation'. Prefer 'atsLocation' for country and state, and fall back to 'location' fields like postalCode when they are present.

lowRemote jobs have null city and state

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.

lowCompensation is null or inconsistently formatted

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.

Best practices
  1. 1Validate subdomains with /careers/company-info before pulling listings
  2. 2Detect HTML/login responses and treat them as auth-required, not parse errors
  3. 3Fetch /careers/{id}/detail only when you need descriptions, dates, or pay
  4. 4Prefer atsLocation over location for the most complete geography
  5. 5Throttle detail calls and back off on HTTP 429 rather than hammering the API
  6. 6Cache results and re-scrape on a schedule rather than fetching on every request
Or skip the complexity

One endpoint. All BambooHR jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=bamboohr" \
  -H "X-Api-Key: YOUR_KEY"
Ready to integrate

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.

99.9%API uptime
<200msAvg response
50M+Jobs processed