# ATS Jobs Scraper - Greenhouse, Lever, Ashby, Workable, Workday (`neverempty/ats-jobs-api`) Actor

Pull open jobs from a company's Greenhouse, Lever, Ashby, Workable or Workday board into one identical row shape. Zero open roles, a missing board and a failed fetch come back as three different rows, never as a silently empty result. Unofficial, not affiliated with these services.

- **URL**: https://apify.com/neverempty/ats-jobs-api.md
- **Developed by:** [NeverEmpty](https://apify.com/neverempty) (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

$5.00 / 1,000 jobs

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?

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 - Greenhouse, Lever, Ashby, Workable, Workday

Pull open jobs from company job boards on **Greenhouse**, **Lever**, **Ashby**, **Workable** and **Workday** and get back **one identical row shape**, read straight from each ATS's own public API.

*Unofficial. This Actor is not affiliated with, endorsed by, or sponsored by Greenhouse, Lever, Ashby, Workable or Workday. All product names are trademarks of their respective owners.*

No HTML scraping. No proxies. No API keys. Nothing that breaks when a careers page is redesigned.

**Don't know a company's board name?** **[ATS Job Board Finder](https://apify.com/neverempty/ats-board-finder)** turns a domain into the board name this Actor takes - it tells you which ATS a company uses and what their board is called.

***

### Why this one is different

Most job-board Actors return an empty dataset when something goes wrong, and an empty dataset when there is genuinely nothing to report. Those are not the same answer, and if you are monitoring hiring activity the difference is the whole point.

**This Actor always returns a row.** Every board you ask for produces at least one row, tagged in the `rowType` column:

| `rowType` | `ok` | What it means | Charged? |
|---|---|---|---|
| `job` | `true` | A real open job | Yes |
| `no-open-roles` | `true` | The board is live and has no open jobs (or none matching your filters) | **No** |
| `board-not-found` | `false` | No board with that name exists on that ATS | **No** |
| `unavailable` | `false` | The ATS could not be reached, or the entry could not be read as a board; the `reason` column says why | **No** |
| `budget-reached` | `false` | The run hit the **Max total charge** you set. Says how many rows of that board were delivered, and names every board that was not fetched because of it | **No** |
| `partial` | `true` | The rows you got are real, but the board has more than one run returns. Says how many of how many, and why it stopped. Workday only | **No** |

You are charged **only for `job` rows**.

#### If your charge limit runs out, you are told which boards were cut

Apify lets you cap a run with **Max total charge**. When that cap is hit, the platform silently stops accepting rows - which would make whole boards disappear from your result with no explanation. This Actor watches for that and writes a free `budget-reached` row for the board that was cut short **and for every board it therefore did not fetch**, so a truncated run can never be mistaken for a complete one.

#### It does not invent a remote flag

`isRemote` is set **only when the ATS itself says so** - Lever's `workplaceType`, Ashby's `isRemote`, Workable's `telecommuting`. When the source is silent, `isRemote` stays `null`.

A guess based on the location text lives in a separate column named `remoteHint`, so you always know which is which. `remoteOnly` filtering uses `isRemote` only - it never passes a guess off as a fact. This matters: a job located in "Remote, Oregon" is not a remote job.

#### It does not invent a salary

`salaryMin` / `salaryMax` / `salaryCurrency` / `salaryInterval` are filled **only from structured compensation data** (Ashby publishes it as numbers). Nothing is parsed out of description prose, and a missing figure stays `null` - never `0`.

#### It adds the columns the raw APIs don't have

- Lever returns `createdAt` as a UNIX millisecond number - here it is ISO 8601, like every other source
- Workable returns a date with no time, Greenhouse returns a timezone offset - all normalised to ISO 8601 UTC
- `full-time` / `part-time` / `contract` / `internship` are unified wherever the ATS publishes it (Lever calls it "Permanent")
- `workplaceType` is unified to `remote` / `hybrid` / `onsite`
- Ashby's unlisted postings are excluded, so you don't get jobs that aren't actually public

***

#### What each ATS actually publishes

The columns are identical everywhere, but the ATSs do not all publish the same facts. Measured on the four default boards, 2026-08-28:

| | Greenhouse (580) | Lever (89) | Ashby (137) | Workable (8) |
|---|---|---|---|---|
| `title`, `jobUrl`, `postedAt` | 100% | 100% | 100% | 100% |
| `department` | 100% | 100% | 100% | 13% |
| `employmentType` | **0%** | 97% | 100% | 100% |
| `workplaceType`, `isRemote` | **0%** | 100% | 100% | 100% |
| `salaryMin` / `salaryMax` | **0%** | **0%** | 96% | **0%** |

Greenhouse's public board API simply does not carry employment type, workplace type or salary, so those stay `null` rather than being guessed. `department` is filled by reading Greenhouse's `/departments` endpoint, which is why it is populated even without descriptions. **`remoteOnly` therefore never matches a Greenhouse board** - use `remoteHint` if you want the guess.

**Workday carries even less.** Its board listing returns only the title, location, a job code and a relative "Posted 3 Days Ago" string. So `department`, `employmentType`, salary and `isRemote` stay `null`, and `postedAt` stays `null` because a relative phrase is not a date. That means **`remoteOnly`, `departmentContains` and `postedWithinDays` filter out every Workday row** - use `titleContains` and `locationContains` instead, or `remoteHint` for the guess.

**Workday is also identified differently.** The other four accept a bare board name; Workday needs its board URL (`https://<tenant>.wd5.myworkdayjobs.com/<Site>`), because the tenant and the site name are both part of the address. A bare name therefore never tries Workday. One run returns up to 500 roles per Workday board; if a board has more, you get a `partial` row telling you how many of how many you received.

### Input

| Field | Type | Default | What it does |
|---|---|---|---|
| `boards` | array | `greenhouse:stripe`, `lever:spotify`, `ashby:ramp`, `workable:lyst` | One entry per company board, up to 200. See the three accepted forms below. |
| `titleContains` | string | *(empty)* | Keep only jobs whose title contains this text (case-insensitive) |
| `departmentContains` | string | *(empty)* | Keep only jobs whose department **or** team contains this text |
| `locationContains` | string | *(empty)* | Matches the primary location and every secondary location |
| `remoteOnly` | boolean | `false` | Keep only jobs the ATS itself flags as remote |
| `postedWithinDays` | integer | `0` (off) | Keep only jobs first published within N days. Jobs with an unknown date are excluded rather than assumed recent. |
| `maxJobsPerBoard` | integer | `0` (no limit) | Cap of job rows per board, applied after filtering |
| `includeDescription` | boolean | `false` | Add the full description as plain text in `descriptionText` |
| `maxRetries` | integer | `3` | Total attempts per board, including the first, for temporary ATS errors (HTTP 429 / 5xx). A 404 is a permanent answer and is not retried. |

#### Three ways to name a board

```jsonc
{
  "boards": [
    "greenhouse:stripe",                  // <ats>:<board name>
    "https://jobs.lever.co/spotify",      // paste the job board URL
    "ramp"                                // name only - Greenhouse, Lever, Ashby and Workable are tried
    // Workday needs the board URL: "https://<tenant>.wd5.myworkdayjobs.com/<Site>"
  ]
}
```

Giving the name only costs up to four lookups (Workday is only tried when you give its board URL) but finds the board wherever it lives. If you already know the ATS, name it - the Actor will then never wander onto a same-named board at a different company.

***

### Output columns

`rowType`, `ok`, `reason`, `ats`, `companySlug`, `companyName`, `jobId`, `title`, `department`, `team`, `employmentType`, `locationRaw`, `locationsAll`, `country`, `city`, `workplaceType`, `isRemote`, `remoteHint`, `salaryMin`, `salaryMax`, `salaryCurrency`, `salaryInterval`, `salaryText`, `postedAt`, `updatedAt`, `jobUrl`, `applyUrl`, `descriptionText`, `source`

Every row from every ATS has all 29 columns, in the same order - including the marker rows. Your spreadsheet, database table or dashboard never changes shape depending on which ATS answered.

***

### Measured, not estimated

All figures below were measured on 2026-08-28 with the shipped build:

- Default input (4 boards: stripe, spotify, ramp, lyst) → **814 jobs in 7.2 seconds**, 4.1 MB downloaded, 0 failures
- Greenhouse alone: **0.35 MB** of jobs plus **0.46 MB** of departments without descriptions, versus **4.4 MB** with them - which is why `includeDescription` is off by default
- A board that does not exist returns exactly **1 `board-not-found` row and is not charged**. An entry that cannot be read as a board at all returns **1 `unavailable` row** rather than vanishing
- Test suite: **93 checks** across two files, plus **40 deliberate defects injected into the source, all 40 caught by the tests**. The run loop itself - charging, marker rows and the charge-limit path - is driven by fakes so every branch is exercised.

***

### Questions this answers

- **How do I get every open job from a company's Greenhouse board?** - `"greenhouse:stripe"`, or paste the board URL.
- **Same for Lever, Ashby, Workable and Workday?** - yes, and every row comes back in the same 29 columns.
- **Which of these jobs are actually remote?** - `isRemote`, set only when the ATS says so; the guess lives separately in `remoteHint`.
- **What do they pay?** - `salaryMin` / `salaryMax` where the employer published structured figures.
- **Did they stop hiring, or did the scraper break?** - two different rows: `no-open-roles` and `unavailable`.

### Typical uses

- **Hiring intelligence / GTM signals** - a company that just opened 12 engineering roles is a different sales target than one that closed them all. `no-open-roles` rows make "they stopped hiring" a fact you can see rather than a gap in the data.
- **Job aggregators** - one schema across five ATSs, so adding a source doesn't mean rewriting the parser.
- **Compensation research** - structured salary ranges where the employer published them, never guessed.
- **Recruiting ops** - track a competitor's open roles daily and diff on `jobId`.

***

### Notes and limits

- Only public job boards are read. Nothing here needs a login, and no private ATS data is accessed.
- Boards are read from each ATS's own public job board API, so results reflect what the company itself publishes.
- Board names ("slugs") are the company identifier used by the ATS - `stripe` in `boards.greenhouse.io/stripe`.
- Rate limiting is handled with retries for HTTP 429 and 5xx. Permanent answers such as 404 are reported, not retried. Every request has a 45-second timeout, so a silent server cannot hang the run.
- Up to 200 boards per run.

### Other tools by NeverEmpty

Every NeverEmpty Actor follows the same rule: it never returns an empty result to mean two different things, and it only charges for rows that actually carry an answer.

- **[ats-board-finder](https://apify.com/neverempty/ats-board-finder)** - a company domain to the ATS and board name it uses
- **[website-contacts](https://apify.com/neverempty/website-contacts)** - emails, phones and social profiles from any website, no invented addresses
- **[domain-lookup](https://apify.com/neverempty/domain-lookup)** - WHOIS, DNS, MX, SPF, DMARC and SSL expiry for a list of domains

### Support

Found a board that behaves oddly, or want another ATS supported? Open an issue on the **Issues** tab of this Actor and include the board name - it will be reproduced against the live API before anything is changed.

# Actor input Schema

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

One entry per company job board. Use "<ats>:<board>" (greenhouse:stripe, lever:spotify, ashby:ramp, workable:lyst), or paste the job board URL, or give just the board name to try Greenhouse, Lever, Ashby and Workable. Workday can only be identified by its board URL (https://<tenant>.wd5.myworkdayjobs.com/<Site>), so a bare name never tries Workday. A board that does not exist is reported as a board-not-found row - it is never silently dropped. Up to 200 boards per run.

## `titleContains` (type: `string`):

Keep only jobs whose title contains this text (case-insensitive). Leave empty for all jobs.

## `departmentContains` (type: `string`):

Keep only jobs whose department or team contains this text (case-insensitive). Leave empty for all jobs.

## `locationContains` (type: `string`):

Keep only jobs whose location (primary or any secondary location) contains this text (case-insensitive). Leave empty for all jobs.

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

Keep only jobs the ATS itself flags as remote (Lever workplaceType, Ashby isRemote, Workable telecommuting). Jobs where remote status is only guessed from the location text are NOT included - that guess is exposed separately in the remoteHint column.

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

Keep only jobs first published within this many days. 0 disables the filter. Jobs with an unknown posting date are excluded when this filter is on, so the result never implies a date the source did not give.

## `maxJobsPerBoard` (type: `integer`):

Upper limit of job rows kept per board after filtering. 0 means no limit. You are only charged for job rows actually written to the dataset.

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

Add the full job description as plain text (HTML tags removed) in the descriptionText column. Off by default because it makes each run much heavier - the four default boards download 7.7 MB with descriptions versus 3.7 MB without, and Greenhouse alone goes from 0.35 MB to 4.4 MB (measured 2026-08-28).

## `maxRetries` (type: `integer`):

How many times to try a board in total, including the first attempt, when the ATS returns a temporary error (HTTP 429 or 5xx). Permanent answers such as 404 are not retried.

## Actor input object example

```json
{
  "boards": [
    "greenhouse:stripe",
    "lever:spotify",
    "ashby:ramp",
    "workable:lyst"
  ],
  "titleContains": "",
  "departmentContains": "",
  "locationContains": "",
  "remoteOnly": false,
  "postedWithinDays": 0,
  "maxJobsPerBoard": 0,
  "includeDescription": false,
  "maxRetries": 3
}
```

# Actor output Schema

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

All rows produced by this run.

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

// Run the Actor and wait for it to finish
const run = await client.actor("neverempty/ats-jobs-api").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": [
        "greenhouse:stripe",
        "lever:spotify",
        "ashby:ramp",
        "workable:lyst",
    ] }

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

```

## MCP server setup

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

```

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/HGFj4TCFdHuk7Li0k/builds/Z3WIAckSDMbKCkrZo/openapi.json
