# ATS Jobs Scraper: Greenhouse Jobs, Lever Jobs & Workday Jobs (`cleanrows/ats-jobs-scraper`) Actor

Jobs from nine applicant tracking systems in one schema - Workday, Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee, Rippling and Breezy. Run it daily with onlyNewJobs and it returns, and bills for, only what is new. Descriptions as markdown, text or HTML.

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

## Pricing

from $1.20 / 1,000 job scrapeds

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

## ATS Jobs Scraper — 9 applicant tracking systems, one schema

Scrape job postings from **Workday, Greenhouse, Lever, Ashby, Workable,
SmartRecruiters, Recruitee, Rippling and Breezy** and get them back in **one
normalised schema**, whichever system a company uses.

Most job scrapers cover one or two systems and hand back that system's raw field
names. If you track 200 companies they will be spread across half a dozen ATSs, and
you end up gluing different shapes together yourself, or silently missing the
companies your scraper does not cover. This covers all nine and returns one shape.

### What you get

- **One schema across all nine systems.** `title`, `department`, `location`,
  `isRemote`, `salaryMin`/`salaryMax`, `postedAt`, `url` — same fields, same types,
  every source. Missing values are `null`, never absent, so CSV columns stay aligned.
- **ATS auto-detection.** Pass a bare slug like `databricks` and it works out which
  of the eight systems the company is on. Force it with `greenhouse:stripe` when you
  already know, which is faster and avoids an ambiguous match.
- **Salary parsed into numbers.** Ashby publishes salary as display text
  (`$211.4K - $290.6K`, `SEK 55,000 - SEK 75,000`). You get `salaryMin: 211400`,
  `salaryMax: 290600`, `salaryCurrency: "USD"` — and the original string too.
- **A remote flag that is actually correct.** Ashby marks Hybrid roles as
  `isRemote: true` — on one board that is 107 hybrid roles wrongly flagged remote out
  of 135. This uses `workplaceType`, so `remoteOnly` returns remote jobs.
- **Clean description text.** Greenhouse double-encodes its HTML; naive scrapers
  return `&lt;h2&gt;Who we are&lt;/h2&gt;` as visible text. This decodes, then strips.
- **Filters that run before you pay:** title keywords, location, department,
  remote-only, and posted-within-N-days.

### Only what changed

Set `onlyNewJobs` and a run returns — and bills for — only the jobs it has not
seen before. A daily watch on a fixed company list costs what is new, not the
whole board again.

Three details that decide whether this is useful or merely present:

- **The filter runs before billing.** Skipping after the charge would bill for
  rows that are then thrown away.
- **Only delivered jobs are recorded as seen.** If a run stops at your
  `maxTotalChargeUsd` cap partway through, the jobs it did not reach stay unseen
  and arrive next run. They are never silently marked as already sent.
- **State lives in a named key-value store**, which Apify keeps indefinitely on
  every plan. Give separate watches separate `stateStoreName` values, or one
  company list will suppress jobs for another.

The first run returns everything and seeds the state. Entries not seen for 60
days are dropped, so a role that comes down and is re-posted counts as new again.

### Input

```json
{
  "companies": ["greenhouse:stripe", "lever:spotify", "workable:peoplecert",
                "https://adobe.wd5.myworkdayjobs.com/external_experienced", "databricks"],
  "keywords": ["engineer", "data"],
  "locations": ["bengaluru", "remote"],
  "remoteOnly": false,
  "postedWithinDays": 30,
  "includeDescription": true,
  "descriptionFormat": "text",
  "maxItemsPerCompany": 25,
  "maxItems": 1000
}
```

The slug is the last path segment of a company's careers URL:

| System | Careers URL | Slug |
|---|---|---|
| Greenhouse | `boards.greenhouse.io/stripe` | `stripe` |
| Lever | `jobs.lever.co/spotify` | `spotify` |
| Ashby | `jobs.ashbyhq.com/ramp` | `ramp` |
| Workable | `apply.workable.com/peoplecert` | `peoplecert` |
| SmartRecruiters | `jobs.smartrecruiters.com/BoschGroup` | `BoschGroup` |
| Recruitee | `effectory.recruitee.com` | `effectory` |
| Rippling | `ats.rippling.com/rippling` | `rippling` |
| Breezy | `breezy.breezy.hr` | `breezy` |
| Workday | paste the full careers URL | `https://adobe.wd5.myworkdayjobs.com/external_experienced` |

SmartRecruiters slugs are case-sensitive. Workday has no short slug because every
tenant lives on its own host, so paste the careers URL and it is parsed for you.

### Output

One row per posting:

```json
{
  "company": "Ramp",
  "companySlug": "ramp",
  "ats": "ashby",
  "jobId": "34413f8d-26bf-4bbc-8ade-eb309a0e2245",
  "title": "Security Engineer, Cloud",
  "department": "Engineering",
  "team": "Backend",
  "employmentType": "FullTime",
  "location": "New York, NY (HQ)",
  "locations": ["New York, NY (HQ)"],
  "isRemote": false,
  "workplaceType": "Hybrid",
  "country": "United States",
  "salaryText": "$211.4K - $290.6K",
  "salaryMin": 211400,
  "salaryMax": 290600,
  "salaryCurrency": "USD",
  "salaryInterval": null,
  "postedAt": "2026-04-07T17:12:35.753Z",
  "updatedAt": null,
  "url": "https://jobs.ashbyhq.com/ramp/34413f8d-...",
  "applyUrl": "https://jobs.ashbyhq.com/ramp/34413f8d-.../application",
  "descriptionText": "About Ramp\nRamp is building the smart infrastructure...",
  "descriptionHtml": null,
  "scrapedAt": "2026-08-21T00:00:00.000Z"
}
```

