# Multi-ATS Jobs Scraper (Greenhouse, Lever, Ashby) (`alain_diaz/multi-ats-jobs-scraper`) Actor

Scrape public job postings from Greenhouse, Lever and Ashby career pages into one clean dataset. Filter by keyword, remote or location, get salary ranges, and use incremental mode to receive only new jobs on each run.

- **URL**: https://apify.com/alain_diaz/multi-ats-jobs-scraper.md
- **Developed by:** [José Alaín Díaz Carrero](https://apify.com/alain_diaz) (community)
- **Categories:** Jobs, Lead generation, Automation
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 job 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

## Multi-ATS Jobs Scraper (Greenhouse, Lever, Ashby)

Collect **public job postings** from thousands of company career pages in one normalized format. Give it company names (or career-page URLs) hosted on **Greenhouse**, **Lever** or **Ashby** and get clean JSON/CSV/Excel with title, location, remote flag, department, employment type, **salary range** (when the company publishes it), dates and apply links.

It only uses the **official public, no-authentication job-board APIs** that these platforms provide for career pages. No login, no browser, no proxies, no HTML scraping, so it is fast, cheap and stable.

### What you can do with it

- Build a job board or newsletter from a list of target companies.
- Track hiring at competitors or prospects (sales / recruiting intelligence).
- Get **only new jobs** since your last run (schedule it hourly/daily and send results to Slack, email, a webhook or Google Sheets with Apify integrations).
- Compare **published salary ranges** across companies.
- Filter by keyword, remote, and location.

### Input

| Field | Type | Description |
|---|---|---|
| `companies` | array | Objects `{ "ats": "greenhouse" \| "lever" \| "ashby", "token": "<company slug>" }` |
| `boardUrls` | array of strings | Career-page URLs; ATS and token are auto-detected (`boards.greenhouse.io/x`, `job-boards.greenhouse.io/x`, `jobs.lever.co/x`, `jobs.ashbyhq.com/x`) |
| `keywords` | array | Keep jobs whose title/department/team contains **any** keyword |
| `excludeKeywords` | array | Drop jobs containing **any** of these |
| `searchInDescription` | boolean | Also match keywords inside the description (default `false`) |
| `remoteOnly` | boolean | Only remote jobs |
| `locationContains` | array | Keep jobs whose location contains **any** string (e.g. `["Chile", "United Kingdom", "EMEA"]`) |
| `onlyNewSinceLastRun` | boolean | Return only jobs not seen before (see below) |
| `stateStoreName` | string | Named key-value store holding seen job IDs (default `multi-ats-jobs-state`) |
| `includeDescription` | boolean | Add plain-text description (default `false`) |
| `maxItemsPerCompany` | integer | Cap per company (`0` = unlimited) |
| `maxConcurrency` | integer | Boards fetched in parallel (default 3, max 10) |

You need at least one company or board URL. The company **token** is the slug in the career-page URL, e.g. `https://boards.greenhouse.io/airbnb` → `airbnb`.

#### Example inputs

**1. Everything from a few companies (minimal)**

```json
{
  "companies": [
    { "ats": "greenhouse", "token": "airbnb" },
    { "ats": "lever", "token": "spotify" },
    { "ats": "ashby", "token": "ramp" }
  ]
}
```

**2. Remote engineering jobs, filtered, from career-page URLs**

```json
{
  "boardUrls": [
    "https://job-boards.greenhouse.io/gitlab",
    "https://jobs.lever.co/spotify"
  ],
  "keywords": ["engineer", "data"],
  "excludeKeywords": ["intern"],
  "remoteOnly": true,
  "locationContains": ["United States", "Canada"]
}
```

**3. Daily job alert: only new jobs, capped per company**

```json
{
  "companies": [
    { "ats": "greenhouse", "token": "gitlab" },
    { "ats": "ashby", "token": "ramp" }
  ],
  "keywords": ["product manager"],
  "onlyNewSinceLastRun": true,
  "stateStoreName": "my-daily-pm-alert",
  "maxItemsPerCompany": 50
}
```

### Output

One dataset item per job (see `sample_output.json` in the repository for full examples):

```json
{
  "id": "8731718002",
  "ats": "greenhouse",
  "company": "GitLab",
  "companyToken": "gitlab",
  "title": "Principal Security Researcher",
  "location": "Remote, Canada; Remote, United States",
  "isRemote": true,
  "department": "Product Security",
  "team": null,
  "employmentType": null,
  "salaryMin": 203200,
  "salaryMax": 275000,
  "salaryCurrency": "USD",
  "salaryInterval": "year",
  "postedAt": "2026-09-05T17:47:29.000Z",
  "updatedAt": "2026-10-15T19:24:46.000Z",
  "applyUrl": "https://job-boards.greenhouse.io/gitlab/jobs/8731718002",
  "jobUrl": "https://job-boards.greenhouse.io/gitlab/jobs/8731718002",
  "scrapedAt": "2026-10-25T18:22:11.427Z"
}
```

#### Field availability per ATS

| Field | Greenhouse | Lever | Ashby |
|---|---|---|---|
| company name | real name | slug | slug |
| department / team | department | department / team | department / team |
| employmentType | only if the company uses a custom field | yes | yes |
| salary | when the company publishes pay ranges | when published | when published |
| postedAt | first published | created | published |
| updatedAt | yes | no | no |

`null` means the platform did not provide the value. `isRemote` is taken from the ATS flag when available, otherwise detected from the location/title text.

### Incremental mode ("only new jobs")

With `onlyNewSinceLastRun: true` the Actor remembers the IDs of jobs it already returned, per ATS + company, in a named key-value store (`stateStoreName`, default `multi-ats-jobs-state`). The first run returns everything that matches your filters; later runs return only postings that were not returned before.

How it behaves, step by step:

1. The board is fetched and your filters (keywords, remote, location) are applied.
2. Jobs whose ID is already stored are skipped.
3. If `maxItemsPerCompany` is set, the remaining new jobs are cut to that number.
4. Only the jobs **actually returned** are added to the stored "seen" list.

What this means in practice:

- **`maxItemsPerCompany` and incremental mode:** jobs beyond the cap are **not** marked as seen. They are returned in a later run, so nothing is lost; with a cap of 50 and 120 new jobs you get 50 now, 50 on the next run and 20 on the one after.
- **Filtered-out jobs are not marked as seen** either, so they appear if they start matching (for example after you change your keywords).
- IDs of jobs that disappear from a board are dropped from the stored list, so the state stays small. A job that is removed and later re-posted with the same ID is treated as new.
- If a run stops early (for example because your maximum run cost was reached), the jobs not yet returned are not marked as seen and will come in the next run.
- Use a different `stateStoreName` for each independent monitoring setup (one per Schedule or per client). To start over, change the name or delete the store in Apify Console.
- Combine with an Apify **Schedule** and an integration (Slack, email, webhook, Google Sheets) to build a job alert pipeline.

### Pricing (pay per event)

The Actor is designed for pay-per-event pricing: a small start event plus a charge per job returned (`job-result`). It stops early if your maximum run cost is reached. Check the pricing section on this page for current prices.

### FAQ

**Is this legal / allowed?** It calls the public job-board endpoints that Greenhouse, Lever and Ashby expose precisely so companies' postings can be embedded and consumed elsewhere. No authentication is bypassed and no private data is accessed. You are responsible for how you use the data and for complying with applicable laws and the terms of the career pages.

**Does `maxItemsPerCompany` lose jobs in incremental mode?** No. Jobs beyond the cap are not marked as seen, so they are returned in later runs. See "Incremental mode" above.

**How do I reset incremental mode?** Change `stateStoreName` or delete the key-value store with that name in Apify Console (Storage, Key-value stores).

**What happens if a company has no jobs or its token is wrong?** A company with no matching jobs simply returns nothing. A wrong token or a company that left the ATS is logged as a warning (usually HTTP 404) and the rest of the run continues.

**Can I export to CSV, Excel or Google Sheets?** Yes. The dataset can be downloaded as JSON, CSV, Excel, XML or HTML, or sent to Google Sheets, Slack, email or a webhook with Apify integrations.

**Do I need proxies or cookies?** No. The Actor only calls public endpoints, so no proxy, login or cookies are needed.

**How do I find a company's token?** Open the company's careers page; if it redirects to `boards.greenhouse.io/<token>`, `jobs.lever.co/<token>` or `jobs.ashbyhq.com/<token>`, that is the token. You can also just paste the URL into `boardUrls`.

**A company failed with HTTP 404.** The token is wrong or the company no longer uses that ATS. Failures are logged per company and never stop the run; other companies are still processed.

**Why is `company` a slug for Lever and Ashby?** Those APIs do not return the company display name.

**Does it support EU Lever (`api.eu.lever.co`) or custom domains?** Not yet; only the standard global endpoints.

**How fast is it?** A typical board with a few hundred jobs takes about one second. Memory use is low (256–512 MB is enough).

**Can I get descriptions?** Yes, set `includeDescription: true` (plain text).

### Legal note

This Actor only reads the **public job-board APIs** that Greenhouse, Lever and Ashby offer for companies' public career pages. It does not log in, does not bypass any access control, does not collect private data and does not scrape candidate or recruiter profiles. Job postings are published by the companies themselves to be seen and applied to. Greenhouse, Lever and Ashby are trademarks of their respective owners; this Actor is independent and not affiliated with or endorsed by them. You are responsible for how you use the data and for complying with applicable laws (including data protection rules) and the terms of the career pages you query.

### Notes

- Only public job-board APIs are used. No proxies needed.
- Requests are retried with exponential backoff on 429/5xx and run with low concurrency.
- Greenhouse boards with thousands of jobs return large responses; use a few companies per run for best speed.

# Actor input Schema

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

List of objects like {"ats": "greenhouse", "token": "airbnb"}. `ats` is one of greenhouse, lever, ashby. `token` is the company slug in the job board URL (e.g. boards.greenhouse.io/<token>).

## `boardUrls` (type: `array`):

Public job board URLs. ATS and company token are detected automatically. Supported: boards.greenhouse.io/<x>, job-boards.greenhouse.io/<x>, jobs.lever.co/<x>, jobs.ashbyhq.com/<x>.

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

Keep only jobs whose title, department or team contains at least one of these keywords (case-insensitive). Enable "Search keywords in description" to also match the description. Leave empty to keep all.

## `excludeKeywords` (type: `array`):

Drop jobs whose title, department or team (and description, if enabled) contains any of these keywords (case-insensitive).

## `searchInDescription` (type: `boolean`):

By default keywords are matched against title, department and team. Enable to also match inside the full job description (more results, more noise).

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

Keep only jobs flagged or described as remote.

## `locationContains` (type: `array`):

Keep only jobs whose location text contains at least one of these strings (e.g. "Chile", "United States", "London", "EMEA"). Case-insensitive. Country names and country codes (e.g. "US") are matched against the location text and, where the ATS provides it, the country field.

## `onlyNewSinceLastRun` (type: `boolean`):

Return only jobs not seen in earlier runs. Seen job IDs are stored in a named key-value store (see `stateStoreName`) per ATS and company. The first run returns everything.

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

Name of the key-value store that remembers seen job IDs for incremental mode. Use different names for different monitoring setups (e.g. one per schedule).

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

Add the job description as plain text to each item. Makes the dataset larger.

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

Maximum number of jobs returned per company (0 = unlimited).

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

How many company boards are fetched in parallel. Keep low to be polite to the job-board APIs.

## Actor input object example

```json
{
  "companies": [
    {
      "ats": "greenhouse",
      "token": "airbnb"
    },
    {
      "ats": "lever",
      "token": "spotify"
    },
    {
      "ats": "ashby",
      "token": "ramp"
    }
  ],
  "boardUrls": [
    "https://job-boards.greenhouse.io/gitlab"
  ],
  "keywords": [
    "engineer"
  ],
  "searchInDescription": false,
  "remoteOnly": false,
  "onlyNewSinceLastRun": false,
  "stateStoreName": "multi-ats-jobs-state",
  "includeDescription": false,
  "maxItemsPerCompany": 0,
  "maxConcurrency": 3
}
```

# Actor output Schema

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

Dataset items with the normalized job postings.

# 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": [
        {
            "ats": "greenhouse",
            "token": "airbnb"
        },
        {
            "ats": "lever",
            "token": "spotify"
        },
        {
            "ats": "ashby",
            "token": "ramp"
        }
    ],
    "boardUrls": [
        "https://job-boards.greenhouse.io/gitlab"
    ],
    "keywords": [
        "engineer"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("alain_diaz/multi-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": [
        {
            "ats": "greenhouse",
            "token": "airbnb",
        },
        {
            "ats": "lever",
            "token": "spotify",
        },
        {
            "ats": "ashby",
            "token": "ramp",
        },
    ],
    "boardUrls": ["https://job-boards.greenhouse.io/gitlab"],
    "keywords": ["engineer"],
}

# Run the Actor and wait for it to finish
run = client.actor("alain_diaz/multi-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": [
    {
      "ats": "greenhouse",
      "token": "airbnb"
    },
    {
      "ats": "lever",
      "token": "spotify"
    },
    {
      "ats": "ashby",
      "token": "ramp"
    }
  ],
  "boardUrls": [
    "https://job-boards.greenhouse.io/gitlab"
  ],
  "keywords": [
    "engineer"
  ]
}' |
apify call alain_diaz/multi-ats-jobs-scraper --silent --output-dataset

```

## MCP server setup

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