# Greenhouse, Lever, Ashby, Workable, SmartRecruiters Job Scraper (`djbrooks111/ats-jobs`) Actor

ATS jobs scraper: paste a Greenhouse, Lever, Ashby, Workable or SmartRecruiters job-board URL (boards.greenhouse.io/stripe) or a board slug and get up to the whole board as clean rows, with new/changed/removed tracking. From $1 per 1,000 jobs; descriptions and change rows $0.0005 each. No run fee.

- **URL**: https://apify.com/djbrooks111/ats-jobs.md
- **Developed by:** [David Brooks](https://apify.com/djbrooks111) (community)
- **Categories:** Jobs, Lead generation, MCP servers
- **Stats:** 1 total users, 0 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 delivered jobs

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.

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

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Greenhouse, Lever, Ashby, Workable, SmartRecruiters Job Scraper

ATS jobs scraper for Greenhouse jobs, Lever jobs, Ashby, Workable and SmartRecruiters — paste a job-board URL or slug, get up to the whole board as rows. From $1 per 1,000 jobs, no run fee.

Greenhouse, Lever, Ashby, Workable and SmartRecruiters are the applicant tracking systems behind a large share of company careers pages, and this actor reads all five from the same input. Paste the job-board URL the careers page links to — boards.greenhouse.io/stripe, jobs.lever.co/spotify, jobs.ashbyhq.com/openai, apply.workable.com/rockstar-3, careers.smartrecruiters.com/BoschGroup — or the short form `greenhouse:stripe`, and up to the whole board (`maxItems` caps it, 50 by default) comes back in one flat schema. Every row carries the title, the company (the board's display name, or its slug where the board publishes none), the posting URL and an apply URL; location, city, region and ISO country code, remote or hybrid or onsite, work type, department, salary and the posted date are filled wherever the board publishes them, and Known limits says which board leaves out what. Switch on change tracking and each run also tells you which postings are new, which changed and which have come down since the last run.

### What you get

- Five applicant tracking systems behind one input, so a run can mix boards from all five — and up to 200 boards in a single run
- One schema for all five: the same keys, the same `sourceId` format and a `schemaVersion` that never changes under you, whichever ATS a row came from
- `trackChanges` gives every row a `changeStatus` of new, changed or unchanged against the previous run of that board, and postings that have come down arrive as rows with `changeStatus: removed` — a job-feed monitor with no diffing of your own
- Location parsed into `city`, `region` and an ISO-3166 `country` code, even where the board publishes a country name ('United States') or a lower-case code ('mx') instead
- Salary as numbers — `salaryMin`, `salaryMax`, `salaryCurrency`, `salaryPeriod` — on the boards that publish compensation, plus the board's own summary text in `salaryText`
- Full descriptions are optional, so a list-only monitoring run stays cheap and pays nothing for payloads it does not want
- A job-board URL or `ats:slug` with no board behind it is named in the run summary, and a run in which every board is missing fails with a typed error instead of succeeding with zero rows

Sample row:

```json
{
  "sourceId": "ashby:openai:8fb1615c-34bf-47c4-a1d1-b7b2f836bbd3",
  "sourceUrl": "https://jobs.ashbyhq.com/openai/8fb1615c-34bf-47c4-a1d1-b7b2f836bbd3",
  "source": "ashby",
  "schemaVersion": 1,
  "title": "Technical Program Manager, Compute Infrastructure",
  "company": "openai",
  "companyId": "openai",
  "location": "San Francisco",
  "city": "San Francisco",
  "region": "California",
  "country": "US",
  "remote": "unknown",
  "workType": "full-time",
  "category": "Technical Program Management",
  "subcategory": "Technical Program Management",
  "salaryText": "$257K – $335K • Offers Equity",
  "salaryMin": 257000,
  "salaryMax": 335000,
  "salaryCurrency": "USD",
  "salaryPeriod": "year",
  "postedAt": "2026-03-12T16:38:15.322Z",
  "expiresAt": null,
  "applyUrl": "https://jobs.ashbyhq.com/openai/8fb1615c-34bf-47c4-a1d1-b7b2f836bbd3/application",
  "changeStatus": null
}
```

### Input

| Field | Description |
|---|---|
| `boards` | One entry per company: a job-board URL (`https://boards.greenhouse.io/stripe`, `https://jobs.lever.co/spotify`, `https://jobs.ashbyhq.com/openai`, `https://apply.workable.com/rockstar-3`, `https://careers.smartrecruiters.com/BoschGroup`) or the short form `ats:slug`. Up to 200 per run; anything else is rejected before the run starts, with the accepted forms listed |
| `includeDescription` | Fetch the full description (HTML and plain text). Charged per row that carries one |
| `since` | ISO date. Postings first published before it are skipped and never charged. A date filter only |
| `trackChanges` | Compare each board against its previous run: rows carry `changeStatus`, and postings that have come down are delivered as `removed` |
| `maxItems` | Hard cap on delivered jobs (default 50), enforced before the request that would exceed it |
| `maxCostUsd` | Hard stop on spend, enforced before each request |
| `proxyConfiguration` | Datacenter Apify Proxy by default; switch it off for small runs |
| `outputFormat` | flat (default) or nested |

### Pricing

Pay per event. You are charged only for delivered, validated results — never for empty pages, blocked requests, or duplicates. `maxCostUsd` is a hard stop enforced before each request.

| Event | Price | Unit |
|---|---|---|
| `job` | $0.001 | per delivered job — $1.00 per 1,000 |
| `job-description` | $0.0005 | per row with a full description — only with includeDescription |
| `company-diff` | $0.0005 | per new, changed or removed posting — only with `trackChanges`, and never on a board's first run |

- 1,000 jobs ≈ $1.00
- 1,000 jobs with full descriptions ≈ $1.50
- A daily monitor over 40 boards returning 200 postings with 20 changes ≈ $0.21 per run, about $6.30 a month
- There is no per-run fee: a run pays only for the rows it delivers, so a run over boards that return no rows costs nothing at all

### Why this actor

- One actor covers Greenhouse, Lever, Ashby, Workable and SmartRecruiters: paste each board URL as the careers page links to it and the actor reads the system from the host, so a run can mix all five
- No per-run start fee. Scheduled monitoring pays for the rows it delivers and nothing else, which matters when you run the same 40 boards every morning
- A job-board URL or `ats:slug` with no board behind it is named in the run summary under `meta.missingBoards`, and a run in which every board is missing fails with a typed `BoardNotFoundError`, rather than finishing successfully with zero rows and letting a broken monitor look healthy for weeks
- `maxItems` and `maxCostUsd` are enforced before the request that would exceed them, and the run summary says which cap stopped it
- Change detection is stored per board and is only updated after a complete pass over that board's feed, so a run stopped by a cap never makes the next run report postings it simply never reached as 'new' or 'removed'. A board whose posting count happens to equal `maxItems` exactly still counts as a complete pass, and a removal a cap left no room for is kept and reported on the next run rather than dropped
- Descriptions are priced separately and off by default, so a list-only feed watcher is not charged for payloads ten times the size it needs
- No AI 'enrichment' and no inferred fields priced as data: every value is the board's own, and anything the board does not publish is null rather than guessed (the two fallbacks are `company`, which is the board slug where the board publishes no display name, and `applyUrl`, which is the posting page where it publishes no separate apply link)

### Known limits

- Five systems only — Greenhouse, Lever, Ashby, Workable and SmartRecruiters. Workday, iCIMS, Recruitee, Personio, Teamtailor and company career sites on their own software are not supported, and the actor does not guess a company's ATS from its name or domain: you give it the job-board URL (e.g. boards.greenhouse.io/stripe) or `ats:slug`
- Greenhouse publishes no salary on its public job board API: none of the boards checked carried a pay range, so `salaryMin`, `salaryMax` and `salaryText` are null on Greenhouse rows. `category`, `subcategory` and `country` come from fields Greenhouse sends only with the full description, so they fill in with `includeDescription` — and for that reason `category` is left out of the Greenhouse change-detection hash, so turning descriptions on or off never reports a board as changed. The same holds on SmartRecruiters, whose own apply URL only comes with the full posting (without it, `applyUrl` is the posting page) and is excluded from the hash for that reason
- Greenhouse also has no workplace field, so `remote` on a Greenhouse row is read from the location label ('Remote - Japan') and is `unknown` when the label does not say
- Lever and Ashby publish no employer display name, so `company` on those rows is the board slug. Workable and SmartRecruiters do publish one, and Greenhouse sends it with every posting
- Workable's API carries no work type and no salary, so `workType`, `salaryMin` and `salaryMax` are null on Workable rows
- Ashby returns an entire board in a single response — 830 postings measured 14.5 MB, with the compensation fields this actor always requests — so `maxItems` limits what you are charged for, not how much is downloaded. The same is true of Greenhouse, which has no pagination either
- Because of that, the actor asks the platform for at least 1 GB of memory and refuses a single response larger than 64 MB. Both numbers come from the same measurement — parsing a 14.5 MB board peaked at 168 MB resident, and the cost scales at roughly 6.9 MB of memory per MB of response on top of a 74 MB baseline, so a response at the 64 MB ceiling needs about 500 MB before a single row is mapped. Run it with less and the largest boards are killed by the container rather than reported. Raise the memory rather than the ceiling if you ever see a board refused for size
- Workable and SmartRecruiters descriptions need one extra request per job, so `includeDescription` runs on those two boards are slower (Greenhouse, Lever and Ashby send the description with the listing)
- SmartRecruiters' Posting API answers identically for a company id that does not exist and a company with no live postings, so the actor checks where the public careers page ends up to tell them apart. A company id it cannot confirm is reported as an error rather than as an empty board, because a silently empty monitor is worse than a loud one. Requests to that host are kept to one per second
- Salary appears only where the board publishes it. Most do not: expect it on Ashby boards that display compensation, and rarely elsewhere
- Change detection needs `trackChanges` and a complete pass over the board: a run in which `maxItems` or `maxCostUsd` cut a board's feed short deliberately leaves that board's stored baseline alone and reports no removals for it, so give a monitored board a `maxItems` above its posting count. Removal rows count against `maxItems` like any other row, and removals the cap had no room for stay in the baseline and are reported on the next run — `meta.deferredRemovals` says how many are still waiting. `since` is a posted-date filter and nothing else, the same as in every other actor in this account. If the platform restarts a run part-way through, the rows it had already delivered are counted as delivered: they are not billed again and they stay in the baseline
- A board that is missing or empty is reported, not silently dropped: each one is logged as a warning while the run continues, and the run's `RUN_SUMMARY` record in the key-value store lists them under `meta.missingBoards` and `meta.emptyBoards`, alongside `meta.requested`, `meta.delivered`, `meta.skipped`, `meta.diffMode`, the per-board breakdown in `meta.boards`, and — in `trackChanges` runs — `meta.newPostings`, `meta.changedPostings`, `meta.unchangedPostings`, `meta.removedPostings`, `meta.deferredRemovals` and `meta.removalOnlyBoards`. A board that went quiet is listed under `meta.removalOnlyBoards` rather than `meta.emptyBoards`: its feed is empty, but every posting it used to carry was delivered as a removal row and charged, which is the opposite of nothing happening. Read that record to find out which of a long `boards` list actually produced rows. The run fails with a `BoardNotFoundError` only when every board is missing
- Employer job postings only. No candidate, applicant or recruiter personal data is collected from any of the five systems

### Use from an AI agent (MCP)

Add the actor as an MCP tool in Claude Desktop / Cursor via the Apify MCP server:

```json
{ "mcpServers": { "ats-jobs": { "url": "https://mcp.apify.com?actors=<your-username>/ats-jobs", "headers": { "Authorization": "Bearer <APIFY_TOKEN>" } } } }
```

The tool accepts exactly the input fields above and returns the same rows, so an agent can ask for a
company's open roles by passing the job-board URL (e.g. boards.greenhouse.io/stripe) or `ats:slug` it
already found.

### Use from n8n / Make

Use the Apify node → "Run actor" with actor `<your-username>/ats-jobs` on a daily schedule and a JSON
input such as
`{"boards":["greenhouse:stripe","lever:spotify","ashby:openai"],"trackChanges":true,"maxItems":500}`,
then "Get dataset items" and filter on `changeStatus` to act only on what moved since yesterday.

### FAQ

**How do I find a company's board slug?**

You do not need it. Open the company's careers page, and if it links to (or the address bar shows) one of the five ATS hosts, paste that job-board URL, e.g. boards.greenhouse.io/stripe. The actor takes the slug out of it, including from a single job's URL. A careers page on the company's own domain is not accepted.

**What happens if a company moves to a different ATS?**

The old board starts answering "not found" and that board is reported as missing in the run summary, with nothing charged for it. Add the new job-board URL (e.g. boards.greenhouse.io/stripe) or `ats:slug` and the rows continue under a new `source` value.

**How does change detection know what changed?**

After a complete pass over a board it stores a hash of the fields a feed watcher cares about — title, location, work type, remote arrangement, salary, department, posted date and apply URL — per posting. Descriptions are deliberately excluded, because boards re-render their HTML without the job changing. So is department on Greenhouse, which publishes it only alongside the full description — hashing it would have reported a whole board as changed the first time you turned `includeDescription` on. The apply URL on SmartRecruiters is left out for the same reason.

**Why is salary null on most rows?**

Because most boards do not publish it. The actor reports what the board sends and leaves the rest null rather than inventing a figure or inferring one from the job title.

**Do I need a proxy?**

Not for small runs. All five APIs answer datacenter and plain IPs today. The default is datacenter Apify Proxy with session rotation because each host rate-limits per address, which matters once you monitor dozens of boards on a schedule.

### Legal and data posture

This actor reads five documented, public, keyless job board APIs: the Greenhouse Job Board API, the Lever Postings API, the Ashby Posting API, Workable's public careers widget API and the SmartRecruiters Posting API. It never logs in, never uses accounts or cookies from accounts, and never solves a challenge. It collects employer job advertisements only, and no personal data about candidates, applicants or recruiters. Request rates are conservative — one request per second per host, which honours Lever's published crawl delay and SmartRecruiters' documented API guidance — and 429 responses fail the run with a typed error instead of being retried in a loop. SmartRecruiters' robots.txt disallows crawling the API host even though the Posting API is published and documented for exactly this use; the rate limit above is our answer to that, and it is stated here rather than buried. Data is delivered to you for your own use; no corpus is built or resold.

### Support

Open an issue on the Issues tab; first response within 6 hours on business days. Every run writes a `RUN_SUMMARY` record to the key-value store with items delivered, events charged, and the stop reason.

# Actor input Schema

## `boards` (type: `array`):

One entry per company. Paste the careers URL — https://boards.greenhouse.io/stripe, https://job-boards.greenhouse.io/stripe, https://jobs.lever.co/spotify, https://jobs.ashbyhq.com/openai, https://apply.workable.com/rockstar-3, https://careers.smartrecruiters.com/BoschGroup — or use the short form `ats:slug` (`greenhouse:stripe`, `lever:spotify`, `ashby:openai`, `workable:rockstar-3`, `smartrecruiters:BoschGroup`). Anything else is rejected before the run starts, with the accepted forms listed. Up to 200 boards per run.

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

Fetch the full job description (HTML and plain text). Costs one `job-description` event per row that carries one. Greenhouse, Lever and Ashby return it with the listing; Workable and SmartRecruiters need one extra request per job, so those runs are slower.

## `since` (type: `string`):

ISO date (YYYY-MM-DD), read as UTC midnight. Postings first published before it are skipped and never charged. A value that is not a date is rejected before the run starts. This is only a date filter — change detection is the separate "Track changes" option.

## `trackChanges` (type: `boolean`):

Compare each board against the previous run of the same board. Every row comes back with `changeStatus` (new, changed or unchanged) and postings that have come down are delivered with `changeStatus: removed`. Each new, changed or removed posting costs one `company-diff` event; a board's first run is never charged for one. A run stopped by a cap deliberately leaves the stored baseline alone.

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

Hard cap on delivered jobs for this run. Default 50 keeps free trials cheap; raise it for production. Enforced before the request that would exceed it.

## `maxCostUsd` (type: `number`):

Hard stop: the run never charges more than this. Enforced before each request, not after.

## `stateNamespace` (type: `string`):

Optional suffix for this actor's cross-run state store, so separate schedules or test runs keep separate monitor baselines and watermarks (e.g. `prod`, `staging`).

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

Datacenter Apify Proxy with session rotation. All five ATS APIs answer datacenter IPs today; rotation matters only because each host rate-limits per address. Switch it off for small runs, or to residential only if you see blocks.

## `outputFormat` (type: `string`):

flat (default): one level of keys, spreadsheet-friendly. nested: keep objects.

## Actor input object example

```json
{
  "boards": [
    "https://boards.greenhouse.io/stripe",
    "https://jobs.lever.co/spotify"
  ],
  "includeDescription": false,
  "trackChanges": false,
  "maxItems": 50,
  "maxCostUsd": 5,
  "proxyConfiguration": {
    "useApifyProxy": true
  },
  "outputFormat": "flat"
}
```

# Actor output Schema

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

Every delivered row, validated against the actor's entity schema (flat keys, ISO dates, sourceUrl, scrapedAt, sourceId). Only delivered rows are billed.

## `runSummary` (type: `string`):

RUN_SUMMARY: items delivered, events charged, USD charged, blocks met, requests made, the typed error if the run failed, and actor-specific meta.

# 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 = {
    "boards": [
        "https://boards.greenhouse.io/stripe",
        "https://jobs.lever.co/spotify"
    ],
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("djbrooks111/ats-jobs").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 = {
    "boards": [
        "https://boards.greenhouse.io/stripe",
        "https://jobs.lever.co/spotify",
    ],
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("djbrooks111/ats-jobs").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 '{
  "boards": [
    "https://boards.greenhouse.io/stripe",
    "https://jobs.lever.co/spotify"
  ],
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call djbrooks111/ats-jobs --silent --output-dataset

```

## MCP server setup

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

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/MV3QFq3rSNalpZhYI/builds/n6tmssq2GlVzdcnCZ/openapi.json
