# Greenhouse, Lever, Ashby & Workday Jobs Scraper (Multi-ATS) (`crypticcabe/multi-ats-jobs`) Actor

\[From $1.50 / 1K jobs] Greenhouse jobs scraper, Lever jobs API and Workday jobs scraper in one: plus Ashby, SmartRecruiters & Workable. Paste company names or careers URLs. Salary parsing, remote/country filters, new/removed tracking. No login, no proxies.

- **URL**: https://apify.com/crypticcabe/multi-ats-jobs.md
- **Developed by:** [Victor Verpooten](https://apify.com/crypticcabe) (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 $1.50 / 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?

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 Jobs Scraper, Lever Jobs API & Workday Jobs Scraper: Multi-ATS Job Feed (Ashby, SmartRecruiters, Workable)

One Actor, one clean schema, six applicant-tracking systems. Give it company names, ATS board links or a plain careers-page URL. It returns every open job in the same format, with **parsed salary ranges**, **country and remote flags**, and optional **"what changed since last run"** tracking.

Everything comes from the ATS vendors' own public job-board endpoints. **No browser, no proxies, no login.** Runs are fast and cheap: **from $1.50 per 1,000 jobs** (pay per event, no monthly rental).

**Try it in 30 seconds:** press **Start** with the prefilled input (Figma on Greenhouse, Spotify on Lever, Ramp on Ashby and Hugging Face on Workable, at most 40 jobs, about 6 cents) or start with an empty input for a smaller 25-job demo. Then replace `companies` with your own list.

### What you get

- **Six ATSs in one schema.** Greenhouse, Lever (global and EU), Ashby, SmartRecruiters, Workable and **Workday** (the career sites of many large employers, `*.myworkdayjobs.com`).
- **Paste anything.** Accepts `greenhouse:figma`, `https://boards.greenhouse.io/figma`, `https://jobs.lever.co/spotify`, `https://apply.workable.com/huggingface/`, `https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite`, or a company careers URL such as `https://www.figma.com/careers/`. The Actor finds the ATS behind the careers page. A bare name such as `ramp` is tried against every ATS except Workday (Workday needs its URL or full slug, see below).
- **Salary extraction.** Structured pay data is used where the ATS provides it: Ashby, Lever, and Greenhouse (opt-in). Otherwise salary ranges are parsed from the posting text ("$120,000-$150,000 USD", "€70.000 - €90.000", "$55-$65/hour"). Each salary has min, max, currency, period and an annualised value. `source` tells you whether it is `structured` or `text`.
- **Filters.** Keywords (any-of), exclude keywords, keyword scope (title only, or title and description), countries (ISO codes or names), remote only, departments, and posted within N days.
- **Change tracking.** Turn on `sinceLastRun` and you only get `new`, `updated` and `removed` jobs. Ideal for scheduled runs, alerts and job-board syncs.
- **Built-in company directory.** About **3,100 verified boards** (Greenhouse ~720, Ashby ~970, Workable ~700, Lever ~460, SmartRecruiters ~75, **Workday ~215 large employers**). Turn on `useSeedList` to scan them without supplying a list. The Workday directory is opt-in: add `"workday"` to `seedAts`, because Workday boards are big and slower to read.
- **Polite and resilient.** Per-host concurrency limits, pacing, retries with backoff, and `Retry-After` support. One failing company never aborts the run.

### One Actor instead of four: Greenhouse, Lever, Ashby and Workday

- **Greenhouse jobs scraper**: every open job on any `boards.greenhouse.io` board, with optional official pay ranges.
- **Lever jobs API**: any `jobs.lever.co` board (global and EU) as clean JSON, CSV or Excel.
- **Workday jobs scraper**: large employers' `*.myworkdayjobs.com` career sites (opt-in, with a directory of 217 verified tenants).
- **Ashby, SmartRecruiters and Workable jobs** in the same schema, so you write your pipeline once.

### Typical uses

- Scrape every open job from a list of target companies (startups on Greenhouse, Lever and Ashby, large employers on Workday)

- Job boards and aggregators that need fresh, normalised listings

- Salary benchmarking and compensation research (see the `salaries` view)

- Recruiting and sales intelligence: "who is hiring a Head of Data in Germany?"

- Hiring-signal alerts for competitors or target accounts (schedule with `sinceLastRun`)

- Feeding LLM/RAG pipelines with structured job data (read the safety note below)

### Input

| Field | Meaning |
|---|---|
| `companies` | Company identifiers: `ats:slug`, a board URL, a careers-page URL, or a bare slug. |
| `useSeedList`, `seedAts`, `seedLimit` | Also scan the built-in directory (largest boards first). |
| `keywords`, `excludeKeywords`, `keywordScope` | Keyword filters. Scope is `title` or `title_and_description`. |
| `countries`, `includeUnknownCountry` | Country filter. Jobs with an unknown country are dropped unless you opt in. |
| `remoteOnly`, `departments`, `postedWithinDays` | Remote, department and recency filters. Jobs with no posting date are kept. |
| `sinceLastRun`, `stateKey`, `includeRemoved` | Change tracking. `stateKey` lets you keep independent watch-lists. |
| `descriptionFormat` | `text`, `html`, `both` or `none`. |
| `parseSalary`, `fetchGreenhousePayRanges` | Salary options. Greenhouse's official pay ranges need one extra request per job. |
| `maxJobs`, `maxJobsPerCompany` | Hard caps. Use these to bound your cost. |
| `maxConcurrency`, `perHostConcurrency` | Throughput controls. The defaults are polite. |

#### Example input

```json
{
  "companies": ["greenhouse:figma", "lever:spotify", "ashby:ramp", "smartrecruiters:Equinox", "https://apply.workable.com/huggingface/"],
  "keywords": ["engineer", "manager"],
  "fetchGreenhousePayRanges": true,
  "maxJobsPerCompany": 25,
  "maxJobs": 100
}
```

#### Example: weekly "what's new" feed for a watch-list

```json
{
  "useSeedList": true,
  "seedAts": ["greenhouse", "ashby"],
  "seedLimit": 300,
  "keywords": ["data engineer", "machine learning"],
  "countries": ["US", "CA"],
  "sinceLastRun": true,
  "stateKey": "weekly-data-roles"
}
```

### Workday

Workday career sites live at `https://<tenant>.<wdN>.myworkdayjobs.com/<site>`. Give the Actor that URL (a localised `/en-US/` part or a link to a single job is fine) or the explicit form `workday:<tenant>.<wdN>.<site>`:

```json
{
  "companies": [
    "workday:nvidia.wd5.NVIDIAExternalCareerSite",
    "https://salesforce.wd12.myworkdayjobs.com/en-US/External_Career_Site"
  ],
  "countries": ["US"],
  "maxJobsPerCompany": 100
}
```

Or scan the bundled directory of about 215 verified Workday employers: `{"useSeedList": true, "seedAts": ["workday"], "seedLimit": 20}`.

What to know:

- **Slug format.** A Workday slug is `tenant.wdN.site`, for example `nvidia.wd5.NVIDIAExternalCareerSite`. The site name is case-sensitive. The easiest way to get it is to open the company's careers page and paste its URL.
- **Bare names are not guessed.** `nvidia` on its own is never tried against Workday, because Workday needs the data-centre and site name too.
- **More than 2,000 jobs.** Workday returns at most 2,000 results per query. For bigger tenants the Actor splits the search by job family, time type and similar filters, so it can return, for example, 2,700 NVIDIA jobs. If a tenant is over the cap and offers nothing to split on, you get the first 2,000, and `sinceLastRun` does not infer removals for it.
- **Dates.** The list only says "Posted 3 Days Ago". The Actor reads the exact start date from each job's detail page.
- **Locations.** Where the list says "2 Locations", the real locations are read from the job's detail page when you use the `countries` filter.
- **Department and team** are not published in the list, so they are empty for Workday jobs. A `departments` filter therefore excludes Workday jobs; use `keywords` instead.
- **Change tracking** ignores description edits for Workday (as for SmartRecruiters), because descriptions are fetched per job. Title, location and similar changes are tracked.
- **Not covered:** career sites on `myworkdaysite.com` and employers that run their own login-only Workday.
- **Be gentle.** Requests are paced, Workday's throttling replies (403/429) are retried with backoff, and a failing tenant never stops the run. The paging approach for Workday's 2,000-result cap follows [kalil0321/ats-scrapers](https://github.com/kalil0321/ats-scrapers) (MIT licence). The code is original.

### Output

One item per job. See `samples/output_sample.json` for real examples from the original five systems. Workday jobs use the same fields.

```json
{
  "id": "ashby:ramp:34413f8d-26bf-4bbc-8ade-eb309a0e2245",
  "ats": "ashby",
  "companySlug": "ramp",
  "companyName": "Ramp",
  "jobId": "34413f8d-26bf-4bbc-8ade-eb309a0e2245",
  "title": "Security Engineer, Cloud",
  "url": "https://jobs.ashbyhq.com/ramp/34413f8d-26bf-4bbc-8ade-eb309a0e2245",
  "applyUrl": "https://jobs.ashbyhq.com/ramp/34413f8d-26bf-4bbc-8ade-eb309a0e2245/application",
  "department": "Engineering",
  "team": "Backend",
  "employmentType": "full_time",
  "workplaceType": "hybrid",
  "isRemote": false,
  "locations": [
    "New York, NY (HQ)",
    "Remote (Canada)",
    "Remote (US)",
    "Miami, FL"
  ],
  "primaryLocation": "New York, NY (HQ)",
  "countryCodes": [
    "US",
    "CA"
  ],
  "postedAt": "2026-04-07T17:12:35Z",
  "updatedAt": null,
  "salary": {
    "min": 211400,
    "max": 290600,
    "currency": "USD",
    "period": "year",
    "source": "structured",
    "text": "$211.4K - $290.6K",
    "annualMin": 211400,
    "annualMax": 290600
  },
  "changeType": "current",
  "firstSeenAt": null,
  "scrapedAt": "2026-10-27T20:49:52Z",
  "contentHash": "34e27a814cf94819",
  "descriptionText": "About Ramp\n\nRamp is building the smart infrastructure for finance teams, embedded in the transaction flow of every dolla…"
}
```

- `employmentType` is one of `full_time`, `part_time`, `contract`, `temporary`, `intern`, `other` (or null).
- `changeType` is `current` (no tracking), or `new`, `updated`, `removed` (tracking on). `removed` records carry only identity fields (id, title, url, firstSeenAt).
- `firstSeenAt` is only filled when change tracking is on.
- `contentHash` changes when the job's meaningful content changes. Timestamps are ignored.
- Export as JSON, CSV, Excel, XML or via API. The dataset has two views, **Overview** and **Salaries**.

### Pricing (pay per event)

You pay only for results. There is no monthly rental.

| Event | Price | When |
|---|---|---|
| Actor start | $0.00005 | once per run |
| Job | **$0.0015** (i.e. $1.50 per 1,000) | each job delivered |
| Salary parsed | $0.0005 | each job where a salary range was found (optional, `parseSalary`) |
| Removed job | $0.0005 | each "removed" record (only with change tracking) |

Platform usage (compute) is included. Rough example: 1,000 jobs from Ashby and Greenhouse boards, about half with a salary, cost about **$1.50 + $0.25 = $1.75**. A weekly change-tracking run over 300 boards usually delivers only the few hundred jobs that changed.

Tip: set `maxJobs` to cap spend. The Actor stops cleanly when your per-run spending limit is reached.

### How change tracking works

With `sinceLastRun: true`, the Actor keeps a compact fingerprint of each company's jobs in a named key-value store (`multi-ats-jobs-state`, under your `stateKey`). On the next run it compares fingerprints and outputs only:

- `new`: not seen before
- `updated`: title, location, department, type or description changed
- `removed`: was listed last time and is gone now

A company that fails to load in a run is skipped without touching its state, so a temporary error never creates false "removed" records. If a company is cut off by `maxJobsPerCompany`, removals are not inferred for it.

### FAQ

**How much does it cost?** About **$1.50 per 1,000 jobs**, plus $0.50 per 1,000 jobs where a salary range was parsed, and a $0.00005 start fee. Example: 25 jobs cost under $0.05. Set `maxJobs` to cap spend.

**Do I need to know which ATS a company uses?** No. Paste the company's careers-page URL and the Actor detects the ATS behind it. If you know it, `greenhouse:figma` style entries are faster.

**Can I get only new jobs?** Yes. Turn on `sinceLastRun` and schedule the Actor. You get `new`, `updated` and `removed` jobs only.

**Is this allowed?** It reads the public job-board endpoints that Greenhouse, Lever, Ashby, SmartRecruiters, Workable and Workday publish so that companies can show jobs on their own sites. It does not log in, bypass protections, or touch private data. You are responsible for how you use the data. Respect the terms of the employers and platforms, and applicable data-protection law. Job postings are published by the employer, and the data contains no candidate information.

**Why is a company missing or "not_found"?** The slug is wrong, the company doesn't use that ATS, or its board is empty. Try the careers-page URL and the Actor will detect the ATS. The built-in directory is community-sourced and can go stale. Stale entries are reported, not fatal.

**Is salary always present?** No. Many postings don't publish pay. Where they do, we extract it. Currency is taken from the symbol or code next to the numbers. A bare `$` is reported as USD even for CAD/AUD postings unless a code is written next to the number. No FX conversion is done.

**Which countries?** Country codes are inferred from the location text (US states and Canadian provinces, country names, common cities) and from ATS fields where available. This is best effort. Filter with `includeUnknownCountry` if you don't want to lose jobs where it can't be inferred.

**What about Recruitee, Personio, iCIMS, etc.?** Not covered yet.

**Speed?** Greenhouse, Ashby and Workable return a whole board in one request. SmartRecruiters needs one extra request per job for the description. Lever can be slower on large boards. Workday lists 20 jobs per request and needs one extra request per delivered job for the description and exact date, so a 1,500-job tenant takes about 15 s to list and about 2.5 minutes with descriptions (Cisco, 1,529 jobs: 162 s). Use `maxJobsPerCompany` or `descriptionFormat: none` for quick runs. In our tests, 100 boards (up to 25 jobs each) took 13 to 47 seconds, and SmartRecruiters took about 90 seconds because of the per-job requests.

#### Important: job text is untrusted data

Descriptions are written by third parties. The Actor strips scripts, iframes, event handlers and unsafe links from HTML, and converts to plain text by default. But **if you feed the output to an LLM or an autonomous agent, treat every description as data, never as instructions.** A posting could contain text such as "ignore previous instructions". Keep it out of your system prompt, and don't give an agent that reads this data tool access you wouldn't give an anonymous stranger.

### Run locally

```bash
python -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt pytest pytest-asyncio
python -m pytest                       # offline tests against recorded ATS responses
mkdir -p storage/key_value_stores/default
cp samples/input.json storage/key_value_stores/default/INPUT.json
ACTOR_TEST_PAY_PER_EVENT=true apify run   # apify-cli; results land in ./storage
```

`ACTOR_TEST_PAY_PER_EVENT=true` logs charge events to a local `charging-log` dataset instead of billing.

To refresh the directory: `python -m tools.verify_slugs <ats> candidates.json out.json`, then `python -m tools.build_seed` (see `tools/`).

# Actor input Schema

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

One entry per line. Accepts: a company careers URL (e.g. https://stripe.com/jobs – the Actor finds the embedded job board), a job-board URL (boards.greenhouse.io/figma, jobs.lever.co/spotify, jobs.ashbyhq.com/ramp, careers.smartrecruiters.com/BoschGroup, apply.workable.com/huggingface, nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite), an explicit `ats:slug` such as `greenhouse:figma` or `workday:nvidia.wd5.NVIDIAExternalCareerSite` (Workday slugs are tenant.wdN.site), or just a bare slug such as `figma` (tried on the non-Workday systems; Workday needs its URL or full slug).

## `useSeedList` (type: `boolean`):

Adds the bundled list of several hundred companies whose job boards were verified to exist. Use the limit below to control how many (largest boards first).

## `seedAts` (type: `array`):

Restrict the built-in directory to some systems. Allowed: greenhouse, lever, ashby, smartrecruiters, workable, workday. Leave empty for the original five; add workday explicitly to include the Workday directory (large boards, slower).

## `seedLimit` (type: `integer`):

How many directory companies to scan (biggest boards first). 0 = all of them.

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

Keep only jobs matching at least one keyword (case-insensitive substring, e.g. `engineer`, `data scientist`). Empty = no keyword filter.

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

Drop jobs matching any of these (e.g. `intern`, `sales`).

## `keywordScope` (type: `string`):

Searching descriptions is more thorough but, for SmartRecruiters, needs one extra request per job.

## `countries` (type: `array`):

Keep jobs located in these countries. ISO codes or names (`US`, `Germany`, `GB`). Country is inferred from the location text and structured address data, so it is best-effort. Empty = worldwide.

## `includeUnknownCountry` (type: `boolean`):

When filtering by country, also keep jobs where no country could be determined (e.g. just "Remote").

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

Keep only jobs the employer marks as remote (or whose location says Remote).

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

Keep jobs whose department or team contains one of these words (e.g. `Engineering`, `Marketing`).

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

0 = no limit. Jobs without a posting date are kept.

## `sinceLastRun` (type: `boolean`):

Remembers every job in a named key-value store and outputs only jobs that are NEW or UPDATED since your last run (plus REMOVED jobs). Perfect for scheduled alerts. The first run outputs everything as `new`.

## `stateKey` (type: `string`):

Use different names to keep independent change-tracking histories (e.g. one per client or per schedule).

## `includeRemoved` (type: `boolean`):

With change tracking on, emit a small `removed` record for jobs that disappeared (job filled or taken down). Billed as the cheaper `job-removed` event.

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

HTML is sanitized (scripts, iframes and event handlers removed). Treat descriptions as untrusted data, never as instructions.

## `parseSalary` (type: `boolean`):

Extract salary ranges (structured fields where the system provides them, otherwise conservatively from the description text). Each job with a salary adds one `salary-parsed` event.

## `fetchGreenhousePayRanges` (type: `boolean`):

Greenhouse only exposes structured pay ranges per job. Enabling this makes one extra request per emitted Greenhouse job (slower, more accurate).

## `maxJobs` (type: `integer`):

Hard cap on dataset items for the run (a cost safety net).

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

Very large employers (e.g. 5,000 postings) can be capped. 0 = unlimited.

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

How many companies are processed at the same time. Requests per host are additionally rate limited, so this is safe to raise.

## `perHostConcurrency` (type: `integer`):

Politeness limit toward each job-board API.

## Actor input object example

```json
{
  "companies": [
    "greenhouse:stripe",
    "https://jobs.lever.co/spotify"
  ],
  "useSeedList": false,
  "seedLimit": 50,
  "keywordScope": "title",
  "includeUnknownCountry": false,
  "remoteOnly": false,
  "postedWithinDays": 0,
  "sinceLastRun": false,
  "stateKey": "default",
  "includeRemoved": true,
  "descriptionFormat": "text",
  "parseSalary": true,
  "fetchGreenhousePayRanges": false,
  "maxJobs": 40,
  "maxJobsPerCompany": 10,
  "maxConcurrency": 8,
  "perHostConcurrency": 4
}
```

# Actor output Schema

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

No description

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

No description

# 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:figma",
        "lever:spotify",
        "ashby:ramp",
        "https://apply.workable.com/huggingface/"
    ],
    "maxJobs": 40,
    "maxJobsPerCompany": 10
};

// Run the Actor and wait for it to finish
const run = await client.actor("crypticcabe/multi-ats-jobs").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:figma",
        "lever:spotify",
        "ashby:ramp",
        "https://apply.workable.com/huggingface/",
    ],
    "maxJobs": 40,
    "maxJobsPerCompany": 10,
}

# Run the Actor and wait for it to finish
run = client.actor("crypticcabe/multi-ats-jobs").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:figma",
    "lever:spotify",
    "ashby:ramp",
    "https://apply.workable.com/huggingface/"
  ],
  "maxJobs": 40,
  "maxJobsPerCompany": 10
}' |
apify call crypticcabe/multi-ats-jobs --silent --output-dataset

```

## MCP server setup

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

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/Y0LN0dE6jNl1CXWjK/builds/LX8fimWpyqo2P0DK7/openapi.json
