# Greenhouse, Lever, Ashby & Workable Jobs Scraper (Any Company) (`nekruz/ats-jobs-scraper`) Actor

Pull every open job from company career pages on Greenhouse, Lever, Ashby and Workable into one clean schema. Paste company URLs or slugs, filter by keyword, location, remote or posting date, and pay only per job returned.

- **URL**: https://apify.com/nekruz/ats-jobs-scraper.md
- **Developed by:** [Nekruz Kasimov](https://apify.com/nekruz) (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.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.
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?

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 Jobs Scraper (Any Company)

Get every open job from company career pages in one normalized dataset. Give it a list of companies, get back title, location, remote flag, department, employment type, salary where published, posting date, apply link, and optionally the full description. Works for any company whose careers page runs on **Greenhouse**, **Lever**, **Ashby** or **Workable**, which together host the hiring pages of tens of thousands of companies, from startups to Stripe, Palantir and OpenAI.

No browser, no proxies, no CAPTCHAs: it reads the official public job-board endpoints, so runs are fast, cheap and stable.

### What you can do with it

- **Build a job board or newsletter** for a niche, a city or an industry, refreshed daily.
- **Track hiring at competitors, customers or target accounts** for sales and investment research.
- **Feed AI agents and RAG pipelines** with structured job data. The Actor is available over API and MCP.
- **Monitor remote roles or specific titles** across hundreds of companies with keyword and location filters.
- **Source candidates' market data** for recruiting and salary research.

### Input

| Field | Description |
|---|---|
| `companies` | One entry per company. Accepts a careers URL, a `platform:slug` entry, or a bare slug. Examples: `https://boards.greenhouse.io/stripe`, `https://jobs.lever.co/palantir`, `https://jobs.ashbyhq.com/openai`, `https://apply.workable.com/careers`, `ashby:openai`, `stripe`. A bare slug is tried on all four platforms. |
| `includeDescription` | Add the full job description as plain text and HTML. Off by default to keep output small. |
| `keywords` | Keep only jobs whose title contains one of these words, for example `["engineer", "designer"]`. |
| `locations` | Keep only jobs whose location contains one of these strings, for example `["Remote", "Berlin"]`. |
| `remoteOnly` | Keep only remote jobs. |
| `postedAfter` | ISO date. Keep only jobs posted or updated on or after it. Use it for daily incremental runs. |
| `maxJobsPerCompany` | Cap per company. `0` means no cap. |

Example:

```json
{
  "companies": ["https://boards.greenhouse.io/stripe", "lever:palantir", "ashby:openai"],
  "keywords": ["engineer"],
  "remoteOnly": true,
  "postedAfter": "2026-10-01"
}
```

### Output

One dataset item per job:

```json
{
  "platform": "greenhouse",
  "company": "Stripe",
  "companySlug": "stripe",
  "jobId": "8172503",
  "title": "Abuse Research Engineer",
  "url": "https://stripe.com/jobs/search?gh_jid=8172503",
  "applyUrl": "https://stripe.com/jobs/search?gh_jid=8172503",
  "location": "Remote from the US",
  "locations": ["US"],
  "country": null,
  "isRemote": true,
  "workplaceType": null,
  "department": "Security Analytics",
  "team": null,
  "employmentType": null,
  "salary": null,
  "publishedAt": "2026-09-09T10:52:09-04:00",
  "updatedAt": "2026-10-07T19:02:27-04:00",
  "descriptionPlain": "Who we are ...",
  "descriptionHtml": "<h2>Who we are</h2> ...",
  "scrapedAt": "2026-10-12T20:08:49.690Z"
}
```

`isRemote` comes from the platform's workplace type where one exists (remote vs hybrid vs on-site), otherwise from its remote flag, otherwise it is inferred from the location text. `salary` is filled when the board publishes a range (Lever and Ashby support this). Fields that a platform does not provide are `null`.

The run's key-value store also gets an `OUTPUT` record with a per-board summary: jobs found on the board, jobs after filters, jobs pushed, and any boards that were not found or failed. It contains:

- `boards`: one entry per board or input entry, with `input`, `platform`, `slug`, `status`, `jobsOnBoard` (jobs on the board), `jobsFetched` (jobs downloaded; on Workable this can be fewer when paging stops early at your cap), `jobsAfterFilters`, `jobsPushed`, plus `descriptionFailures`, `descriptionsStopped`, `error` or `duplicateOf` when they apply. `descriptionsStopped` is `true` when a Workable board's description requests were given up (see Notes). A `duplicate` entry carries only `input`, `status`, `duplicateOf` and, where the entry names a board, `platform` and `slug`.
- `jobsPushed`: jobs saved to the dataset. `jobsCharged`: jobs billed under pay-per-event pricing, otherwise `null`.
- `filteredOut`: jobs dropped by each filter (`keywords`, `locations`, `remoteOnly`, `postedAfter`); a job is counted under the first filter it failed.
- `descriptionFailures`: jobs saved without a description (see Pricing). `descriptionsRecovered`: descriptions that failed once and succeeded on the automatic retry.
- `chargeLimitReached`: `true` if the run stopped at your maximum charge per run.

Each board's `status` is one of:

- `ok`: the board was read.
- `partial`: a later page of a Workable board failed. Jobs from the earlier pages are kept and `error` says which page failed.
- `not_found`: the careers URL or `platform:slug` entry points to no board.
- `not_found_on_any_platform`: a bare company name matched no board on Greenhouse, Lever, Ashby or Workable.
- `unrecognized`: the entry is not a supported careers URL or slug.
- `error`: the board request failed after retries; `error` has the reason.
- `duplicate`: the entry is the same board as an earlier one (see Notes), so it was not requested and not charged; `duplicateOf` is the first input for it. A bare company name that an earlier entry already looked up and did not find is reported the same way.

With `includeDescription`, a Workable job whose description could not be fetched is still returned, with null descriptions and a `descriptionError` field (for example `HTTP 503`, or `not requested: 3 descriptions in a row failed (HTTP 503)` when the Actor stopped requesting that board's descriptions, see Notes).

### Pricing

You pay per job returned. Filtered-out jobs are free, so narrow filters keep costs low. Set a maximum charge per run in the run options if you want a hard cap; the Actor stops cleanly when it is reached (`chargeLimitReached` is `true` in `OUTPUT`).

A board entered more than once is requested and charged once: repeat entries show as `duplicate` in `OUTPUT` and cost nothing.

A Workable job whose description could not be fetched is not charged. Failed description requests are retried (rate limits and server errors get one more try after a short pause); if the description is still missing, the job is returned with `descriptionError` set and counted in `descriptionFailures`. If the Actor stops requesting a board's descriptions (see Notes), the jobs it skipped are saved the same way, free, with `descriptionError: "not requested: ..."`. `jobsCharged` shows what was actually billed. Such a job still counts toward `maxJobsPerCompany`.

### Limits and notes

- Only public, listed jobs are returned. Internal or unlisted postings are not visible on these endpoints.
- A company that moved from one ATS to another may still have an old, empty board on the previous platform. The `OUTPUT` summary tells you which board returned how many jobs.
- Workable descriptions require one extra request per job, so `includeDescription` is slower for Workable boards.
- Workable description requests stop on a board after 3 failures in a row (rate limits, server errors or network errors, after their own retries). The remaining jobs wait for the retry after 5 s. If it works, the skipped jobs are fetched; if it fails, the board's descriptions are given up: those jobs are saved free with `descriptionError: "not requested: ..."`, the board's `OUTPUT` entry gets `descriptionsStopped: true`, and the final status message adds ", description requests stopped as Workable kept failing".
- If a later page of a Workable board fails after retries, the board is marked `partial` in `OUTPUT` and a warning is logged. The jobs already fetched are kept, and the final status message adds "(1 incomplete, see OUTPUT)", for example `Done: 10 jobs from 1 boards (1 incomplete, see OUTPUT)`. Check `OUTPUT` for `partial` boards.
- Boards are compared by platform and slug. Greenhouse and Ashby ignore case, so `greenhouse:Stripe`, `https://boards.greenhouse.io/stripe/` and `boards.greenhouse.io/STRIPE` are one board. Lever and Workable slugs are case-sensitive, so another case there is another board. A repeat makes no request and is not charged.
- A bare company name is probed across the four platforms only once; entering it again, or entering the board it matched as `platform:slug` or as a URL, is a repeat. Known limit: `greenhouse:x` followed by a bare `x` still probes the other three platforms. A repeat of a board that failed is not retried.
- Careers URLs without `https://` (for example `jobs.lever.co/palantir`) are read as URLs, not as bare slugs.
- On Workable, a job that appears on two list pages (the board changed while it was being read) is kept once, so `jobsFetched` and `maxJobsPerCompany` count it once.
- A run that saves no jobs says why in its status message and in a warning, for example `Done: 0 jobs. 1033 jobs found but all filtered out (1033 by keywords) (see OUTPUT)`. Other reasons it can give: charge limit reached, board empty, not found, not recognized, failed, or only partly fetched.
- Rate limits (HTTP 429), server errors (5xx) and network errors are tried up to 3 times. The Actor waits for the `Retry-After` the server sends (at most 60 s), otherwise 1.5 s, then 3 s.
- Found a company whose careers page runs on a different ATS? Open an issue on the Actor page and it may be added.

### Integrations

Run it on a schedule, then push results to Google Sheets, Airtable, Slack, a webhook or your own database with Apify integrations. Call it from code with the Apify API clients for JavaScript and Python, or let an AI agent call it as a tool through the Apify MCP server.

# Changelog

This Actor's version history is a separate document: https://apify.com/nekruz/ats-jobs-scraper/changelog.md

# Actor input Schema

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

One entry per company. Accepts a careers URL (https://boards.greenhouse.io/stripe, https://jobs.lever.co/acme, https://jobs.ashbyhq.com/openai, https://apply.workable.com/acme), a prefixed slug (greenhouse:stripe, lever:acme, ashby:openai, workable:acme), or a bare slug (all four platforms are tried).

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

Add the full job description as plain text and HTML. Makes output larger.

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

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

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

Keep only jobs whose location contains one of these strings (case-insensitive), for example "Remote", "Berlin", "United States". Leave empty for all.

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

Keep only jobs marked remote or whose location mentions remote.

## `postedAfter` (type: `string`):

ISO date (YYYY-MM-DD). Keep only jobs published or updated on or after this date. Leave empty for all.

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

Stop after this many jobs for each company. 0 means no limit.

## Actor input object example

```json
{
  "companies": [
    "https://boards.greenhouse.io/stripe",
    "ashby:openai"
  ],
  "includeDescription": false,
  "keywords": [],
  "locations": [],
  "remoteOnly": false,
  "maxJobsPerCompany": 25
}
```

# Actor output Schema

## `items` (type: `string`):

One record per open job, normalized across Greenhouse, Lever, Ashby and Workable.

## `summary` (type: `string`):

The OUTPUT record: per-target statuses, counts, what was charged, and why items were skipped or missing.

# 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": [
        "https://boards.greenhouse.io/stripe",
        "ashby:openai"
    ],
    "maxJobsPerCompany": 25
};

// Run the Actor and wait for it to finish
const run = await client.actor("nekruz/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": [
        "https://boards.greenhouse.io/stripe",
        "ashby:openai",
    ],
    "maxJobsPerCompany": 25,
}

# Run the Actor and wait for it to finish
run = client.actor("nekruz/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": [
    "https://boards.greenhouse.io/stripe",
    "ashby:openai"
  ],
  "maxJobsPerCompany": 25
}' |
apify call nekruz/ats-jobs-scraper --silent --output-dataset

```

## MCP server setup

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