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

Company names in, live jobs out: finds each company's own board on 9 ATS platforms (Greenhouse, Lever, Ashby, Workday...), returns postings as one clean schema with salary/location/seniority, or emits only what opened, closed or changed since the last run. Employer-published data, no LinkedIn.

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

## Pricing

from $0.90 / 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/platform/actors/running/actors-in-store#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 & API: Greenhouse, Lever, Ashby, Workday and 5 more

Give this actor company names or domains. It finds each company's official
job board across **nine ATS platforms**, Greenhouse, Lever, Ashby, Workday,
SmartRecruiters, Workable, Breezy, Personio, Rippling, then returns the live
postings as **one clean, normalized schema**, or just **what opened, closed
and changed** since your last run.

The data comes from the ATS vendors' own public job-board APIs, the same
endpoints each employer's careers page is built on. Not LinkedIn, not Indeed,
no login, no cookies, no proxies, nothing that breaks when a social network
changes its markup. If the employer publishes it, you get it, with a source
URL you can click.

### Why this beats a job-site scraper

| | Job-site scrapers | This actor |
|---|---|---|
| Source | LinkedIn/Indeed HTML, changes without notice | The employer's own ATS API |
| Coverage per company | Whatever the site indexed, often days late | The complete live board, every run |
| Salary | Occasional text blob | Employer-published ranges as structured min/max/currency/period, plus careful text parsing, each value labeled with its source |
| Company identification | You search and hope | `find_board` mode tells you which ATS a company uses, with evidence and a confidence score |
| Change tracking | Diff it yourself | `watch_changes` emits opened / closed / changed events with the fields that changed |
| Anti-bot risk | Constant | None. These endpoints exist to be read |

### When you should use something else instead

There is a second kind of tool on this Store, and it is not a job-site
scraper: an **aggregated ATS index**. Those crawl a very large number of
boards ahead of time, hold a few million postings, and let you search them by
keyword, location, skill or salary. They cover far more platforms than the
nine here.

**If your question is "find me senior Go roles in Berlin", buy one of those,
not this.** Searching an index is what they are for, and this actor does not
do it.

Buy this one when the question is about **named companies**:

- *"What is open at these 60 accounts right now?"* This reads each employer's
  own board at request time, so a role closed an hour ago is closed here. An
  index refreshes on its own schedule.
- *"Which of my accounts started hiring this week?"* `watch_changes` emits
  opened, closed and changed events per company. An index gives you a
  different result set and leaves the diffing to you.
- *"Does this company even have a public board, and where?"* `find_board`
  answers that for a company that no index has crawled, with evidence and a
  confidence score.
- *"Is that salary real?"* Every derived field says whether the employer
  published it or we parsed it. Tools that infer skills and salaries with a
  model rarely tell you which numbers were stated and which were guessed, and
  on compensation that difference matters.

Recruiters, sales teams and competitive researchers are usually asking the
second kind of question. Job seekers are usually asking the first.

### The three modes

#### 1. `fetch_jobs`: live postings for your companies

Input companies as names (`"Ramp"`), domains (`"ramp.com"`, more precise), or
board URLs (exact, and the only way to reach Workday tenants):

```json
{
  "mode": "fetch_jobs",
  "companies": ["stripe.com", "duolingo.com", "https://jobs.ashbyhq.com/ramp"],
  "max_jobs_per_company": 100
}
```

Every job comes back in the same shape regardless of ATS. A real record,
fetched 2026-08-10:

```json
{
  "job_id": "ashby:ramp:34413f8d-26bf-4bbc-8ade-eb309a0e2245",
  "company_name": "ramp",
  "ats_platform": "ashby",
  "title": "Security Engineer, Cloud",
  "department": "Engineering",
  "team": "Backend",
  "location": {"raw": "New York, NY (HQ)", "city": "New York",
               "region": "NY", "country": "US", "is_remote": true},
  "workplace_type": "hybrid",
  "workplace_type_source": "published",
  "employment_type": "full_time",
  "seniority": "mid",
  "seniority_source": "inferred",
  "salary": {"min": 211400.0, "max": 290600.0, "currency": "USD",
             "period": "year", "source": "published"},
  "salary_disclosed": true,
  "posted_at": "2026-04-07T17:12:35Z",
  "age_days": 124,
  "job_url": "https://jobs.ashbyhq.com/ramp/34413f8d-26bf-4bbc-8ade-eb309a0e2245",
  "description_text": "...",
  "fingerprint": "60402780...",
  "fetched_at": "2026-08-10T09:52:00Z"
}
```

