# ATS Jobs Scraper: Greenhouse, Lever, Ashby & More (`zerahdata/ats-jobs-scraper`) Actor

Fetch all open jobs from a company's career board on Greenhouse, Lever, Ashby, Workable, or SmartRecruiters using the official public job-board JSON APIs.

- **URL**: https://apify.com/zerahdata/ats-jobs-scraper.md
- **Developed by:** [Zerah Data](https://apify.com/zerahdata) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$1.00 / 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.

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

## ATS Job Board Scraper - Greenhouse, Lever, Ashby, Workable, SmartRecruiters

Fetch every open job from a company's career page on **Greenhouse**, **Lever**,
**Ashby**, **Workable**, or **SmartRecruiters** - using each platform's own
public job-board JSON API, not HTML scraping and not a login.

### Why this actor

- **Five live ATS platforms, not three** - including Workable, which several
  comparable multi-ATS scrapers skip entirely.
- **Don't know which ATS a company uses?** Give it a bare company slug and
  it auto-detects the platform by trying Greenhouse, Lever, Ashby, Workable,
  then SmartRecruiters in order, and uses whichever one responds.
- **Insight rows, not just raw rows.** An opt-in per-company hiring summary
  (open roles, department/location mix, remote share, salary transparency,
  median pay) and opt-in new/closed-job change tracking between scheduled
  runs - without buying a second actor for either.
- **Deeper, deterministic normalization**: rule-based seniority, a
  canonical department taxonomy, best-effort country/city, consolidated
  remote detection, and salary parsed into min/max/currency/period even when
  a platform only publishes free text. Same input always produces the same
  output - no probabilistic guessing.
- **A real self-check, every run.** A `RUN_REPORT` record is written to the
  key-value store on every run: rows produced, errors by cause, warnings for
  companies that returned nothing, and a check that required fields are
  actually non-empty in the output - not just a success/failure flag.
- **No telemetry, no personal data.** Nothing about your run or inputs is
  recorded anywhere outside your own Apify account, and no hiring-manager
  names/emails or other personal data are ever extracted.
- **Reads each platform's own public API directly** - no login, no headless
  browser, so it can't break because a careers page redesign changed some
  HTML.
- **Priced at $1.00 per 1,000 results** - cheaper than comparable multi-ATS
  job scrapers.

### What it does

Give it one or more career-board URLs, company slugs, or bare company names
and it will:

- Auto-detect which of the five supported ATS platforms a URL belongs to, or
  (when you only give a name) probe all five and use the one that responds.
- Call that platform's official public job-board API directly.
- Paginate through all open jobs (SmartRecruiters) or fetch the full board in
  one call (Greenhouse, Lever, Ashby, Workable).
- Normalize every job into one consistent row: title, department (raw and a
  canonical normalized category), team, location (plus best-effort parsed
  country/city), remote/workplace type, employment type, rule-based
  seniority, salary (structured where the board publishes it, parsed from
  free text otherwise) with min/max/currency/period, posted/updated dates,
  job URL, apply URL, and a plain-text and HTML job description.
- Filter by keyword, location, remote-only, and recency before it counts
  against your result total.
- Optionally track which jobs are new or have closed since your last run,
  and roll up a one-row-per-company hiring summary.
- Report companies/URLs it couldn't resolve as clear error rows instead of
  failing the whole run.
- Write a `RUN_REPORT` self-check to the key-value store on every run.

No recruiter names, emails, or other personal data are collected - only the
job posting fields a company already publishes on its own public career page.

### Use cases

- **Talent sourcing** - pull open roles across many companies into one sheet.
- **Market research** - track hiring trends, team growth, or new offices by
  company or industry.
- **Competitor intelligence** - see what roles a competitor is actively
  hiring for, and get notified when roles open or close.
- **Job aggregation** - feed a normalized job dataset into your own board,
  alert system, or newsletter.

### How to use

1. Add one or more career-board URLs to **Career board URLs**. Supported
   formats (the platform is detected automatically):
   - Greenhouse: `https://boards.greenhouse.io/{company}` or
     `https://job-boards.greenhouse.io/{company}`
   - Lever: `https://jobs.lever.co/{company}` (or `https://jobs.eu.lever.co/{company}`)
   - Ashby: `https://jobs.ashbyhq.com/{company}`
   - Workable: `https://apply.workable.com/{company}`
   - SmartRecruiters: `https://jobs.smartrecruiters.com/{company}` (or `https://careers.smartrecruiters.com/{company}`)
2. Alternatively (or in addition):
   - Add entries to **Companies (platform + slug)**, e.g.
     `{ "platform": "greenhouse", "slug": "airbnb" }`.
   - Or, if you don't know which ATS a company uses, add its slug to
     **Company names/slugs (auto-detect platform)**, e.g. `"airbnb"` - the
     actor tries Greenhouse, Lever, Ashby, Workable, then SmartRecruiters in
     order and uses the first one that responds.
3. (Optional) Set **Keyword filter**, **Location filter**, and **Remote only**
   to narrow the results.
4. (Optional) Set **Max jobs per company** (default `5`) - set to `0` for
   unlimited. Note: change tracking and the per-company summary only see the
   jobs actually fetched, so set this to `0` for a complete picture.
5. (Optional) Turn off **Include job description** for a lighter dataset.
6. (Optional) Turn on **Track new/closed jobs between runs** and schedule the
   actor to get `isNew`/`firstSeenAt` on every job plus a `change` row for
   every job that disappeared since the last run.
7. Run the actor.

#### Example input

```json
{
  "startUrls": [
    { "url": "https://boards.greenhouse.io/airbnb" },
    { "url": "https://jobs.lever.co/spotify" },
    { "url": "https://jobs.ashbyhq.com/ramp" },
    { "url": "https://apply.workable.com/huzzle" },
    { "url": "https://jobs.smartrecruiters.com/McDonaldsCorporation" }
  ],
  "companyNames": ["stripe"],
  "keyword": "",
  "location": "",
  "remoteOnly": false,
  "maxJobsPerCompany": 5,
  "includeDescription": true,
  "postedWithinDays": 0,
  "trackChanges": false,
  "includeCompanySummary": true
}
```

The slugs above (`airbnb` on Greenhouse, `spotify` on Lever, `ramp` on
Ashby, `huzzle` on Workable, `McDonaldsCorporation` on SmartRecruiters) were
verified to return live job postings at the time this actor was built.

### Output example

#### Job row

```json
{
  "type": "job",
  "platform": "greenhouse",
  "company": { "slug": "airbnb", "name": "Airbnb" },
  "jobId": "7789554",
  "title": "Associate Legal Counsel, Japan",
  "department": "Legal",
  "departmentNormalized": "Legal",
  "team": null,
  "location": "Tokyo, Japan",
  "country": "Japan",
  "city": "Tokyo",
  "isRemote": false,
  "seniority": "mid",
  "employmentType": null,
  "workplaceType": "Hybrid",
  "salaryMin": null,
  "salaryMax": null,
  "salaryCurrency": null,
  "salaryPeriod": null,
  "salaryText": null,
  "postedAt": "2026-04-13T08:49:05.000Z",
  "updatedAt": "2026-09-30T00:00:54.000Z",
  "jobUrl": "https://careers.airbnb.com/positions/7789554?gh_jid=7789554",
  "applyUrl": "https://careers.airbnb.com/positions/7789554?gh_jid=7789554",
  "descriptionText": "Airbnb was born in 2007 when two hosts welcomed three guests...",
  "descriptionHtml": "<div class=\"content-intro\"><p>Airbnb was born in 2007...</p></div>",
  "scrapedAt": "2026-10-26T13:46:18.281Z"
}
```

When **Track new/closed jobs between runs** is on, job rows also carry
`isNew` (boolean) and `firstSeenAt` (ISO timestamp of the run that first saw
this job ID).

#### Company hiring summary row

Included by default (disable with **Include per-company hiring summary**),
charged as a single result row:

```json
{
  "type": "companySummary",
  "platform": "greenhouse",
  "company": { "slug": "airbnb", "name": "Airbnb" },
  "generatedAt": "2026-10-26T13:46:18.281Z",
  "openRoles": 184,
  "byDepartment": { "Engineering": 61, "Sales": 24, "Legal": 3 },
  "byLocation": { "United States": 102, "Japan": 6, "Remote": 18 },
  "remoteShare": 0.38,
  "newThisRun": 5,
  "salaryTransparencyShare": 0.21,
  "medianSalary": 142500
}
```

`newThisRun` and `medianSalary` are `null` when change tracking is off or no
job in the run published a salary, respectively.

#### Change row (closed job)

Only produced when **Track new/closed jobs between runs** is on, for a job
ID that was seen in an earlier run but is no longer on the board:

```json
{
  "type": "change",
  "status": "closed",
  "platform": "greenhouse",
  "company": { "slug": "airbnb", "name": "Airbnb" },
  "jobId": "7789554",
  "firstSeenAt": "2026-10-20T09:00:00.000Z",
  "closedDetectedAt": "2026-10-26T13:46:18.281Z"
}
```

#### Error row

If a URL or company can't be resolved, you get an error row instead of a
silent gap in your data - and it is never charged:

```json
{
  "type": "error",
  "input": "https://boards.greenhouse.io/this-company-does-not-exist",
  "error": "COMPANY_NOT_FOUND",
  "message": "Greenhouse board \"this-company-does-not-exist\" was not found",
  "scrapedAt": "2026-10-26T13:46:19.677Z"
}
```

### Monitoring: track new & closed jobs between runs

Turn on **Track new/closed jobs between runs** (`trackChanges`) and
(optionally) run the actor on a schedule with the same **Monitor name**
(`monitorName`). The actor persists which job IDs it has seen per company in
a named key-value store:

- Every job row gets `isNew` (true the first time that job ID is seen) and
  `firstSeenAt`.
- Any job ID seen in an earlier run but missing from the current one gets a
  `type: "change", status: "closed"` row.
- Set **Output mode** (`outputMode`) to `newOnly` to push only the jobs that
  are new since the last run (closed-job rows and the company summary are
  still produced either way) - useful for a lean "what changed" feed.
- Use a different **Monitor name** to run multiple independent monitors
  (e.g. one per project) without their state colliding; leave it empty to
  use the shared default store.

The first run for a company only seeds the state (every job is `isNew` and
no `closed` rows are produced, since there's nothing to compare against
yet).

### Normalization extras

Every job row is enriched the same way regardless of platform:

- **`seniority`** - rule-based from the title: `intern`, `junior`, `mid`,
  `senior`, `lead`, `manager`, `director`, or `exec`. Deterministic: the same
  title always classifies the same way.
- **`departmentNormalized`** - the platform's raw `department` mapped onto a
  small canonical set (Engineering, Product, Design, Data, Sales, Marketing,
  Customer Support, Operations, Finance, HR / People, Legal, Other).
- **`country`/`city`** - best-effort split of the platform's free-text
  location into a normalized country name and city, when the text parses
  cleanly (e.g. "Tokyo, Japan"). Left `null` rather than guessed when it
  doesn't (e.g. "Remote", "Multiple locations").
- **`isRemote`** - consolidates each platform's own remote flag with a
  keyword check across title/location/workplace type, so remote roles are
  flagged consistently even on platforms with no native remote field.
- **`salaryMin`/`salaryMax`/`salaryCurrency`/`salaryPeriod`** - structured
  compensation where the platform publishes it (consistently available on
  Ashby; occasionally on Lever), otherwise parsed from the platform's free
  salary text when present. Never estimated: all four stay `null` when no
  platform data exists to parse.

### Self-check: RUN_REPORT

Every run writes a `RUN_REPORT` record to the run's key-value store (see the
Output tab / `{{links.apiDefaultKeyValueStoreUrl}}/records/RUN_REPORT`):
how many inputs were processed, row counts by type, errors grouped by error
code, warnings (e.g. a company whose board returned zero jobs), and a
validation check confirming that required fields (`title`, `jobUrl`,
`platform`, `company.slug`) are actually non-empty across the job rows
produced. The run's status message mirrors a short version of the same
summary.

