# ATS Jobs Scraper (All In One) (`scrapyx/ats-jobs-scraper`) Actor

Scrapes live jobs from four ATS platforms — Ashby, BambooHR, Breezy HR and SmartRecruiters — into one unified schema. Paste mixed career page URLs and each is routed automatically. Split city/state/country, ISO 8601 UTC dates, and descriptions as clean Markdown for LLM use.

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

## Pricing

from $0.56 / 1,000 results

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

## All-in-One ATS Jobs Scraper

Scrapes live job postings from **four applicant tracking systems** — [Ashby](https://www.ashbyhq.com),
[BambooHR](https://www.bamboohr.com), [Breezy HR](https://breezy.hr) and
[SmartRecruiters](https://www.smartrecruiters.com) — and returns them in **one
unified schema**.

Paste a mixed list of career page URLs. The Actor works out which ATS each one
belongs to from the URL and routes it automatically, so you never have to sort
your list by platform or run four different scrapers and reconcile the output
yourself.

Public data only. No login, no cookies, no browser, and **no bot challenge on
any of the four** — every one of 8 TLS fingerprints tested returned a clean 200
from an ordinary residential connection.

### Why one Actor instead of four

The four platforms differ in transport — how they paginate, whether
descriptions arrive with the listing or need a second request, what they do
when a company does not exist. They do not differ in *purpose*: each is a
company-scoped public job board.

Which ATS a company runs is an implementation detail its candidates never
chose and should not have to care about. If you are tracking 200 companies,
some are on Ashby and some are on BambooHR, and what you want is one table.
**Reconciling four location shapes, four employment-type vocabularies and four
timestamp formats is the hard part of this job** — so the Actor does it, once,
rather than handing you the same problem four times.

### Supported URL forms

| ATS | Career page URL | Single job URL |
| --- | --- | --- |
| **Ashby** | `https://jobs.ashbyhq.com/posthog` | `https://jobs.ashbyhq.com/posthog/<uuid>` |
| **BambooHR** | `https://nectar.bamboohr.com/careers` | `https://nectar.bamboohr.com/careers/157` |
| **Breezy HR** | `https://boldare.breezy.hr` | `https://boldare.breezy.hr/p/<id>` |
| **SmartRecruiters** | `https://jobs.smartrecruiters.com/BoschGroup` | `https://jobs.smartrecruiters.com/BoschGroup/<id>` |

A URL naming one job scrapes just that posting. The shorthand
`platform:company` works too — `ashby:posthog`, `bamboohr:nectar`,
`breezy:boldare`, `smartrecruiters:BoschGroup`.

If a company's careers page is on its own domain (`careers.acme.com`), it is
usually just embedding one of these four. Use the ATS URL its **Apply** button
points at.

### What you get

Three record types share one dataset, told apart by `recordType`.

#### `JOB` — one row per posting

Every job row has **the same fields regardless of which ATS it came from**:

```jsonc
{
  "atsPlatform": "ashby",
  "companySlug": "vanta",
  "companyName": "Vanta",
  "jobId": "f8af3807-3595-4580-a65c-dad2e268ace5",
  "title": "Technical Account Executive - EMEA",
  "jobUrl": "https://jobs.ashbyhq.com/vanta/f8af3807-…",
  "applyUrl": "https://jobs.ashbyhq.com/vanta/f8af3807-…/application",
  "department": "Sales & Customer Success",
  "team": "New Business Sales",
  "employmentType": "FULL_TIME",          // FULL_TIME | PART_TIME | CONTRACT
                                          // | INTERNSHIP | TEMPORARY
                                          // | VOLUNTEER | OTHER
  "workplaceType": "REMOTE",              // REMOTE | HYBRID | ONSITE
  "location": {
    "raw": "Remote U.S.",
    "city": null,
    "state": null,
    "country": "United States",
    "countryCode": "US",                  // ISO 3166-1 alpha-2
    "postalCode": null,
    "latitude": null,
    "longitude": null,
    "isRemote": true
  },
  "secondaryLocations": [ /* same shape */ ],
  "publishedAt": "2026-06-12T09:30:02Z",  // ISO 8601, always UTC
  "updatedAt": null,
  "compensation": { "raw": "…", "minimum": 150000, "maximum": 190000,
                    "currency": "USD", "interval": "YEARLY" },
  "descriptionHtml": "<h2>About…",
  "descriptionMarkdown": "## About…",     // clean Markdown, for LLM/RAG use
  "descriptionText": "About…",
  "isListed": true,
  "raw": { /* upstream's object, verbatim */ }
}
```

`raw` keeps the untouched upstream object, so nothing is lost to
normalisation and you can always reach a platform-specific field the unified
schema does not model.

#### `BOARD_SUMMARY` — one row per career page

The board's own job count, how many rows this run took, how many requests it
cost, and the honesty flags below.

#### `ERROR` — one row per input that failed

**Every input URL produces at least one row**, so a board that 404s or a URL
that is not an ATS at all is visible in the dataset rather than silently
missing.

### Descriptions as Markdown

ATS job descriptions are HTML written in rich-text editors: inline styles,
spacer paragraphs, nested lists. `descriptionMarkdown` is that HTML converted
to clean Markdown with ATX headings, which is what makes this dataset directly
usable as LLM input without a second cleaning pass.

On SmartRecruiters the description arrives as four separate blocks — company
description, job description, qualifications, additional information. They are
concatenated in the order the real job page shows them, each under its own
heading, rather than being flattened into one undifferentiated wall of text.

### Cost: the `includeDescription` switch

This is the Actor's main cost lever, and it behaves differently per platform:

| ATS | Requests with descriptions | Requests without |
| --- | --- | --- |
| **Ashby** | 2 per board, **any number of jobs** | 1 per board |
| **BambooHR** | 1 + 1 per job | 1 per board |
| **Breezy HR** | 1 + 1 per job | 1 per board |
| **SmartRecruiters** | 1 per 100 jobs + 1 per job | 1 per 100 jobs |

Ashby ships the full description inside the board response, so descriptions
there are effectively free.

**One caveat**: turning descriptions off on BambooHR also drops
`location.country` and `publishedAt`, because that platform only returns them
on the per-job response.

### Known limits and honest flags

**A nonexistent SmartRecruiters company looks exactly like a real one with no
open jobs.** It answers `totalFound: 0` either way, and no other endpoint
distinguishes them — `jobs.smartrecruiters.com/<company>` and
`careers.smartrecruiters.com/<company>` redirect identically for a real-but-empty
company and a misspelt one. Rather than report a confident "0 jobs", the
summary row sets **`companyExistenceUnverified: true`** and the run logs a
warning. The other three platforms are honest: Ashby and Breezy return 404,
BambooHR redirects away.

**`workplaceType` is left `null` rather than guessed.** Some Ashby postings
carry no workplace field at all; where the location text does not clearly say
remote or hybrid, the Actor emits `null` instead of assuming on-site.

**BambooHR exposes no company display name.** Its API has no such field and
every tenant's careers page is titled "BambooHR", so `companyName` falls back
to the URL slug there. Ashby's name is read from the board page and falls back
to the slug on the minority of tenants that serve an un-rendered page.

**`pageSize` only affects SmartRecruiters.** It is the only one of the four
that paginates; the others return their whole board in a single response. It is
capped at 100 because SmartRecruiters silently clamps anything higher back to
100 while still reporting success.

### Input

| Field | Type | Default | Notes |
| --- | --- | --- | --- |
| `startUrls` | array | — | **Required.** Mixed career page URLs. |
| `includeDescription` | boolean | `true` | Full descriptions + Markdown. |
| `maxItems` | integer | `0` | Jobs per board; 0 = all. |
| `pageSize` | integer | `100` | SmartRecruiters only. Max 100. |
| `maxConcurrency` | integer | `6` | Total requests in flight. |
| `minRequestInterval` | integer | `0` | Seconds between request starts. |
| `proxyConfiguration` | object | Residential | Not needed to get past a bot wall — there isn't one. |

### Notes on politeness

None of these platforms rate-limited during testing, but many of these boards
belong to small companies rather than enterprises. The defaults are modest on
purpose, and `minRequestInterval` — not `maxConcurrency` — is the honest speed
control: once a rate cap binds, extra concurrency buys nothing.

See [`CRAWLING_METHOD.md`](CRAWLING_METHOD.md) for the full recon trail, the
endpoints, and every trap found while building this.

# Actor input Schema

## `startUrls` (type: `array`):

Career page URLs from any mix of the four supported ATS platforms — the Actor detects which is which from the URL and routes each one automatically, so you can paste them all into a single run.

Supported forms:

- **Ashby** — `https://jobs.ashbyhq.com/posthog`
- **BambooHR** — `https://nectar.bamboohr.com/careers`
- **Breezy HR** — `https://boldare.breezy.hr`
- **SmartRecruiters** — `https://jobs.smartrecruiters.com/BoschGroup`

A URL that points at one specific job (`https://jobs.ashbyhq.com/posthog/<uuid>`, `https://nectar.bamboohr.com/careers/157`, `https://boldare.breezy.hr/p/<id>`) scrapes just that posting instead of the whole board.

You can also use the shorthand `platform:company` — `ashby:posthog`, `bamboohr:nectar`, `breezy:boldare`, `smartrecruiters:BoschGroup`.

If a company's careers page lives on its own domain, use the ATS URL its Apply button points at — that is the one this Actor can read.

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

Fetch each job's full description and convert it to clean Markdown (`descriptionMarkdown`) alongside the original HTML.

This is the Actor's main cost lever. On **Ashby** it is free — the description already ships in the board response, so no extra requests are made either way. On **BambooHR**, **Breezy** and **SmartRecruiters** it costs one extra request per job.

Turning it off on BambooHR also drops `location.country`, which only appears on that platform's detail response.

## `maxItems` (type: `integer`):

Stop after this many jobs from each career page. Set to 0 for every job on the board.

Defaults to a bounded 200 rather than unlimited because board sizes vary enormously — a single Ashby board can hold 700+ postings and one SmartRecruiters enterprise board over 4,700 (the default example, BoschGroup, is one of these) — so an unbounded first run can turn into a multi-thousand-request crawl. Raise it or set it to 0 once you know how big your target boards are.

## `pageSize` (type: `integer`):

Rows per request, for SmartRecruiters — the only one of the four platforms that paginates at all. Ashby, BambooHR and Breezy return their whole board in a single response and ignore this setting.

Capped at 100 because SmartRecruiters silently clamps anything higher back to 100 while still reporting success, so a larger number here would quietly mean something other than what it says.

## `maxConcurrency` (type: `integer`):

Upper bound on requests in flight across the whole run — boards and their per-job detail fetches draw from the same budget.

None of these four platforms showed rate limiting during testing, but they are small companies' careers pages as often as they are enterprises'; the default is deliberately modest.

## `minRequestInterval` (type: `integer`):

Seconds to leave between the START of one request and the next, across the whole run. 0 disables pacing.

This, not concurrency, is the honest speed control: once a rate cap binds, extra concurrency buys nothing.

## `proxyConfiguration` (type: `object`):

All four platforms answered every TLS fingerprint tested from a plain residential connection, so a proxy is not needed to get past a bot wall here — there isn't one.

Residential is still the default on the platform, because a cloud run shares datacenter egress with every other Actor and these APIs rate-limit by IP long before they fingerprint.

## Actor input object example

```json
{
  "startUrls": [
    "https://jobs.ashbyhq.com/posthog",
    "https://nectar.bamboohr.com/careers",
    "https://boldare.breezy.hr",
    "https://jobs.smartrecruiters.com/BoschGroup"
  ],
  "includeDescription": true,
  "maxItems": 200,
  "pageSize": 100,
  "maxConcurrency": 6,
  "minRequestInterval": 0,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# Actor output Schema

## `items` (type: `string`):

One row per scraped record. See the dataset's default view for field definitions.

# 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 = {
    "startUrls": [
        "https://jobs.ashbyhq.com/posthog",
        "https://nectar.bamboohr.com/careers",
        "https://boldare.breezy.hr",
        "https://jobs.smartrecruiters.com/BoschGroup"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapyx/ats-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 = { "startUrls": [
        "https://jobs.ashbyhq.com/posthog",
        "https://nectar.bamboohr.com/careers",
        "https://boldare.breezy.hr",
        "https://jobs.smartrecruiters.com/BoschGroup",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("scrapyx/ats-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 '{
  "startUrls": [
    "https://jobs.ashbyhq.com/posthog",
    "https://nectar.bamboohr.com/careers",
    "https://boldare.breezy.hr",
    "https://jobs.smartrecruiters.com/BoschGroup"
  ]
}' |
apify call scrapyx/ats-jobs-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapyx/ats-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/CLhMaMEBHmIGbkewH/builds/5TXeCEFRhUa6IzDWr/openapi.json