#### 2. `watch_changes`: only what changed

The mode the others cannot do. Give it the same list on a schedule (daily is
typical) with a stable `watch_id`:

```json
{
  "mode": "watch_changes",
  "watch_id": "competitor-watch",
  "companies": ["stripe.com", "ramp.com", "duolingo.com"]
}
```

The first run baselines silently. Every later run emits only events:

```json
{"event": "opened",  "board": "ramp.com", "title": "Staff Engineer, Payments", "job": {"...": "full record"}}
{"event": "closed",  "board": "stripe.com", "title": "Head of EMEA Sales", "last_seen": {"...": ""}}
{"event": "changed", "board": "ramp.com", "changed_fields": ["salary"],
 "before": {"salary": "150000-190000 USD/year"},
 "after":  {"salary": "165000-210000 USD/year"}}
```

What people build on this: competitor hiring intel (a burst of sales roles in
Berlin is a market-entry signal), account-based sales triggers ("they just
opened three data-engineering roles, call them about your data product"),
recruiting intelligence (a closed role means a hire or a freeze), salary-band
monitoring on specific companies.

**Honesty rule built in:** if an ATS is down or rate-limits during a run,
that board emits **no events** and its baseline is kept, and the failure is
named in `fetch_errors`. A vendor 503 will never be reported to you as "all
their jobs closed". Deleting a company from your list drops its baseline
without emitting closes, either.

#### 3. `find_board`: which ATS does this company use?

```json
{"mode": "find_board", "companies": ["Ramp", "NVIDIA", "stripe.com"]}
```

```json
{"company_input": "Ramp",
 "best": {"platform": "ashby", "token": "ramp", "open_jobs": 122,
          "confidence": 0.99,
          "evidence": "slug probe 'ramp'; linked from the company's own careers page; token matches the company domain"},
 "boards": ["...every candidate found, ranked..."]}
```

Useful on its own (ATS market research, lead routing by tech stack) and as
the precision step before a big `fetch_jobs` or `watch_changes` run.

### What we will tell you that others will not

- **Every derived field names its source.** `workplace_type_source`,
  `seniority_source` and `salary.source` are `published` (the employer's own
  structured field), `parsed` (read out of the employer's own text), or
  `inferred` (our guess from the title). Filter on published-only if your use
  case demands it.
- **Absent means absent.** No salary published, `salary: null` and
  `salary_disclosed: false`. We never fabricate a "market estimate".
- **Discovery shows its evidence.** Every board comes with how it was found,
  its live job count, and a confidence score. When we cannot confirm a board
  (NVIDIA hides its Workday tenant behind scripts, for example), you get
  `best: null` plus candidates and the reason, never a guess dressed as an
  answer. For Workday, paste the board URL and it resolves exactly.
- **A failed lookup is named, not swallowed.** Rate limits and outages per
  platform appear in `failed_platforms` / `fetch_errors`, so "not on
  Personio" and "Personio was busy" stay different answers.

### Coverage and limits, stated plainly

- **Platforms:** Greenhouse, Lever, Ashby, Workday (by board URL),
  SmartRecruiters, Workable, Breezy, Personio, Rippling. That covers most
  venture-backed and mid-market tech employers and a large share of the
  enterprise. Not yet covered: iCIMS, Taleo, SuccessFactors, BambooHR
  (no public board JSON), Teamtailor (requires a per-company key).
- **Workday needs the board URL** (e.g.
  `https://acme.wd5.myworkdayjobs.com/External`): tenant hosts are not
  guessable from a company name, and we say so rather than pretend.
- **Companies per run:** 500 for `find_board` and `watch_changes`, 200 for
  `fetch_jobs`.
- **`watch_changes` needs descriptions consistent:** keep
  `include_descriptions` the same between runs of one watch, or the run after
  the flip reports every description as changed. Default is off for watches.

### Pricing

Pay per event, no subscription:

| Event | When |
|---|---|
| `board_lookup` | Per company successfully resolved in `find_board` (unresolved companies are free) |
| `job` | Per job returned in `fetch_jobs`. **No rounding.** $1.30 per 1,000 jobs on the free tier, falling to $0.90 |
| `board_watched` | Per board checked in a `watch_changes` run |
| `change_event` | Per opened/closed/changed event emitted |

A daily watch on 50 companies costs 50 `board_watched` per day plus the
handful of real events; a full pull of a 500-job enterprise is 500 `job`.

### Quick starts

Sales triggers, daily, into your CRM:

```json
{"mode": "watch_changes", "watch_id": "abm-accounts",
 "companies": ["snowflake.com", "databricks.com", "confluent.io"]}
```

Recruiting market map, one shot:

```json
{"mode": "fetch_jobs",
 "companies": ["stripe.com", "ramp.com", "brex.com", "mercury.com"],
 "include_descriptions": true}
```

Which ATS do my 200 target accounts use?

```json
{"mode": "find_board", "companies": ["...200 domains..."]}
```

Not affiliated with or endorsed by any ATS vendor; all trademarks belong to
their owners. Reads only public, unauthenticated job-board endpoints that
employers publish for exactly this purpose.

# Actor input Schema

## `mode` (type: `string`):

find\_board = which ATS a company uses, with evidence; fetch\_jobs = live normalized postings for your companies; watch\_changes = only what opened/closed/changed since the last run with the same watch\_id

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

One entry per company: a name ("Ramp"), a domain ("ramp.com", more precise), or a job-board URL ("https://jobs.lever.co/plaid", exact; the only way to reach Workday tenants). Objects work too: {"company": "Ramp", "domain": "ramp.com", "id": "my-key"}. Up to 500 for find\_board/watch\_changes, 200 for fetch\_jobs.

## `watch_id` (type: `string`):

Stable name for this watch list, e.g. 'competitor-watch'. It names the stored snapshot the next run diffs against. The first run with a new watch\_id sets the baseline and emits no events; schedule the actor and later runs emit only real changes.

## `include_descriptions` (type: `boolean`):

Full description text per job (plain text, cleaned). Default ON for fetch\_jobs. Default OFF for watch\_changes: leaner, faster, and title/location/salary/team changes are still detected. Keep it consistent between watch runs, changing it mid-watch makes one run report every description as changed.

## `include_html` (type: `boolean`):

Also keep the employer's original HTML description alongside the cleaned text (fetch\_jobs mode).

## `max_jobs_per_company` (type: `integer`):

Cap per company in fetch\_jobs mode. Large enterprises list thousands.

## `read_careers_page` (type: `boolean`):

During discovery, also read the company's own public careers page to confirm the board and to find Workday tenants (their board URLs cannot be guessed from a name). Turn off only if you want pure API probing.

## Actor input object example

```json
{
  "mode": "fetch_jobs",
  "companies": [
    "stripe.com",
    "duolingo.com",
    "https://jobs.ashbyhq.com/ramp"
  ],
  "watch_id": "my-watch",
  "include_descriptions": true,
  "include_html": false,
  "max_jobs_per_company": 100,
  "read_careers_page": true
}
```

# Actor output Schema

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

Jobs, boards, or change events depending on mode.

## `csv` (type: `string`):

For spreadsheets and ATS imports.

## `runInConsole` (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 = {
    "mode": "fetch_jobs",
    "companies": [
        "stripe.com",
        "duolingo.com",
        "https://jobs.ashbyhq.com/ramp"
    ],
    "watch_id": "my-watch",
    "max_jobs_per_company": 100
};

// Run the Actor and wait for it to finish
const run = await client.actor("friendlyapi/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 = {
    "mode": "fetch_jobs",
    "companies": [
        "stripe.com",
        "duolingo.com",
        "https://jobs.ashbyhq.com/ramp",
    ],
    "watch_id": "my-watch",
    "max_jobs_per_company": 100,
}

# Run the Actor and wait for it to finish
run = client.actor("friendlyapi/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 '{
  "mode": "fetch_jobs",
  "companies": [
    "stripe.com",
    "duolingo.com",
    "https://jobs.ashbyhq.com/ramp"
  ],
  "watch_id": "my-watch",
  "max_jobs_per_company": 100
}' |
apify call friendlyapi/ats-jobs-scraper --silent --output-dataset

```

## MCP server setup

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