### Pricing

This actor uses **pay-per-result** pricing: **$1.00 per 1,000 results**,
with no start fee. A result is one job row, one company hiring-summary row,
or one closed-job change row. Error rows for unresolved URLs/companies are
never charged. You can cap spending with the run's maximum cost setting, and
the actor stops fetching as soon as that limit is reached.

### FAQ

**Is this legal?** The actor only reads the public job-board JSON APIs that
each platform exposes for its own "apply now" career pages (the same feeds
that power the company's public job listings). It does not log in, does not
scrape HTML, and does not access any private or authenticated data.

**What happens if a URL isn't a recognized career board?** You get an error
row with `error: "UNSUPPORTED_URL"` for that entry instead of a failed run -
every other URL in the same run is unaffected.

**What other errors can show up?** `COMPANY_NOT_FOUND` when the platform (or
every platform tried during auto-detection) confirms the slug doesn't exist,
and `FETCH_FAILED` for a network-level failure (timeout, DNS error, or an
unexpected response shape).

**How does auto-detection pick a platform?** For each entry in **Company
names/slugs**, the actor tries Greenhouse, then Lever, then Ashby, then
Workable, then SmartRecruiters, and uses the first platform whose API
responds without a "not found" error. If a company has boards on more than
one platform, the one earliest in that order wins.

**What are the limits?** Only jobs a company has published to its public
board are returned. Salary fields are only populated when a platform
publishes structured compensation data or free salary text (consistently
available on Ashby; occasionally on Lever; rarely exposed on Greenhouse,
Workable, or SmartRecruiters). SmartRecruiters' public API returns an empty
result for both "unknown company" and "company with zero open jobs right
now" - the actor can't distinguish the two for that platform, so no error
row is produced in that case. Closed-job detection and the per-company
summary only see jobs actually fetched during the run, so set **Max jobs
per company** to `0` for a complete picture.

# Actor input Schema

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

Career board URLs to scrape, e.g. https://boards.greenhouse.io/airbnb, https://jobs.lever.co/spotify, https://jobs.ashbyhq.com/ramp, https://apply.workable.com/huzzle, https://jobs.smartrecruiters.com/McDonaldsCorporation. The platform is auto-detected from the URL.

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

Optional alternative to Career board URLs: a list of { "platform": "greenhouse"|"lever"|"ashby"|"workable"|"smartrecruiters", "slug": "company-slug" } objects.

## `companyNames` (type: `array`):

Optional alternative when you don't know which ATS a company uses: a list of plain company slugs, e.g. "airbnb". For each one, the actor tries Greenhouse, Lever, Ashby, Workable, then SmartRecruiters in order and uses the first platform that responds.

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

Only keep jobs whose title contains one of these words/phrases (case-insensitive). Separate multiple keywords with a comma for OR matching, e.g. "engineer, designer". Leave empty for no filter.

## `location` (type: `string`):

Only keep jobs whose location text contains this string (case-insensitive). Leave empty for no filter.

## `remoteOnly` (type: `boolean`):

Only keep jobs that are flagged as remote by the job board.

## `maxJobsPerCompany` (type: `integer`):

Maximum number of jobs to fetch per company. Set to 0 for unlimited (fetch all open jobs).

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

When enabled (default), each job includes descriptionText (plain text) and descriptionHtml. Disable to omit description fields and produce a lighter dataset.

## `postedWithinDays` (type: `integer`):

Only keep jobs posted/updated within this many days. Set to 0 to disable (keep all jobs regardless of posting date). Jobs without a known posting date are always kept.

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

Optional proxy configuration. Not required for these public JSON APIs, but can help if a board starts rate-limiting your IP.

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

Maximum number of companies to scrape in parallel.

## `requestDelayMs` (type: `integer`):

Optional delay in milliseconds between consecutive paginated requests to the same company, to be polite to the public API.

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

When enabled, the actor remembers which job IDs it has seen per company (in a named key-value store) and on every run after the first tags each job with isNew/firstSeenAt, and emits a "change" row for every previously-seen job that disappeared (closed). Combine with a schedule for a daily hiring-change feed.

## `monitorName` (type: `string`):

Name of the key-value store used to persist seen-job state when "Track new/closed jobs" is on. Use the same name across scheduled runs of the same project; use different names to keep multiple monitors independent. Leave empty to use the default "ats-jobs-monitor" store.

## `outputMode` (type: `string`):

"all": push every matching job every run (default). "newOnly": only push jobs that are new since the last run (isNew: true) - closed-job "change" rows and the company summary are still pushed either way. Ignored unless "Track new/closed jobs" is on.

## `includeCompanySummary` (type: `boolean`):

When enabled (default), each company also gets one extra "companySummary" row: open-roles count, breakdown by department and location, remote share, salary-transparency share, median salary (where available), and new-this-run count (when tracking changes). Charged as a single result row, same as a job row.

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://boards.greenhouse.io/airbnb"
    },
    {
      "url": "https://jobs.lever.co/spotify"
    },
    {
      "url": "https://jobs.ashbyhq.com/ramp"
    }
  ],
  "companies": [],
  "companyNames": [],
  "keyword": "",
  "location": "",
  "remoteOnly": false,
  "maxJobsPerCompany": 5,
  "includeDescription": true,
  "postedWithinDays": 0,
  "proxyConfiguration": {
    "useApifyProxy": false
  },
  "maxConcurrency": 5,
  "requestDelayMs": 0,
  "trackChanges": false,
  "monitorName": "",
  "outputMode": "all",
  "includeCompanySummary": true
}
```

# Actor output Schema

## `overview` (type: `string`):

All scraped job rows, plus error rows for companies/URLs that could not be scraped.

## `runReport` (type: `string`):

Per-run summary written to the key-value store: inputs processed, row counts by type, errors by code, warnings, and output validation.

# 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": [
        {
            "url": "https://boards.greenhouse.io/airbnb"
        },
        {
            "url": "https://jobs.lever.co/spotify"
        },
        {
            "url": "https://jobs.ashbyhq.com/ramp"
        }
    ],
    "companies": [],
    "companyNames": [],
    "proxyConfiguration": {
        "useApifyProxy": false
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("zerahdata/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": [
        { "url": "https://boards.greenhouse.io/airbnb" },
        { "url": "https://jobs.lever.co/spotify" },
        { "url": "https://jobs.ashbyhq.com/ramp" },
    ],
    "companies": [],
    "companyNames": [],
    "proxyConfiguration": { "useApifyProxy": False },
}

# Run the Actor and wait for it to finish
run = client.actor("zerahdata/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": [
    {
      "url": "https://boards.greenhouse.io/airbnb"
    },
    {
      "url": "https://jobs.lever.co/spotify"
    },
    {
      "url": "https://jobs.ashbyhq.com/ramp"
    }
  ],
  "companies": [],
  "companyNames": [],
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}' |
apify call zerahdata/ats-jobs-scraper --silent --output-dataset

```

## MCP server setup

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