# BambooHR Jobs Scraper - Any Company's Open Jobs (`neverempty/bamboohr-jobs-scraper`) Actor

Every open job of any company that hires through BambooHR, one row per job: title, department, employment type, city, state, country, link, and with one switch the posting date, compensation text and full description. A company ID that does not exist is reported, never returned as zero jobs.

- **URL**: https://apify.com/neverempty/bamboohr-jobs-scraper.md
- **Developed by:** [NeverEmpty](https://apify.com/neverempty) (community)
- **Categories:** Jobs, Lead generation, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.19 / 1,000 job returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## BambooHR Jobs Scraper - Any Company's Open Jobs

Pull every open job of any company that hires through **BambooHR** - the careers sites at `<company>.bamboohr.com/careers` - read straight from the JSON that BambooHR's own careers page loads. No login, no API key, no proxies, no HTML scraping.

One row per job: title, department, employment type, city, state, country and a link to the job page - plus, with one switch, the posting date, the compensation text, the minimum experience level, the postal code and the full job description.

*Unofficial. This Actor is not affiliated with, endorsed by, or sponsored by BambooHR. All product names are trademarks of their respective owners.*

***

### A company that does not exist is never reported as "0 jobs"

BambooHR answers a company ID that does not exist with a **redirect to www.bamboohr.com**, and a company that exists but has no openings with **`totalCount: 0`**. Those are two different answers, and this Actor keeps them apart: a typo in the company ID gets a `company-not-found` row that says where to find the right ID, and a company with nothing open gets a `no-openings` row. Neither is charged, and neither is an empty dataset.

| `rowType` | `ok` | What it means | Charged? |
|---|---|---|---|
| `job` | `true` | A real open job | Yes |
| `no-openings` | `true` | The company's BambooHR careers site exists and lists no open jobs | **No** |
| `no-matching-jobs` | `true` | The company has jobs, but none match your filters - the row says how many it has | **No** |
| `company-not-found` | `false` | No BambooHR careers site exists for this ID (BambooHR redirects it to www.bamboohr.com). The row tells you where to find the right one | **No** |
| `posting-closed` | `false` | A job that was in the list but whose own page says it is gone or no longer Open when its details were read - not returned as an open job | **No** |
| `incomplete` | `false` | BambooHR reported more openings than it returned; the jobs that were read are returned | **No** |
| `more-jobs-available` | `true` | `maxResults` cut the list; says how many were left out | **No** |
| `not-looked-up` | `false` | Not fetched because `maxResults` was already reached, or the run was close to its time limit | **No** |
| `duplicate-input` | `false` | The same company was given twice (IDs are not case-sensitive); its jobs are returned once | **No** |
| `bad-input` | `false` | Not a BambooHR company ID or careers link | **No** |
| `unavailable` | `false` | BambooHR could not be reached after retries, or answered in a shape this Actor does not read | **No** |
| `budget-reached` | `false` | The run hit your maximum charge before or during this company | **No** |

You are charged **only for `job` rows.**

***

### What BambooHR publishes where - measured, not estimated

Measured 2026-09-12 on 10 companies with open jobs (`401auto`, `basdensteel`, `montroseholdings`, `kiln`, `flyio`, `cardinal`, `fbcs`, `beehiiv`, `avaindustries`, `posthog`): **208 open jobs**, every one of them also read from its own page.

The **list** (`/careers/list`) is one request per company - there is no paging - and it carries only part of each job:

| Column | Filled (208 jobs) | Source |
|---|---|---|
| `title`, `employmentType`, `jobUrl` | 100% | list |
| `department` | 97% | list |
| `city` / `state` (location) | 95% | list, from `location` or, when that is empty, `atsLocation` |
| `country` | **2%** (4 of 208) | list - only a few companies fill it there; **94% (195 of 208) once the job page is read** |
| `postalCode` | **0%** | list - only on the job page (94%) |
| `isRemote` | **0%** | list - BambooHR sends the field but it was `null` for all 208 jobs, so it stays `null` here |

The **job page** (`/careers/<id>/detail`) is where the rest lives. With `includeDescription` on, each job costs one more request and the row gains:

| Column | Filled (208 jobs) |
|---|---|
| `postedAt` (date only, as BambooHR gives it), `descriptionText` | 100% |
| `minimumExperience`, `country`, `postalCode` | 94% |
| `compensationText` | 66% |

`compensationText` is whatever the company typed - `$55,000 per year`, `$19/hr`, `TBD`, `Up to $20 DOE` - and is left as text. No number is parsed out of it, and nothing is filled in when it is empty.

`locationTypeCode` is BambooHR's own code (`0`, `1` or `2`). It was `0` for all 194 jobs that carry a street address and `1`/`2` for the 14 that do not, but BambooHR publishes no key for it, so it is passed through as-is rather than translated into "remote" or "on-site".

***

### Filters

BambooHR's list has no search of its own, so every filter is applied here, after reading the list, against the fields the list carries: `titleContains`, `departmentContains`, `locationContains` and `keyword` (title, department, location, employment type). Filters do not look into the description. When a filter can never match on a company - it publishes no department, or no location - the `no-matching-jobs` row says so instead of pretending no job qualifies.

**Location needs one more word.** The list carries only the city and the state (a country for 4 of the 208 measured jobs, never a postal code). So on its own, `locationContains` matches city and state - `Kitchener`, `Ontario` - and a country or postal code returns a `no-matching-jobs` row that says exactly this. With **`includeDescription: true`** every listed job's page is read *before* the filter is applied, and `locationContains` then also matches the country and the postal code: `Canada` returns 118 of 401 Auto's 124 jobs, `N2C` the jobs in that Kitchener postal district. Jobs whose page could not be read are judged on the list's city and state only.

***

### Input

| Field | Type | Default | What it does |
|---|---|---|---|
| `companies` | array | *(prefilled: `401auto`)* | BambooHR company IDs or careers links, up to 200. If empty, `401auto` is used and every row says so in `input` |
| `keyword` | string | *(empty)* | Keep jobs whose title, department, location or employment type contains this text |
| `titleContains` | string | *(empty)* | Keep jobs whose title contains this text (case-insensitive) |
| `departmentContains` | string | *(empty)* | Keep jobs whose department contains this text |
| `locationContains` | string | *(empty)* | Matches city and state from the list (`Kitchener`, `Ontario`); with `includeDescription` also country and postal code (`Canada`, `N2C`) |
| `includeDescription` | boolean | `false` | Read each job's page: adds the description, posting date, compensation text, experience level, postal code and country (one extra request per job, 8 in parallel). With `locationContains`, every listed job's page is read before filtering |
| `maxResults` | integer | `1000` | Job rows for the whole run; a `more-jobs-available` row counts the rest, later companies get `not-looked-up` |
| `maxRetries` | integer | `3` | Attempts per request for temporary errors (HTTP 429 / 5xx) |

**Finding a company ID:** open the company's careers page. The address looks like `https://401auto.bamboohr.com/careers` - `401auto` is the ID. You can also paste that address, or a job link such as `https://401auto.bamboohr.com/careers/560`.

#### Descriptions

With `includeDescription` on, `descriptionStatus` says what happened for each job:

- `ok` - the description is in `descriptionText` (`empty` if the page has none)
- `failed: ...` - BambooHR did not return the page after retries; **the job is still returned and charged**, because everything from the list is real, but `postedAt`, `compensationText` and the other page-only columns stay `null`
- `not-fetched-time-limit` - the run was close to its time limit, so pages were skipped to return the jobs in time (still charged); companies after it get a `not-looked-up` row
- A job whose page answers 404 or is no longer marked `Open` is **not** returned as a job - it becomes a free `posting-closed` row

Without `includeDescription`, `descriptionStatus` and the page-only columns are `null` for every job.

***

### Output columns

`rowType`, `ok`, `note`, `input`, `companyId`, `jobId`, `title`, `department`, `departmentId`, `employmentType`, `city`, `state`, `country`, `postalCode`, `fullLocation`, `isRemote`, `locationTypeCode`, `postedAt`, `compensationText`, `minimumExperience`, `jobUrl`, `descriptionText`, `descriptionStatus`, `searchedKeyword`, `checkedAt`

Every row - including note rows - has all 25 columns in the same order.

#### Things it refuses to invent or pass on

- **No people's names - but the application address stays.** The list and the job page carry no recruiter name. Inside descriptions, an e-mail address whose local part looks like a person's name (`jane.doe@`, `janedoe@`) is replaced with `[email removed]`; role mailboxes such as `careers@`, `hr@` or `jobs+infra@` and phone numbers are the way to apply, so they are left as written (measured: all 125 addresses and 118 phone numbers in the 208 descriptions were role mailboxes and a company switchboard).
- **`isRemote`** is only ever what BambooHR sends. A job without a location is not turned into "remote"; its `fullLocation` stays `null`.
- **`postedAt`** is the date BambooHR publishes (`YYYY-MM-DD`). No time of day is added.
- **`employmentType`** is the company's own label (`Full-Time`, `Part-Time`, `Full-Time - Weekly Paid (Ocean Hotels)`), not normalised into a guess.
- **Empty values are `null`**, never an empty string or a `0`.

***

### How it is checked

- **71 automated checks**, most of which run the real list, page, retry, filter and charging code against recorded BambooHR responses (a company that does not exist, an empty company, a list shorter than its count, closed postings, budget and time limits)
- **72 deliberate defects injected into the source, plus 23 into the charging path; every one was caught by those checks** (re-run 2026-09-12)
- The rows this Actor returns for the 10 measured companies were compared against the companies' lists fetched directly: 208 of 208 jobs, same IDs, same order
- Only public careers pages are read. Nothing needs a login
- Every request has a 45-second timeout; temporary errors are retried with a growing pause

### Support

Found a company that behaves oddly? Open an issue on the **Issues** tab with the company ID - it will be reproduced against the live careers site before anything is changed.

# Actor input Schema

## `companies` (type: `array`):

One entry per company: the part before .bamboohr.com in its careers link (for example 401auto for https://401auto.bamboohr.com/careers - not case-sensitive), or the careers link itself. A company that has no BambooHR careers site returns a company-not-found note row, never an empty result; a company that exists with no openings returns a no-openings row. Up to 200 per run.

## `keyword` (type: `string`):

Optional. Keep only jobs whose title, department, location or employment type contains this text (case-insensitive). BambooHR's list has no search of its own, so this is matched here, against the list fields only (not the description).

## `titleContains` (type: `string`):

Optional. Keep only jobs whose title contains this text (case-insensitive).

## `departmentContains` (type: `string`):

Optional. Keep only jobs whose department contains this text (case-insensitive). Some companies publish no department; the no-matching-jobs row then says so instead of pretending none qualify.

## `locationContains` (type: `string`):

Optional. Keep only jobs whose location contains this text (case-insensitive), for example Kitchener or Ontario. BambooHR's list carries only city and state (a country for a few jobs, never a postal code), so on its own this matches city and state. With includeDescription:true every listed job's page is read first and the filter also matches the country and the postal code (Canada, N2C). Jobs without any location never match.

## `includeDescription` (type: `boolean`):

Reads each job's own page (one extra request per job, 8 in parallel). This adds the full description as plain text and the fields BambooHR only publishes there: the posting date, the compensation text, the minimum experience level, the postal code and the country. A job whose page no longer exists, or is no longer marked Open, is not returned as a job and not charged. The descriptionStatus column says when a page could not be read; the job is still returned and charged.

## `maxResults` (type: `integer`):

Upper limit of job rows for the whole run, in the order the companies and their lists come. When the limit cuts a company, a more-jobs-available note row says how many were left out, and companies after it get a not-looked-up row. You are charged only for job rows returned.

## `maxRetries` (type: `integer`):

How many times to try a request in total, including the first attempt, when BambooHR answers with a temporary error (HTTP 429 or 5xx). A redirect (company not found) or a 404 (job closed) is never retried.

## Actor input object example

```json
{
  "companies": [
    "401auto"
  ],
  "includeDescription": false,
  "maxResults": 1000,
  "maxRetries": 3
}
```

# Actor output Schema

## `results` (type: `string`):

One row per open job, plus one note row when a company does not exist, has no open or matching jobs, or could not be read.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "companies": [
        "401auto"
    ],
    "keyword": ""
};

// Run the Actor and wait for it to finish
const run = await client.actor("neverempty/bamboohr-jobs-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "companies": ["401auto"],
    "keyword": "",
}

# Run the Actor and wait for it to finish
run = client.actor("neverempty/bamboohr-jobs-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "companies": [
    "401auto"
  ],
  "keyword": ""
}' |
apify call neverempty/bamboohr-jobs-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,neverempty/bamboohr-jobs-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/ZReHGlfhXete8cJRh/builds/TK2rvz90V9K4jiX7d/openapi.json