A `RUN_SUMMARY` record is written to the key-value store with per-company counts and
any slugs that could not be resolved, so a partial run is diagnosable.

### Who this is for

- **Recruiters and sourcers** tracking hiring at a list of target companies.
- **Job boards and aggregators** that need a steady feed with a stable schema.
- **Sales teams** using open roles as a buying signal ("hiring 5 data engineers").
- **Market researchers** tracking headcount, salary bands and remote policy over time.

### Notes

- Reads the **public job board APIs** that these systems publish for exactly this
  purpose. No login, no cookies, no CAPTCHA solving, no proxies required.
- Companies not on one of these eight systems will not resolve; they are reported in
  `RUN_SUMMARY.notFound` rather than failing the run.
- Rippling and Workday publish only a title and location on their board listings, so
  each posting is fetched individually to fill in dates, employment type and
  description. `maxItemsPerCompany` is applied **before** those requests, so capping
  at 25 costs 25 requests rather than two thousand.
- Workday does not expose a department on its public API, so `department` is null for
  Workday rows. Every other system populates it.
- Boards are fetched at a concurrency of 5 with retry-and-backoff on 429/5xx.

### Run it locally

```bash
npm install
mkdir -p storage/key_value_stores/default
## put your input in storage/key_value_stores/default/INPUT.json
npm start
```

# Actor input Schema

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

Company board slugs. Use a bare slug (`stripe`) to auto-detect the ATS, or force one with a prefix: `greenhouse:`, `lever:`, `ashby:`, `workable:`, `smartrecruiters:`, `recruitee:`, `rippling:`, `breezy:`. For Workday, paste the full careers URL (e.g. `https://adobe.wd5.myworkdayjobs.com/external_experienced`) — Workday has no short slug.

## `keywords` (type: `array`):

Keep only jobs whose title contains at least one of these (case-insensitive). Leave empty for all jobs.

## `locations` (type: `array`):

Keep only jobs whose location contains at least one of these (case-insensitive), e.g. `bengaluru`, `remote`, `london`.

## `departments` (type: `array`):

Keep only jobs in these departments (case-insensitive substring match).

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

Keep only postings flagged remote by the ATS, or whose location mentions `remote`.

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

Keep only jobs first published in the last N days. 0 disables the filter.

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

Fetch and return the full job description. Turning this off makes Greenhouse runs noticeably faster.

## `descriptionFormat` (type: `string`):

Return the description as clean plain text, original HTML, or both.

## `onlyNewJobs` (type: `boolean`):

Return and charge for only the jobs not seen in previous runs. Filtering happens before billing, so a daily run against a fixed company list costs what is new, not the whole board again. The first run returns everything and seeds the state.

## `stateStoreName` (type: `string`):

Named key-value store holding what has already been seen. Give separate watches separate names, or one company list will suppress jobs for another. Apify retains named stores indefinitely, so the state survives between runs.

## `maxItemsPerCompany` (type: `integer`):

Stop after this many jobs from each company. Applied before per-job requests, so it also makes Workday and Rippling boards much faster. Set to 0 for every job on every board.

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

Stop after this many jobs across all companies. 0 means no limit.

## Actor input object example

```json
{
  "companies": [
    "stripe",
    "workable:peoplecert"
  ],
  "keywords": [],
  "locations": [],
  "departments": [],
  "remoteOnly": false,
  "postedWithinDays": 0,
  "includeDescription": true,
  "descriptionFormat": "text",
  "onlyNewJobs": false,
  "stateStoreName": "ats-jobs-seen",
  "maxItemsPerCompany": 25,
  "maxItems": 0
}
```

# Actor output Schema

## `jobs` (type: `string`):

One row per posting from Greenhouse, Lever and Ashby, in a single schema.

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

Per-company counts, which ATS each slug resolved to, and any slugs that could not be found.

# 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": [
        "greenhouse:stripe",
        "lever:spotify",
        "ashby:ramp",
        "workable:peoplecert"
    ],
    "maxItemsPerCompany": 25
};

// Run the Actor and wait for it to finish
const run = await client.actor("cleanrows/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 = {
    "companies": [
        "greenhouse:stripe",
        "lever:spotify",
        "ashby:ramp",
        "workable:peoplecert",
    ],
    "maxItemsPerCompany": 25,
}

# Run the Actor and wait for it to finish
run = client.actor("cleanrows/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 '{
  "companies": [
    "greenhouse:stripe",
    "lever:spotify",
    "ashby:ramp",
    "workable:peoplecert"
  ],
  "maxItemsPerCompany": 25
}' |
apify call cleanrows/ats-jobs-scraper --silent --output-dataset

```

## MCP server setup

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