# ATS Job Listings API $0.99/1K — Lever Job Board, Job Openings (`snow_leo_data/greenhouse-workday-lever-ashby-ats-jobs-scraper`) Actor

$0.99/1K | 59,185 job listings in one run — where others stop at 5,000. Straight from the careers page: Greenhouse, Workday, Lever, Ashby, Oracle, BrassRing, UKG +13 more. Lever job listings, Ashby job listings, Greenhouse job listings, Workday job listings, employer job listings, recruiting data.

- **URL**: https://apify.com/snow\_leo\_data/greenhouse-workday-lever-ashby-ats-jobs-scraper.md
- **Developed by:** [Snow Leo Data](https://apify.com/snow_leo_data) (community)
- **Categories:** Jobs, Automation, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.99 / 1,000 job postings

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 — jobs from the employer, not from a job board

**$0.99 per 1,000 jobs.** Straight from 20 applicant tracking systems —
Greenhouse, Workday, Lever, Ashby, SmartRecruiters, Oracle Cloud, BrassRing,
UKG and twelve more. Greenhouse data and Workday data reach you through
different endpoints with different shapes; this actor hides that and returns
one row format for all of them — applicant tracking data you can query without
knowing which ATS it came from, ATS data behind a single careers API.

|  |  |
|---|---|
| **59,185 jobs in one run** | the closest competitor stops at 5,000 |
| **1,045 company boards built in** | run it with an empty input and it works |
| **Salary taken from the source field** | not inferred by a model, not missing |
| **$0.99 / 1,000** | others in this niche charge $1.30, $2.50 and $4.00 |

Not a job board. Not a resold database. These are career page jobs: every run
reads the same endpoint the company's own careers page reads, at the moment you
run it — so a role closed this morning is already gone, and one posted an hour
ago is already here.

***

### Who this is for

**Job boards and aggregators.** Fill a board with company job listings that are
live right now, in the employer's own words, and index them straight away — the
output can come out as schema.org `JobPosting`.

**Recruiters and sourcers.** Watch a list of target companies and see only what
opened since yesterday.

**Market and salary research.** Salary comes from the field the employer filled
in, so a pay study is not built on numbers a model guessed at.

**Anyone priced out by a 5,000-job cap.** One large employer alone can fill a
competitor's entire run: Bosch has 4,834 open roles, Walgreens 22,346.

***

### Quick start

**Run it with an empty input.** No account slugs, no tenant addresses: the actor
falls back to a bundled registry of 1,045 verified company boards and returns
1,000 rows, so a trial costs about a dollar.

That sample is spread out on purpose: one board per platform, no employer over
60 rows. A measured trial returns **50 companies across 19 platforms**; picking
by size gave 996 of 1,000 rows from Walgreens alone.

Point it at your own companies when you know them — a bare name is enough, the
platform is detected for you:

```json
{ "companies": ["stripe", "ramp", "notion"], "keywords": ["engineer"] }
```

Watching a market instead? Add `"onlyNew": true` and each run returns only the
postings that appeared since the last one.

Full field reference is further down under **Input**.

***

### Which ATS platforms are supported

Twenty, each read through the same interface the employer's own careers page
calls — no HTML parsing, no headless browser, no job-board middleman:

SmartRecruiters job listings, JOIN job listings, Workable job listings, Personio
job listings, Recruitee job listings, BambooHR job listings, Rippling job
listings and Breezy job listings come back in the same 28 fields as Workday
careers data — the platform only shows up in `source`. Greenhouse, Ashby, Lever,
Pinpoint, Oracle Cloud, Eightfold, HireHive, Comeet, BrassRing, UltiPro and
Jobsoid are read exactly the same way. The endpoint each is read
from, and how many of its boards the registry holds, are in **Where each platform
is read from** below.

***

### What you get back, in plain words

Every platform above runs an employer job feed API for its own careers page, and
this Actor reads it directly. What comes back is employer job
listings — open positions data from the company itself, not a copy that a job
board made of it.

That makes it talent acquisition data rather than a search-result page: the
title, the location, the employment type, the salary field when the ATS has one,
and the whole advert body. Job posting data assembled this way carries the
employer's own wording, because there is no aggregator in between to rewrite it.

The awkward cases are read the same way. A Workday careers site is taken through
its own CXS endpoint rather than its rendered page, and any row can be emitted as
a JobPosting schema document, so a job data pipeline downstream receives
structured objects instead of HTML it has to parse again.

#### What you can do with the file

Every run leaves a job listings dataset you can pull as JSON, CSV or Excel, or
read straight from the dataset API. It is open roles data from the employer's
own system: job openings data with the advert body, the apply URL and the salary
field the ATS published. Used as employer hiring data or company hiring data it
shows who is growing and where; used as a recruitment API it fills an internal
board without a second source.

Read as a job postings feed it can be served onward unchanged; job description
data arrives as plain text and, where the ATS publishes it, as HTML. Lever job
listings, Greenhouse job listings and Workday job listings land in that same
file with only `source` telling them apart, so a job postings dataset built from
twenty platforms needs no per-platform mapping of your own.

### Common questions

**How fresh is a row?** Fetched when you press Start — nothing cached, so a role
closed this morning is already absent.

**Do I have to supply a company list?** No. An empty input reaches the bundled
registry's 1,045 careers page listings; with your own list a bare company name
is enough, because the ATS platform data that routes the request is detected for
you.

**Is this a feed or a download?** Both. The dataset behind a run is an employer
data API you read by URL, which makes it a jobs data feed and not only an
export.

**Why employer job postings and not a job board?** A board is a copy. These are
direct employer listings, read from the system the employer files the role in,
so wording, salary and closing date are the employer's own.

### What one row looks like

Every row carries the same 28 fields whatever the platform, and every key is
always present — no checking whether a field exists.

```json
{
  "source": "ashby",
  "company": "ramp",
  "company_slug": "ramp",
  "job_id": "c63ba7d7-5290-4d9b-b002-40b2873b66f6",
  "title": "Partner Consultant, Accounting",
  "url": "https://jobs.ashbyhq.com/ramp/c63ba7d7-5290-4d9b-b002-40b2873b66f6",
  "location": "Remote (US)",
  "locations": [
    "Remote (US)",
    "Remote (Canada)",
    "San Francisco, CA",
    "New York, NY (HQ)"
  ],
  "city": "",
  "country": "",
  "latitude": null,
  "longitude": null,
  "department": "Sales / Solutions Consulting",
  "employment_type": "FullTime",
  "experience_level": "",
  "remote": true,
  "hybrid": false,
  "has_remote_option": true,
  "posted_at": "2026-09-02T17:17:20.841+00:00",
  "posted_days_ago": 8,
  "salary_raw": "$151K - $231K",
  "salary_min": 151000,
  "salary_max": 207000,
  "salary_currency": "USD",
  "salary_period": "year",
  "description_text": "ABOUT RAMP\n\nRamp is building the smart infrastructure for finance teams, embedded in the transaction flow of every dollar a business spends. We automate how over $200B in …",
  "description_html": "",
  "ref_number": ""
}
```

`remote` and `hybrid` are separate booleans; `has_remote_option` is the looser
flag ("remote is one of the choices") that competitors report as `remote`, kept
apart so you decide which one you meant. `salary_period` says whether the figure
is yearly or hourly, `locations` keeps every city a posting was listed in, and
`posted_days_ago` saves you parsing the date.

***

### Where each platform is read from

Every platform is read through the same interface the employer's own careers
page calls. No HTML parsing, no headless browser, no job-board middleman — which
is why a posting closed an hour ago is already gone from the result. The row
shape does not change with the platform: Greenhouse jobs, Workday jobs and
Oracle jobs differ only in what `source` says.

| Platform | Also known as | Read from | Boards in the registry |
|---|---|---:|---:|
| Greenhouse | Greenhouse.io job board | `boards-api.greenhouse.io` | 295 |
| Ashby | AshbyHQ | `api.ashbyhq.com` | 245 |
| Personio | Personio Recruiting | `<slug>.jobs.personio.de` | 148 |
| Lever | Lever.co | `api.lever.co` | 70 |
| Recruitee | — | `<slug>.recruitee.com` | 68 |
| SmartRecruiters | — | `api.smartrecruiters.com` | 46 |
| Pinpoint | Pinpoint HQ | `<slug>.pinpointhq.com` | 44 |
| Workday | Workday CXS, wday cxs | `<co>.wd1.myworkdayjobs.com/wday/cxs` | 36 |
| BambooHR | — | `<slug>.bamboohr.com` | 27 |
| Breezy | Breezy HR | `<slug>.breezy.hr` | 16 |
| Rippling | — | `api.rippling.com` | 11 |
| Oracle Cloud | Oracle HCM Cloud, Oracle Fusion Recruiting, ORC | `<tenant>/hcmRestApi` `recruitingCEJobRequisitions` | 10 |
| JOIN | join.com | `join.com` | 10 |
| Workable | — | `apply.workable.com` | 6 |
| Eightfold | Eightfold AI talent platform | `<slug>.eightfold.ai` | 4 |
| HireHive | — | `<slug>.hirehive.com` | 4 |
| Comeet | — | `www.comeet.co` careers API | 2 |
| BrassRing | IBM Kenexa BrassRing, Kenexa Talent Suite | `sjobs.brassring.com` | 1 |
| UltiPro | UKG Pro Recruiting, UltiPro | `recruiting.ultipro.com` | 1 |
| Jobsoid | — | `<slug>.jobsoid.com` | 1 |

The board counts are what the shipped registry holds today, not what each
platform is capable of: any board of a supported ATS can be passed in by hand
whether or not it is in the registry.

***

### How it compares

| | This actor | `fantastic-jobs/career-site-job-listing-api` | `jobo.world/ats-jobs-api` |
|---|---|---|---|
| **Jobs per run** | **no cap** — 59,185 measured in one run | 5,000 hard cap | 100 per call, 10,000 via paging |
| **Data age** | live, fetched when you run it | 1 h delay, up to 3 h to ingest | indexed within 24 h |
| **Salary** | **taken from the source field** | inferred by an LLM (`ai_salary_min_value`) | from source |
| **Duplicate handling** | same posting across cities merged into one row, locations kept | — | — |
| **Only new jobs since last run** | **yes** — `onlyNew` | — | — |
| **schema.org JobPosting output** | **yes** — `outputFormat` | — | — |
| **Runs with no configuration** | **yes** — 1,045 verified boards bundled | you supply every company | you supply every company |
| **ATS platforms** | 20 | 54 | 150+ |

Competitor figures read from their public store listings on 2026-09-10.
**Where they win: raw platform count.** If you need an ATS this actor does not
support yet, they cover more. Everything else on this table, this actor does better.

***

### Output as schema.org JobPosting

Set `outputFormat` to `jsonld` and rows come out as schema.org `JobPosting`
objects — the format Google Jobs and most job-board engines read, so a board can
index this output without writing a mapper first.

```json
{
  "@context": "https://schema.org/", "@type": "JobPosting",
  "title": "Account Manager | Mid-Market",
  "hiringOrganization": { "@type": "Organization", "name": "Ramp" },
  "jobLocationType": "TELECOMMUTE",
  "baseSalary": { "@type": "MonetaryAmount", "currency": "USD",
    "value": { "@type": "QuantitativeValue",
               "minValue": 211400, "maxValue": 290600, "unitText": "YEAR" } }
}
```

Fields the employer never published are left out rather than emitted empty:
invalid markup is worse than no markup. A salary without a stated period keeps
its numbers and omits `unitText` instead of guessing "per year".

***

### Pay for new jobs, not for the same ones every morning

Turn on `onlyNew` and the actor remembers what it already returned. A daily
monitor over 40 boards then bills for the handful of openings that appeared
overnight instead of the same twenty thousand rows every day.

Measured over three consecutive runs on the same two boards:

```
run 1 → 221 jobs pushed
run 2 → 0 pushed, 221 recognised as already seen
run 3 → 0 pushed, 221 recognised as already seen
```

The memory key is `source:company:job_id`, so two ATS platforms that happen to
use the same internal id never cancel each other out. Neither competitor in the
table offers this.

The memory lives in a **named** key-value store, not the run's default one —
the default store is created fresh for every run on the platform, so state kept
there would silently vanish and every run would re-deliver the same postings.

A posting is remembered **only after it actually reaches the dataset**, and the
memory is checkpointed after every batch. So if a run hits its timeout or the
container moves, the next run picks up where it stopped: jobs already delivered
are not billed twice, and jobs that never made it are not silently marked as
delivered.

***

### You do not need to know the company's ATS

Every competitor here expects you to supply each company yourself — and for
Workday, Oracle Cloud and BrassRing that means digging a tenant address out of
a careers page.

This actor ships a registry of **1,045 company boards, each verified with a live
request** at build time, spanning all 20 platforms. Run it with an empty
input and it just works:

```
1,045 boards · 117,987 jobs at build time · all 20 platforms
```

| Company | ATS | Jobs when verified | Country |
|---|---|---:|---|
| `walgreens` | BrassRing | 22,346 | — |
| `BoschGroup` | SmartRecruiters | 4,834 | Hungary |
| `sgs` | SmartRecruiters | 4,468 | Spain |
| `micron` | Workday | 2,916 | Singapore |
| `spacex` | Greenhouse | 2,376 | — |
| `questdiagnostics` | Oracle Cloud | 2,085 | United States |
| `nvidia` | Workday | 2,000 | — |
| `jabil` | Workday | 2,000 | — |
| `cw` | Workday | 2,000 | India |
| `brookdale` | Oracle Cloud | 1,954 | United States |
| `salesforce` | Workday | 1,431 | — |
| `honeywell` | Oracle Cloud | 1,362 | United States |
| `maersk` | Workday | 1,353 | — |
| `speechify` | Greenhouse | 1,086 | United States |
| `emerson` | Oracle Cloud | 1,059 | India |
| `wppmedia` | Greenhouse | 1,032 | United Kingdom |
| `bmo` | Workday | 1,011 | United States |
| `deliveryhero` | SmartRecruiters | 979 | — |
| `marksandspencer` | Oracle Cloud | 884 | United Kingdom |
| `databricks` | Greenhouse | 873 | Japan |
| `continental` | SmartRecruiters | 861 | Germany |
| `ford` | Oracle Cloud | 838 | United States |
| `openai` | Ashby | 785 | Japan |
| `gopuff` | Lever | 778 | United States |
| `coupang` | Greenhouse | 697 | — |
| `chubb` | Oracle Cloud | 688 | United States |
| `rippling` | Rippling | 664 | Israel |
| `stripe` | Greenhouse | 618 | Israel |
| `transperfect` | Recruitee | 604 | United States |
| `anthropic` | Greenhouse | 599 | United Kingdom |

These are the 30 largest of **1039 boards**. The full list is a drop-down inside the Actor's input — *Or pick companies from the registry* — so you can browse every one of them before you run anything.

Measured, zero configuration, top 100 boards: **20,000 jobs in 215 s, 100 of
100 companies succeeded, 0 failures** — the run stopped at the cap, not at the
end of the registry.

Running it with an empty input is a **trial, not an invoice**: without companies
or a limit the actor takes the 25 largest boards and stops at 1,000 rows, so an
accidental click costs about a dollar rather than forty. Ask for the whole
registry explicitly with `useRegistry: true` and `maxItems: 0`.

Narrow it with `registryAts` (only Oracle, say), `registryCountry`,
`registryMinJobs` or `registryLimit` — or ignore the registry entirely and pass
your own companies.

Six entries in the registry are **vendor demo boards** carrying invented
postings — Jobsoid's demo lists a role called `rf3`, Breezy's lists
`Employee #12`. They prove the platform is readable, so they stay in the
registry, but they are kept out of the trial: a sample is a promise about data
quality, and fake rows break that promise.

The registry is not a frozen blob, and it does not pretend to be fresh forever.
Every run reports when it was built and how old it is; past 120 days the log
says so plainly, because a board that changed ATS should not be mistaken for a
company that stopped hiring.

`tools/build_registry.py` rebuilds it: `--check` re-verifies every board and
drops the ones that stopped answering, `--add names.txt` scans new companies,
`--countries` recomputes countries. Last full verification: **1045 of 1045 boards
answered, 0 dropped.**

Country came from the boards themselves, not from a guess: **413 of the 1,045**
publish one. `DE`, `Germany` and `Deutschland` all select the same 91 boards.
The remaining 632 are left out of a country-filtered run rather than assigned a
country they never stated.

```
United States 116 · Germany 91 · United Kingdom 53 · India 21 · France 21 · Spain 11 · Israel 11 · Canada 11 · Poland 8 · Ireland 7 · Netherlands 6
```

***

### Questions about specific platforms

#### How do I scrape a Workday careers site?

Give the actor the company slug and it brute-forces the tenant: Workday boards
live on `myworkdayjobs.com` behind a tenant and a site name, and the actor tries
the plausible combinations against the Workday CXS endpoint until one answers
with postings. That search is the reason you do not have to dig a tenant address
out of a careers-page link yourself.

#### Which Workday data centre is a board on — wd1, wd3 or wd5?

The prefix in the middle of a Workday address is the data centre the tenant was
provisioned in, and it differs per company: one board is at
`<co>.wd1.myworkdayjobs.com`, the next at `wd3.myworkdayjobs.com`, another at
`wd5.myworkdayjobs.com`. The detector walks wd1, wd2, wd3, wd5, wd10, wd12 and
wd103 against the cxs jobs endpoint and keeps the one that answers, so you never
have to know which myworkdayjobs wd1 or wd3 host a company happens to sit on.
The 36 Workday boards in the registry are spread across wd1, wd3, wd5, wd12,
wd103, wd108 and wd501, which is why guessing a single prefix does not work.

#### How do I scrape Oracle HCM Cloud, formerly Oracle Fusion Recruiting?

Oracle HCM jobs are served by the recruiting module of Oracle Fusion — Oracle
Recruiting Cloud, ORC for short — from each tenant's own Fusion host. The actor
reads the `recruitingCEJobRequisitions` resource of that tenant's `hcmRestApi`,
which needs no key: give it the host and the site number (almost always `CX_1`)
and it pages through the requisition list. Ten Oracle Cloud boards ship in the
registry as working examples.

#### How do I scrape an IBM Kenexa BrassRing board?

BrassRing boards sit on `sjobs.brassring.com` and are addressed by a partner id
and a site id, both visible in the careers-page link. Supply the pair and the
actor reads the board directly. One BrassRing board — Walgreens, with 22,346
postings when it was verified — is in the registry already as a working example.

#### How do I scrape UKG Pro Recruiting, formerly UltiPro?

UKG boards live on `recruiting.ultipro.com` and are addressed by a tenant code
such as `PEN1024PMCO`, which appears in the careers-page link. Give the actor
that code and the board GUID behind it is resolved automatically, so the only
thing you have to find is the part a human can actually see. UltiPro calls its
postings opportunities and links each one as an OpportunityDetail address; both
the search results and those links are read for you.

#### How do I scrape a Breezy HR board?

Breezy HR boards are at `<slug>.breezy.hr`, where the slug is the company's own
name in the URL of its careers page. Pass the slug and the board is read through
Breezy's public feed. The sixteen Breezy boards in the registry were verified
against small boards, so treat very large ones as unmeasured rather than proven.

***

### Why "no cap" matters

A single large employer can exceed a competitor's entire per-run limit.
Bosch alone returns 4,834 open roles; NVIDIA 2,000; Honeywell 1,362. A 5,000-job
ceiling means one big employer fills your entire run.

Measured, 80 registry boards, no limit set:

```
59,185 jobs · 35 s · 80 of 80 companies · 0 failures
```

That is nearly **12× the hard cap** of `fantastic-jobs`, in a single run that finished
in under a minute.

Verified on real Fortune 500 boards, not demo tenants: Honeywell, Emerson, Ford
and Chubb together return **3,947 live roles** through Oracle Cloud Recruiting.

Measured, one run, thirteen companies, no filters:

```
smartrecruiters  4821    eightfold    590    workable    17
workday          2000    brassring    514    recruitee   12
oracle           1357    rippling     347    ultipro      6
greenhouse        614    ashby        146    pinpoint     5
                          lever         75    jobsoid      4
                          join          48    breezy       3
                          personio      38    bamboohr     1
                                              hirehive     1
------------------------------------------------------------
10,599 jobs · 41.1 s · 19 platforms · 0 failed · 338 duplicates merged
```

***

### Field coverage — measured, not promised

Percentage of rows where the field is non-empty, from one live board per
platform, taken on 2026-09-10. A dash means **the employer did not publish it**,
not that the actor dropped it. Nothing here is guessed or filled by a model.

| Platform | Board | Jobs | title | url | location | country | dept | posted | type | description | salary |
|---|---|---|---|---|---|---|---|---|---|---|---|
| ashby | ramp | 146 | 100 | 100 | 100 | — | 100 | 100 | 100 | 100 | **96** |
| bamboohr | apify | 1 | 100 | 100 | 100 | — | 100 | 100 | 100 | 100 | — |
| brassring | walgreens | 60 | 100 | 100 | 73 | — | — | 100 | — | 100 | — |
| breezy | breezy | 3 | 100 | 100 | 100 | 100 | 100 | 100 | 100 | — | **100** |
| eightfold | bayer | 60 | 100 | 100 | 100 | 100 | 100 | 100 | — | 22 | — |
| greenhouse | stripe | 614 | 100 | 100 | 100 | — | 100 | 100 | — | 100 | — |
| hirehive | hirehive | 1 | 100 | 100 | 100 | 100 | — | 100 | 100 | 100 | — |
| jobsoid | demo | 4 | 100 | 100 | 100 | 100 | 50 | 100 | — | 75 | — |
| join | carhartt-wip | 48 | 100 | 100 | 100 | 100 | 100 | 100 | 100 | — | — |
| lever | matchgroup | 75 | 100 | 100 | 100 | 99 | 100 | 100 | 97 | 97 | **55** |
| oracle | honeywell | 60 | 100 | 100 | 100 | 100 | — | 100 | — | 77 | — |
| personio | urbansportsclub | 38 | 100 | 100 | 87 | — | 100 | 100 | 100 | — | — |
| pinpoint | easygenerator | 5 | 100 | 100 | 100 | — | 100 | — | 100 | 100 | — |
| recruitee | channable | 12 | 100 | 100 | 100 | 100 | 100 | 100 | 100 | 100 | **75** |
| rippling | rippling | 664 | 100 | 100 | 100 | — | 100 | 100 | 100 | 100 | — |
| smartrecruiters | BoschGroup | 200 | 100 | 100 | 100 | 100 | 100 | 100 | 100 | — | — |
| ultipro | penske | 6 | 100 | 100 | 100 | — | 100 | 100 | 67 | 100 | — |
| workable | rebellion | 38 | 100 | 100 | 100 | 100 | 100 | 100 | 100 | 100 | — |
| workday | nvidia | 60 | 100 | 100 | 100 | — | — | 100 | 100 | 100 | — |
| comeet | comm-it | 148 | 100 | 100 | 100 | 90 | 100 | 100 | 72 | 100 | — |

**Title and link are 100% on every platform** — those are the two fields a job
row is worthless without.

Salary is bold where the employer publishes it as a number. It is taken from
the source field, never inferred: `fantastic-jobs` fills this column with an
LLM guess (`ai_salary_min_value`), and `memo23` has no salary field at all.

### Remote means remote

Ashby's `isRemote` flag is set when *any* listed workplace option is remote —
at Ramp it was true for 135 of 146 jobs, while `workplaceType` said "Hybrid"
and the location said "New York, NY (HQ)". Passing that flag straight through
would make a "remote only" filter wrong 92% of the time.

This actor reads `workplaceType` instead. Ramp: **17 genuinely remote, 118 hybrid**.
The same rule is applied to Lever, Eightfold, Oracle and Rippling.

***

### Duplicates are merged, not dropped

Workable returns one posting once per city. Rebellion's 38 rows are 17 real jobs.
Rippling's 664 are 347. Rows sharing an id are merged into one, and every city is
kept in `locations` — you lose nothing and count nothing twice.

***

### Input

```json
{
  "companies": [
    { "slug": "stripe" },
    { "slug": "ramp" },
    { "ats": "workday", "slug": "nvidia", "wd": "wd5", "site": "nvidiaExternalCareerSite" }
  ],
  "keywords": ["engineer", "backend"],
  "remoteOnly": true,
  "postedWithinDays": 14,
  "salaryMin": 120000,
  "includeDescription": true
}
```

Leave `ats` out and the actor finds the platform itself — it probes the
platforms in parallel and remembers the answer for a week **in the key-value
store, not in the container**, so a scheduled run does not pay for the same
lookup every morning. Measured on three companies: 3.1 s the first time, 0.7 s
after.

`salaryMin` compares annual figures. An `$80/hour` posting is treated as
$166,400/year, not as "80".

***

### Speed — measured

| Company | Platform | Jobs | Time |
|---|---|---|---|
| Walgreens | BrassRing | 22,346 available | 25 s / 300 |
| Honeywell | Oracle Cloud | 1,362 | 7.3 s |
| Emerson | Oracle Cloud | 1,059 | 8.6 s |
| Ford | Oracle Cloud | 838 | 10.1 s |
| Chubb | Oracle Cloud | 688 | 5.4 s |
| stripe | Greenhouse | 618 | 0.8 s |
| BoschGroup | SmartRecruiters | 4,834 | 2.7 s |
| bayer | Eightfold | 588 | 4.8 s |
| nvidia | Workday | 2,000 | 17 s |
| Oracle tenant | Oracle Cloud | 2,220 | 18 s |
| rippling | Rippling | 664 | 19 s |

Workday caps at 20 records per request and Eightfold at 10, so those are paged
in parallel. Everything else arrives in one or two calls.

**Companies are fetched in parallel too**, not one after another — the same 30
boards took 444 s serially and **27 s in parallel, a 16.6× difference**. On
Apify you pay for compute time, so this is money, not just comfort. Each
platform is capped at 4 concurrent requests: a hundred Greenhouse boards are
one host, and hammering it would earn a 429 rather than more data.

***

### Rate limits are handled, and it is measured

On Apify your requests leave from addresses shared with every other user of the
platform, so an ATS can start returning 429 for reasons that have nothing to do
with you. Waiting out `Retry-After` in one thread is not enough — the other
threads keep hammering the same host and the failures avalanche.

This actor keeps the back-off **per host, visible to every thread at once**: one
429 slows the whole host down, the pause doubles on each further 429, respects
`Retry-After`, and decays again as requests start succeeding.

Measured against a local server that accepts 3 concurrent requests and rejects
the rest, hit by 20 threads:

| | Jobs retrieved | 429s received |
|---|---|---|
| **With the shared back-off** | **20 of 20** | 27–48 |
| Without it | 1–3 of 20 | 117–119 |

The test asserts the success rate, not the number of 429s. Across repeated
runs the success rate is steady — 20 of 20 every time, against 1 to 3 without
the back-off — while the rejection count swings with whatever else the machine
is doing. A threshold on the noisy number makes a suite flaky, not strict.

Hosts that asked us to slow down are listed in the run report, so a slow run is
explained rather than mysterious.

***

### Tested

`tests/test_logic.py` — 10 checks on the rules that were once wrong and got
fixed: remote semantics, salary periods, duplicate merging, the salary filter,
HTML entities, country normalisation.

`tests/test_live.py` — one real board per platform, no mocks. It checks volume,
that `title` and `url` are never empty, and that no duplicate ids survive
merging. **20 of 20 platforms green**, whole suite in about 30 s.

`tools/verify_readme.py` — checks this page **and the input schema** against the
code: every field read, every platform listed, no promise the code does not
keep. With `--live` it re-fetches every board in the coverage table to confirm
the numbers still hold. Twice during development a figure here drifted from reality and it
was luck that caught it, so checking became a script rather than a habit.

The image is built and run before every release, not just imported: the SDK's
own dependency tree drifted twice into a state where `import apify` raised
before a line of this code executed, so `requirements.txt` pins the two
transitive packages that broke and says why.

The suites run clean on Python 3.8 through 3.13, deprecation warnings treated
as errors — the actor image tracks a newer Python than this code was written
for, and a call removed upstream would fail in the cloud, not on a laptop.

`tests/run_all.py` runs everything at once; `--live` adds the suites that talk
to real employer endpoints, which are kept opt-in because hammering someone
else's API on every edit is not a neighbourly habit.

`tests/test_resume.py` — the whole actor lifecycle against a stand-in for the
Apify SDK: a run that dies halfway, the run that resumes it, and a third that
should bill nothing. It asserts the memory of delivered jobs matches what
actually reached the dataset — the check that caught postings being marked as
delivered before they were sent.

`tests/test_throttle.py` — a real HTTP server on localhost that rate-limits, hit
through the real client by 20 threads. Provoking 429s against someone else's
production API is exactly the behaviour this code exists to prevent, so the
server under test is ours.

***

### Memory, and why the default is plain text

Measured on a 15,000-job run over 60 boards with descriptions on:

| Description format | Peak memory |
|---|---|
| `text` (default) | **688 MB** |
| `both` | 1,095 MB |

The two description fields carry nearly the same content and together account
for **98% of a row** — everything else, all 25 remaining fields, is the other 2%.
Keeping one of them is the difference between fitting in a 1 GB actor and being
killed by the OOM reaper mid-run.

Pushes to the dataset are split by **payload size**, not row count: a posting
with a description weighs about 6 KB against 300 bytes without one, so a fixed
row count would either waste requests or overshoot the platform's ~9 MB limit.
Measured on Stripe's 613 postings with both description fields: two pushes of
4.00 MB and 2.82 MB.

Results are also pushed to the dataset **batch by batch**, not all at the end,
so memory no longer grows with the size of the run — and the first jobs land in
the dataset while later companies are still being fetched. The same 15,000-job
run peaks at **494 MB** streamed, against 688 MB when accumulated.

**Suggested memory: 1 GB**, and that is not a guess — the heaviest case the
actor supports was run inside a container capped at exactly 1 GB: 40 registry
boards, `descriptionFormat: both`, 15,000 rows delivered, exit code 0, no OOM.
Without descriptions, 30 boards and 15,000 jobs peak at 368 MB.

***

### A broken company does not break the run

Rows that arrive with neither a title nor a link are dropped rather than
delivered — Workday occasionally returns a record holding nothing but an id, and
on a pay-per-result plan you would otherwise be charged for it. They are counted
in the report as `dropped_empty_rows`, because silently discarding them would
hide a broken adapter.

One unreachable company is reported and skipped; the rest still return. A page
that dies halfway through a company's pagination keeps the jobs already
collected instead of throwing them away — and says so, rather than passing a
partial result off as a complete one.

The run report (`REPORT` in the key-value store) answers both questions a
scraper usually leaves open — what was thrown away, and whether what remains
can be trusted:

```json
{
  "ok": [ { "company": "ramp", "ats": "ashby", "jobs": 146, "seconds": 0.5 } ],
  "failed": [],
  "filtered_out": { "keywords": 46, "remote": 84, "salary": 0 },
  "merged_same_id": 338,
  "failed_requests": { "host.example.com": { "count": 1, "last": "HTTP 404" } },
  "throttled_hosts": { "api.example.com": 2.0 },
  "result": {
    "jobs": 983, "with_salary": 182, "with_date": 983,
    "remote": 200, "hybrid": 217, "has_remote_option": 240,
    "coverage_percent_by_platform": {
      "ashby": { "jobs": 146, "fields": { "salary_min": 97, "posted_at": 100 } }
    }
  }
}
```

Coverage is reported per platform on purpose: an empty salary column is normal
for Workday and would mean a broken adapter for Ashby.

***

### Getting the data into your own system

A run that ends on the Apify run page is half a job. Here is every route out,
in the order people actually use them.

#### Straight download

The run page offers the dataset as JSON, CSV, XML and Excel. For a one-off
list — a market map, a sourcing spreadsheet — this is the whole workflow: press
Start, wait, press Export.

#### The API, for anything repeatable

Every run writes to a dataset that the API serves:

```
https://api.apify.com/v2/datasets/<datasetId>/items?format=csv&clean=true
```

If you always want the newest run of this Actor, you do not need to track ids
at all — this address always answers with the last finished run:

```
https://api.apify.com/v2/acts/<actorId>/runs/last/dataset/items?format=json
```

Add your token as a header or as `?token=`. `clean=true` drops rows the run
marked empty, and `format` also accepts `csv`, `xlsx` and `xml`.

#### No-code pipelines

Apify ships a maintained connector for the tools most buyers already run.
[Make](https://docs.apify.com/platform/integrations/make),
[n8n](https://docs.apify.com/platform/integrations/n8n),
[Zapier](https://docs.apify.com/platform/integrations/zapier) and
[Slack](https://docs.apify.com/platform/integrations/slack) each have one, and
the [webhook integration](https://docs.apify.com/platform/integrations/webhooks)
covers everything else: Apify calls your URL when the run succeeds and hands you
the run id and the dataset id in the payload.

In Make and n8n the pattern is always two blocks — run the Actor, then read its
dataset — so the scenario stays the same whether it fires on a schedule or from
a form someone filled in.

#### On a schedule, paying only for what is new

Schedules live in the Apify console and take a cron expression. Point one at
this Actor with **Only jobs not seen before** switched on and you get a daily
delta rather than the same board re-delivered: the Actor remembers delivered job
ids in a named key-value store that survives between runs, and bills only for
rows it hands over.

Name the storage if you intend to keep it. Unnamed storages on Apify are
temporary by design; a named one stays put, which is exactly what a delta
depends on.

#### Three things people build with this

**A sourcing feed.** Pick the ATS platforms you care about, set keywords, run it
daily with the delta switch on, and push new rows into a Slack channel or an
applicant sheet. The row already carries the apply URL, so nobody has to open
the careers page to act on it.

**A market map.** Leave the keywords empty, take the whole registry, and count
openings per company, per country and per platform. Because salary comes from
the employer's own field rather than from a model's guess, the pay columns are
safe to average.

**A job board.** Every row is ready to republish: title, full description in
text or HTML, location, salary range, employment type and the direct apply link.
The schema.org JobPosting output mode exists for exactly this, so a listing page
can be indexed by Google for Jobs without a transformation step.

***

#### A run succeeded but the dataset is empty — is that a failure?

Not necessarily, and the Actor always says which case it is. The run report
carries `empty_reason` and the log carries a warning naming it:
`nothing-changed` (the incremental mode working as asked — everything had
already been delivered by an earlier run, so nothing was written and nothing
was charged), `filtered-out` (your filters removed everything that was
collected), `source-returned-nothing` (the search matched nothing at all), or
`charge-limit-reached` (the run's spending limit was already used up). A silent
empty dataset is treated as a defect, not as a normal outcome.

### Known limits

- **20 platforms**, against 54 and 150+ for the two competitors above. If your
  target sits on iCIMS, Taleo, Jobvite or Phenom, this actor cannot read it yet.
- **Workday, Oracle Cloud, BrassRing and UKG need their tenant identifiers.** BrassRing takes
  `partner_id` and `site_id`, both visible in the careers-page link; UKG takes a `tenant`
  code such as `PEN1024PMCO` — its board GUID is then found automatically. Comeet takes
  a `uid` such as `76.008` from its board URL; its token is read off the public board.
- **Comeet publishes no posting date**, only the time a posting was last changed,
  which is what `posted_at` carries there.
- **Workday and Oracle Cloud need their tenant address.** Workday is brute-forced
  automatically; Oracle's host cannot be guessed and must be supplied.
- **BambooHR, Breezy, HireHive, Pinpoint and Jobsoid were verified against small boards only**
  (1-22 jobs). Their endpoint shape is confirmed; large-board behaviour is not yet measured.
  The endpoint shape is confirmed; large-board behaviour is not yet measured.
- **Personio publishes no description or country** in its public feed.
- **Some Eightfold tenants block programmatic access** (Amgen and Eaton return 403
  regardless of headers). Bayer and others work; coverage there is partial.
- Salary appears only where the employer published it. Roughly half of US
  postings carry none, and nothing here fills that gap by inference.

### Where the jobs this Actor cannot see live

This Actor reads company career pages, so a job exists here only if the employer
runs one of the 20 supported systems. Two kinds of vacancy never will: those from
employers on an unsupported ATS, and those from markets where small employers
post to a national board instead of running a careers page at all. Three sibling
Actors cover the second case:

- [Jobs.ch Scraper](https://apify.com/snow_leo_data/jobs-ch-scraper-swiss-switzerland-jobs)
  — Switzerland, where most SMEs advertise on the board rather than on an ATS.
- [SEEK, JobStreet & JobsDB Scraper](https://apify.com/snow_leo_data/seek-jobstreet-jobsdb-australia-jobs-scraper)
  — Australia, New Zealand and six Asian markets.
- [The Muse Jobs Scraper](https://apify.com/snow_leo_data/the-muse-remote-company-jobs-scraper)
  — one curated board across employers, with the advert text inline.

Every one of the four stamps each row with a `source` field naming the board or
ATS it came from, so datasets from several of them can be concatenated and still
be told apart afterwards. The column sets themselves differ, because each source
publishes different things.

# Actor input Schema

## `registryPick` (type: `array`):

Pick from 1039 company boards we verified with a live request — no need to know who uses which ATS. Workday, Oracle, BrassRing and UKG work straight from here: the tenant address comes with the pick.

Leave empty to let the Actor take boards from the registry by itself. For a company that is not on this list, use *Other companies* under Advanced — any employer on the 20 supported platforms works.

## `registryAts` (type: `array`):

Leave empty for all 20. The count after each name is how many verified boards the registry holds for that platform.

## `useRegistry` (type: `boolean`):

Pull from a bundled list of company boards, each verified with a live request. You do not need to know the company's ATS, its slug, or its tenant address. Leaving the companies field empty uses the registry too, but treats the run as a trial: the 25 largest boards and at most 1000 rows, so an accidental run costs about a dollar rather than forty. Set this to true to lift that.

## `registryLimit` (type: `integer`):

Take only the N largest boards from the registry. 0 means all of them, unless the run named no companies at all — then 25 are used, because handing an unattended first run every board on file is a bill, not a demo.

## `registryCountry` (type: `array`):

Country was read from the boards themselves, not guessed. Boards whose country could not be established are excluded when you pick anything here.

## `registryMinJobs` (type: `integer`):

Ignore registry boards that had fewer than this many jobs when the registry was built.

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

Keep only jobs whose title or description contains any of these words. Empty = keep all.

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

Drop jobs whose title contains any of these words.

## `location` (type: `string`):

Substring match on the job location, e.g. "Berlin" or "Remote". Empty = any location.

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

Keep only jobs whose location or title indicates remote work.

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

Keep only jobs posted in the last N days. 0 = no date filter.

## `salaryMin` (type: `integer`):

Keep only jobs paying at least this much per year. Hourly, daily, weekly and monthly rates are converted to a yearly figure first, so $80/hour counts as $166,400. Jobs where the employer published no salary are excluded — filtering by pay and receiving jobs with no pay listed is not useful.

## `employmentType` (type: `string`):

Matched as a substring against what the employer published, so "Full-time" and "Full time" both count.

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

Fetch full job descriptions. Slower and larger output.

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

The two description fields hold nearly the same content and together make up 98% of a row. Keeping only one halves memory and dataset size. Plain text is the default because it is what search and LLM pipelines consume; pick HTML if you render the posting.

## `outputFormat` (type: `string`):

Flat rows are the 28-field table described in the README. JSON-LD emits schema.org JobPosting objects instead — the format Google Jobs and most job-board engines read, so a board can index this output without writing a mapper. Fields the employer did not publish are left out rather than invented, because invalid markup is worse than none.

## `deduplicate` (type: `string`):

Extra merging of jobs that merely look alike. Exact duplicates (same job id) are always merged automatically, with their locations combined — you do not need this for that. Use 'By company + title' only if you also want to collapse distinct postings that share a title.

## `onlyNew` (type: `boolean`):

Remembers which postings this actor already returned and skips them next time. On a pay-per-result plan a daily monitor then pays for the handful of new openings instead of the same twenty thousand every morning. State is kept in the actor's key-value store.

## `maxItems` (type: `integer`):

Stop after this many jobs in total across all companies, applied after filters. 0 means no limit — with one exception: a run that names no companies and no limit is treated as a trial and stops at 1000 rows. Combine maxItems=0 with useRegistry=true to pull the whole registry.

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

For an employer that is not in the list above. A plain name is enough — `stripe`, `ramp`, `notion` — the ATS is detected automatically. Any company on the 20 supported platforms works, not only the ones we ship.

Works with 20 ATS platforms: Greenhouse, Ashby, Lever, Workday, SmartRecruiters, Oracle Cloud, BrassRing, UKG, Rippling, Recruitee, Personio, BambooHR, Breezy HR, Comeet, JOIN, Workable, Pinpoint, Eightfold, Jobsoid, HireHive.

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

For platforms that address each employer by a tenant id. Everything you need is visible in the company's own careers link:

- Workday `{"ats":"workday","slug":"nvidia","wd":"wd5","site":"nvidiaExternalCareerSite"}`
- Oracle `{"ats":"oracle","slug":"ibm","host":"xxx.fa.us2.oraclecloud.com","site":"CX_1"}`
- BrassRing `{"ats":"brassring","slug":"acme","partner_id":"26336","site_id":"5014"}`
- UKG `{"ats":"ultipro","slug":"acme","tenant":"PEN1024PMCO"}`
- Comeet `{"ats":"comeet","slug":"acme","uid":"76.008"}`

Entries here are added to the Companies list above.

## `companiesPerBatch` (type: `integer`):

Results are pushed to the dataset batch by batch instead of all at the end, so memory does not grow with the size of the run and first results appear early. Lower this if you run very large boards on a small actor.

## Actor input object example

```json
{
  "registryPick": [
    "greenhouse:stripe",
    "ashby:ramp",
    "ashby:notion"
  ],
  "registryAts": [],
  "useRegistry": false,
  "registryCountry": [],
  "keywords": [],
  "excludeKeywords": [],
  "location": "",
  "remoteOnly": false,
  "postedWithinDays": 0,
  "employmentType": "",
  "includeDescription": true,
  "descriptionFormat": "text",
  "outputFormat": "flat",
  "deduplicate": "none",
  "onlyNew": false,
  "maxItems": 0,
  "companies": [],
  "boards": [],
  "companiesPerBatch": 20
}
```

# Actor output Schema

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

Every job posting collected in this run. Open the Jobs view for a readable table, or the salary and remote views to narrow it down.

## `report` (type: `string`):

Which ATS platforms answered and how many jobs each returned, which boards failed and why, how many rows the filters removed, how many duplicates were merged, and how many empty rows were dropped before you were charged for them.

# 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 = {
    "registryPick": [
        "greenhouse:stripe",
        "ashby:ramp",
        "ashby:notion"
    ],
    "registryLimit": 0,
    "registryMinJobs": 0,
    "salaryMin": 0,
    "companiesPerBatch": 20
};

// Run the Actor and wait for it to finish
const run = await client.actor("snow_leo_data/greenhouse-workday-lever-ashby-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 = {
    "registryPick": [
        "greenhouse:stripe",
        "ashby:ramp",
        "ashby:notion",
    ],
    "registryLimit": 0,
    "registryMinJobs": 0,
    "salaryMin": 0,
    "companiesPerBatch": 20,
}

# Run the Actor and wait for it to finish
run = client.actor("snow_leo_data/greenhouse-workday-lever-ashby-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 '{
  "registryPick": [
    "greenhouse:stripe",
    "ashby:ramp",
    "ashby:notion"
  ],
  "registryLimit": 0,
  "registryMinJobs": 0,
  "salaryMin": 0,
  "companiesPerBatch": 20
}' |
apify call snow_leo_data/greenhouse-workday-lever-ashby-ats-jobs-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,snow_leo_data/greenhouse-workday-lever-ashby-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/mBG4qvZb4cECmeZGg/builds/DGxMeAS6u9tk0QHxb/openapi.json
