How to Scrape BambooHR Job Listings
TL;DR
BambooHR careers pages load from two unauthenticated JSON endpoints: /careers/list for the openings and /careers/{id}/detail for each job's HTML description, structured location, date and pay. Pay is a free-text string, the company name lives in the HTML page instead of the JSON, and evergreen postings can carry very old dates.
BambooHR is popular with small and mid-sized companies, and its hosted careers pages load from JSON endpoints you can call directly, with no API key. Compared with something like Greenhouse, the one difference to plan for is that listing and description are split: you fetch the list, then make one request per job for the full details.
Finding the Company Slug
A BambooHR careers page lives at https://acme.bamboohr.com/careers. The slug is the subdomain, acme. Some companies embed the board into their own website, in which case open the network tab and look for requests to *.bamboohr.com. As with every applicant tracking system, there's no public directory of slugs, so you need to know each company's address.
The Two Endpoints
1. The list. It returns every open job in one response:
curl -H "accept: application/json" "https://acme.bamboohr.com/careers/list"The jobs are in a result array. Each entry is a short summary:
{
"id": "124",
"jobOpeningName": "Application Specialist",
"departmentLabel": "Sales",
"employmentStatusLabel": "Full-Time",
"location": { "city": "Westborough", "state": "Massachusetts" },
"isRemote": null,
"locationType": "2"
}There is no description, no country and no pay in the list.
2. The detail. One request per job, using the id from the list:
curl -H "accept: application/json" "https://acme.bamboohr.com/careers/124/detail"The job is under result.jobOpening:
{
"jobOpeningName": "Application Specialist",
"jobOpeningShareUrl": "https://acme.bamboohr.com/careers/124",
"description": "<p>...</p>",
"datePosted": "2026-09-16",
"employmentStatusLabel": "Full-Time",
"minimumExperience": "Mid-level",
"compensation": "95,000-105,000",
"location": {
"city": "Westborough",
"state": "Massachusetts",
"postalCode": "01581",
"addressCountry": "United States"
},
"locationType": 2
}Detail is the only place you get the HTML description, the country, the posting date and the pay text. Treat the list as an index and the detail as the real record.
Fetching Jobs (Node/TypeScript)
This fetches the list, then the details in small concurrent batches so you don't hammer one company's server:
interface BambooJob {
id: string;
title: string;
department?: string;
employmentType?: string;
city?: string;
state?: string;
country?: string;
postedAt?: string;
compensationText?: string;
html: string;
url: string;
}
async function getJson(url: string) {
const res = await fetch(url, { headers: { accept: "application/json" } });
if (!res.ok) throw new Error(`BambooHR ${res.status} for ${url}`);
return res.json();
}
export async function fetchBambooJobs(slug: string): Promise<BambooJob[]> {
const host = `${slug}.bamboohr.com`;
const { result = [] } = await getJson(`https://${host}/careers/list`);
const jobs: BambooJob[] = [];
const BATCH = 10;
for (let i = 0; i < result.length; i += BATCH) {
const batch = result.slice(i, i + BATCH);
const settled = await Promise.allSettled(
batch.map((o: { id: string }) => getJson(`https://${host}/careers/${o.id}/detail`)),
);
settled.forEach((s, idx) => {
if (s.status !== "fulfilled") return; // one bad job shouldn't drop the board
const j = s.value.result?.jobOpening ?? {};
jobs.push({
id: String(batch[idx].id),
title: j.jobOpeningName,
department: j.departmentLabel,
employmentType: j.employmentStatusLabel,
city: j.location?.city,
state: j.location?.state,
country: j.location?.addressCountry,
postedAt: j.datePosted,
compensationText: j.compensation ?? undefined,
html: j.description ?? "",
url: j.jobOpeningShareUrl ?? `https://${host}/careers/${batch[idx].id}`,
});
});
}
return jobs;
}Pagination
There isn't any. The list call returns every open job in a single response; a board with just over 100 openings came back complete in one request. Because there are no paging parameters, the number of requests you make scales with the number of jobs: a 150-job board costs 151 requests (one list plus 150 details).
Reading the Pay Field
compensation is the field people miss. It's present on many postings, but it's free text that the employer typed, so the format varies from job to job. Real examples from one board:
95,000-105,000
$43.75 to $54.00 per hour flat rate
$39 /hr.
$38,000 - $120,000 per year
CommissionSome have a currency symbol, some don't; some are hourly, some annual; some contain no number at all. A small parser gets you most of the way:
export function parseCompensation(text?: string) {
if (!text) return null;
const nums = [...text.matchAll(/\d[\d,]*(?:\.\d+)?/g)].map((m) => Number(m[0].replace(/,/g, "")));
if (nums.length === 0) return null; // "Commission" and similar
const [min, max = min] = nums;
const t = text.toLowerCase();
const period = /hour|\/\s*hr/.test(t) ? "hour"
: /week/.test(t) ? "week"
: /month/.test(t) ? "month"
: /year|annual|\/\s*yr/.test(t) ? "year"
: min < 1000 ? "hour" : "year"; // bare numbers: guess from magnitude
return { min, max, period, currency: text.includes("$") ? "USD" : null };
}Two caveats. A bare number with no symbol has no known currency, so currency stays null rather than guessing. And the period fallback for bare numbers is a heuristic, so you may want to treat those rows as lower confidence.
Fields Worth Knowing
From result.jobOpening:
jobOpeningNameis the title.descriptionis HTML.datePostedis aYYYY-MM-DDdate.locationis an object withcity,state,postalCodeandaddressCountry. The country is a full name such as "United States" or "Canada", not an ISO code, so you'll need to convert it.employmentStatusLabelis a label like "Full-Time" or "1. Regular Full-Time"; some companies prefix their own numbering.minimumExperienceis a free-text level such as "Mid-level" when the employer sets it.jobOpeningShareUrlis the public page for the job and the best apply link.compensationis the free-text pay described above.
Gotchas
The company name isn't in the JSON. Neither endpoint returns it. The public /careers HTML page includes it in a meta tag, for example <meta property="og:site_name" content="KPM Analytics"/>, so fetch that page once per company and cache the result.
Remote and hybrid aren't reliable. locationType is a code, and values such as 0 and 2 appear on ordinary on-site jobs; isRemote is often null. Don't trust either on its own. Check the title and location text for words like "remote" and "hybrid" as well.
Evergreen postings carry old dates. Some companies leave a posting open for a year or more, so datePosted can be far in the past even though the job is still live. If you want fresh jobs, filter by age yourself, and decide whether a stale date means the job is really stale.
Details can fail individually. Wrap each detail request so that one failure doesn't discard the whole board, as the code above does.
No webhooks and no published limits. It's pull-only. Keep concurrency modest, back off and retry on 429 and 5xx responses, and don't poll the same company more often than you need. On repeat runs you can skip detail requests for job IDs you've already stored, at the cost of missing edits to those jobs.
When This Is Enough
For one company's careers widget, or a handful of BambooHR customers, the list-plus-detail pattern above is simple and free. The work grows when you're covering many companies across several platforms and want one consistent shape: country names to codes, free-text pay to numbers, and remote detection, all handled once instead of once per source.
How CleanJobData Handles BambooHR
CleanJobData reads BambooHR boards alongside the other platforms and returns them in the same schema: a resolved city, state and country, a remote type, an experience level and company details. See the location normalization guide for how locations get resolved. Because BambooHR's pay is free text, salary is the field most likely to be empty on jobs sourced from it.
Compare with Lever and Workable, or read how we normalized four ATS platforms. To see the output, try the CleanJobData Playground or read the API Documentation.