# Job Scraper for Greenhouse, Lever, Ashby, Workday & 10 More ATS (`firstpartydata/ats-job-scraper`) Actor

Job postings from company career sites via the public APIs of 13 applicant tracking systems: Greenhouse, Lever, Ashby, Workday, BambooHR and more. Input career-page URLs, plain domains like stripe.com, or search 23,737 verified live boards. Returns title, location, remote, salary, apply URL.

- **URL**: https://apify.com/firstpartydata/ats-job-scraper.md
- **Developed by:** [First Party Data](https://apify.com/firstpartydata) (community)
- **Categories:** Jobs, Lead generation, Automation
- **Stats:** 1 total users, 0 monthly users, 100.0% runs succeeded, 2 bookmarks
- **User rating**: 5.00 out of 5 stars

## Pricing

from $2.10 / 1,000 job posting returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

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

## Job Scraper for Greenhouse, Lever, Ashby, Workday & 10 More ATS

Extracts job postings from company career sites (who is hiring, and for what)
through the public APIs of **13 applicant tracking systems** including
Greenhouse, Lever, Ashby, Workday, Workable, Personio, BambooHR and Teamtailor.
Input: career-page URLs, company domains like `stripe.com`, or a built-in list of
live boards. Each posting returns one normalised record: title, company,
location, remote status, department, employment type, salary where published,
description, apply URL and posted date.

Filters are applied before anything is delivered: keyword, job title
include/exclude, location, remote-only, published-pay-only, and
posted-within-N-days. You are charged for the rows you asked for rather than
for everything. They run inside the Actor, not at the vendor: the boards are
fetched in full and filtered here. Only Workday accepts a query at the source,
through `searchText` and `jobCategory`. Those two are not on this form: they are
inputs of the Workday Actor, and this one accepts them only from an API or MCP
caller.

Full list: Greenhouse, Lever, Ashby, Workable, Recruitee, SmartRecruiters,
Workday, Teamtailor, Breezy, Personio, BambooHR, Pinpoint and Rippling.

***

### Why this rather than a job-board scraper

These are the ATS vendors' **own public syndication endpoints**: the ones that
exist so a company's jobs can be republished. No login, no proxy, no headless
browser, and no page parsing to break. Three consequences you can measure:

- **It is fast, and it scales sideways.** Whole shipped corpus, all 13 platforms,
  descriptions off: **187,887 jobs from 5,475 boards in under five minutes**.
  Every board is accounted for: 5,475 answered, 3 had closed and were pruned from
  the corpus, 17 were unreachable on the day. An earlier run of the same scope on
  Apify's own infrastructure took 5m17s and peaked at 356 MB. Asking for fewer
  boards does not take proportionally less time: boards are read concurrently, so
  the wall clock is set by the single slowest board rather than by how many you
  request. 60 boards and 34,671 jobs came back in 11 seconds.
- **It does not rot.** A careers page redesign changes nothing, because nothing
  here parses a careers page.
- **It is cheap to run.** No proxy bill and no browser, so the cost is in the
  data rather than in the infrastructure needed to get at it.

That is not the same as "unlimited". These are public endpoints, not an
entitlement: Workable and Personio throttle early, and Greenhouse sits behind a
CloudFront WAF that will start returning `403` to a client that behaves like a
crawler. Building the company corpus for this Actor tripped exactly that. Normal
runs of hundreds or low thousands of boards are nowhere near it, per-vendor
concurrency is capped, and `RUN_SUMMARY` reports throttled and forbidden boards
separately from missing ones so you can tell the difference.

You also get the posting **as the employer published it**, not a copy an
aggregator re-hosted days ago. The apply link points at the company's own
application form.

***

### Coverage

Every board shipped was **verified live** against its vendor's API at build time:
it answered with at least one open posting. Dead and private boards are dropped
rather than shipped as 404s.

| | |
|---|---|
| Live job boards | **23,737** |
| Open postings across them | **1,314,557** |
| Last rebuilt | 2026-09-15 |

| ATS | Boards | Postings |   | ATS | Boards | Postings |
|---|---:|---:|---|---|---:|---:|
| Workday | 3,103 | 718,931 | | Recruitee | 1,441 | 21,495 |
| SmartRecruiters | 896 | 215,807 | | Personio | 2,355 | 20,662 |
| Greenhouse | 5,010 | 175,335 | | Pinpoint | 260 | 3,049 |
| Ashby | 3,213 | 54,822 | | Breezy | 167 | 1,841 |
| BambooHR | 5,158 | 37,360 | | Rippling | 95 | 1,538 |
| Lever | 1,624 | 34,927 | | Teamtailor | 14 | 230 |
| Workable | 401 | 28,560 | |  |  |  |

**A sample goes a long way**: boards are ordered by size, so
`100` reaches 26%, `250` reaches 40%, `500` reaches 53%, `1000` reaches 65%
of all bundled postings.

**The 300 largest employers in the bundled list are printed in full at the
bottom of this page**: Ctrl+F for a company before you pay for anything.

#### What each platform actually gives you

Not all thirteen return the same fields. This is the honest matrix, worth
skimming before you set `atsPlatforms`, because it decides what you can filter on.

| ATS | Descriptions | Structured pay | Posted date | Country | Work arrangement | Notes |
|---|:--:|:--:|:--:|:--:|:--:|---|
| **Greenhouse** | yes | rare | yes | inferred | **no** | the largest board population; names the company on every posting |
| **Lever** | yes | often | yes | yes | yes | reliable `workplaceType` |
| **Ashby** | yes | often | yes | yes | yes | densest pay data; see the compensation note below |
| **Workable** | yes | parsed | yes | yes | rare | behind Cloudflare; rate-limited, capped low |
| **Recruitee** | yes | often | yes | yes | yes | remote/hybrid/on-site flags, and the same `isRemote` trap as Ashby |
| **Teamtailor** | yes | parsed | yes | yes | **no** | embeds a schema.org JobPosting |
| **Pinpoint** | yes | **yes** | no | inferred | yes | explicit "is the range public" flag |
| **Personio** | **no** | parsed | no | **no** | **no** | rate-limits; capped low. `description` came back empty on every posting of all three boards checked, so `keywords` matches titles only here |
| **SmartRecruiters** | via detail | parsed | yes | yes | rare | four description sections, plus the requisition number |
| **BambooHR** | via detail | parsed | via detail | inferred | **no** | the list carries neither; the detail endpoint carries both |
| **Rippling** | via detail | parsed | via detail | inferred | **no** | publishes the company's legal name on the posting |
| **Workday** | via detail | parsed | exact, via detail | yes | rare | see below; paged 20 at a time and capped at 2,000 per query |
| **Breezy** | **no** | rare | yes | yes | **no** | list endpoint only; no public detail endpoint exists |

"parsed" means pay is read out of the description text when it is stated there
unambiguously, rather than coming from a structured field.

**"inferred" in the Country column** means the board publishes no country field
and it is read off the display string instead. "Hawthorne, CA" and "Seoul, South
Korea" are unambiguous, so they resolve; "London" and "Tbilisi, Georgia" are not,
so they stay `null` rather than being guessed. `countryInferred: true` marks every
row where it happened, so you can exclude them if you would rather only have the
vendor's own word.

**How much of a board resolves depends on the board, not on the platform**, and
the spread is wide enough to be worth saying: across two samples, most Greenhouse
boards resolve 94-100%, but EōS Fitness, with 2,701 postings, writes its locations as
internal store codes (`FLD2P - Davenport: US Hwy 27 / Posner Blvd`) and resolves
3%. One board that size moves the whole platform's figure, so treat any single
number for "Greenhouse coverage" with suspicion, including one of ours. Filter on
`countryCode` and check `countryInferred` rather than assuming a rate.

**A "no" in the last two columns means that filter returns nothing from that
platform**: nothing gives Greenhouse a work-arrangement field, for instance. The
run log warns you before the run rather than silently returning less.

**"via detail" means one extra request per posting**, made only when
`includeDescriptions` is on and only for postings that already survived the title
and location filters. Four platforms publish almost nothing in their listing
endpoint and a great deal in a second, equally public one. SmartRecruiters gives
four description sections, BambooHR gives a description *and* the posting date it
otherwise omits entirely, Rippling gives a 20,000-character description that is
easy to miss because it arrives as an object rather than a string. Breezy is the
only one of the thirteen with no second endpoint at all. Personio has one and
leaves the description empty, which comes to the same thing for a keyword filter.

**Workday is worth two notes.** First, its listing endpoint returns no description
and only a relative date ("Posted 3 Days Ago"), which is all most Workday
scrapers give you.
This one follows each posting to Workday's own detail endpoint and gets the full
description, an **exact** `postedAt`, the employment type, Workday's remote flag
and the canonical apply URL. That costs one extra request per posting, so it runs
only when `includeDescriptions` is on, and only for postings that already survived
the title and location filters.

Second, **Workday will not return more than 2,000 postings for any one query**,
and does not say so. `total` is silently clamped and paging past it returns
duplicates. Where a board is bigger than that, this Actor detects the clamp from
the category counts and splits the board into per-category queries to reach the
rest. Across the four largest Workday employers in the corpus that recovers
10,502 postings where naive paging finds 8,000, measured 14 September 2026.
Those boards move daily, so the figure moves with them.

***

### What you get back

| Field | Notes |
|---|---|
| `title`, `company`, `companyToken` | `companyToken` is the exact board identifier |
| `department`, `departments[]`, `team` | as published by the board |
| `locationRaw`, `locations[]` | multi-location postings are split into a list |
| `city`, `region`, `country`, `countryCode` | structured where the vendor publishes it; country is otherwise read off the location string |
| `countryInferred` | `true` when the country came from the location string rather than a vendor field |
| `isRemote`, `workplaceType` | `null` when the board did not say, never a guess |
| `employmentType` | one of `FULL_TIME` `PART_TIME` `CONTRACT` `INTERNSHIP` `TEMPORARY` `VOLUNTEER`, or `null` |
| `employmentTypeRaw`, `workplaceTypeRaw` | what the board actually said, kept beside the normalised value |
| `seniority` | where published, title-cased so it groups. Only four of the thirteen platforms have this field, and it is empty on 99.5% of rows |
| `seniorityLevel`, `seniorityInferred` | one comparable level across every platform: `INTERN` `JUNIOR` `MID` `SENIOR` `MANAGER` `PRINCIPAL` `DIRECTOR` `EXECUTIVE`, or `null`. Taken from the employer's own field where they publish one, and read off the job title otherwise, in which case `seniorityInferred` is `true`. There is deliberately no filter on it: a guess behind a paid filter delivers and charges for rows you did not ask for |
| `compensation`, `offersEquity` | `{min, max, currency, interval, raw}`; equity is a separate flag |
| `compensationText` | the same pay as one string, for a spreadsheet column: `150,000 to 200,000 USD per year`, or the employer's own words when they published no numbers |
| `descriptionHtml`, `descriptionText` | both. The text is de-tagged and unescaped, and the HTML has scripts, iframes, event handlers and `javascript:` links removed. Escape or sanitise on your side before rendering either: this is a blunt strip, not a parser |
| `applyUrl`, `jobUrl`, `boardUrl` | apply links point at the employer |
| `postedAt`, `updatedAt`, `scrapedAt` | ISO 8601, UTC |
| `id`, `ats`, `atsLabel`, `requisitionId` | `id` is stable across runs |

**On `employmentType`:** boards let employers type this field freely, and across
the shipped corpus it held **186 distinct strings** in four languages and four
casings: `Full-Time`, `Full-time`, `FullTime`, `Full Time`, `A tiempo completo`.
Binance uses it for `Binance Accelerator Program`. So the field is grouped into
six values you can actually filter on: measured over a full-corpus run, of the
57,304 rows that carried the field at all **97.8% classify**, and the rest are
`null` because they were never employment types ("Homeoffice", "External",
"Sales", "Binance Accelerator Program") or are a pay or headcount classification
with no hours in them ("Exempt", "Regular", "Employee"), both more honest as
null. `employmentTypeRaw` always holds the original.

The rules read Spanish, Portuguese, German, French, Japanese and Chinese forms as
well as English, because whoever posted the job filled the field in: `CLT` is
Brazil's permanent-employment regime, `en planilla` is Latin American for
on-payroll, `Jornada Completa` and `フルタイム` both mean full-time.

The six values are schema.org's, which **mixes two axes**: `FULL_TIME` and
`PART_TIME` are hours; `CONTRACT`, `TEMPORARY`, `INTERNSHIP` and `VOLUNTEER` are
tenure. A string can say both, and **tenure wins**: Recruitee's
`fulltime_fixed_term` and "Full-Time Temporary" answer `CONTRACT` and `TEMPORARY`,
not `FULL_TIME`, because someone filtering for a staff job should not be handed a
six-month contract. The raw string tells you the rest.

`id` is `ats:company:sourceId` and does not change between runs, so an
incremental pipeline can dedupe on it directly.

***

### Four things this gets right that are easy to get wrong

**Ashby's `isRemote` field does not mean remote.** On OpenAI's board it is `true`
for all **492 Hybrid** roles as well as the 30 genuinely remote ones. It really
means "not strictly on-site". Read it literally and you report 66% of that board
as remote when the truth is 4%. This Actor treats `workplaceType` as
authoritative. Recruitee has the same trap and gets the same treatment.

**Remote status is never inferred from the job description.** Engineering posts
say "distributed systems" and "work from anywhere" constantly, and neither means
the role is remote. Only the location, the title and the vendor's own workplace
field are trusted. Where a board says nothing, `isRemote` is `null` rather than
`false`, so `remoteOnly` never invents matches and never quietly buries the
unknowns.

**Compensation is read as a list, not as a first element.** Ashby attaches
several components to a posting (salary, equity, sometimes a bonus) in no fixed
order. On one board the salary is first; on another the equity line is, and an
equity line carries no numbers. Taking `summaryComponents[0]` therefore reports
"no salary" for boards that publish one: across 19,267 postings from Ashby's 300
largest boards that loses **30.0%** of all the pay data on offer. How much it
loses depends heavily on which boards you read. On a thinner spread, every 90th
board across the whole bundled list, 630 postings, it loses 45%. The shortfall is
real on every sample measured and its size is not predictable. This Actor picks
the component that actually is a pay range, ignores one-time signing bonuses, and
exposes `offersEquity` separately.

**Salary is only read from prose when it is unambiguous.** A range has to be
currency-marked and plausible, so "attacking a $100B market opportunity" does not
become a salary. Ashby, Lever, Recruitee and Pinpoint publish structured pay, and
that is always preferred over parsing. Pinpoint even flags whether the range is
meant to be public, and a range marked private is not emitted.

***

### Input

Career-page URLs in any of these shapes, with the right ATS detected for you:

```
https://job-boards.greenhouse.io/stripe
https://boards.greenhouse.io/embed/job_board?for=airbnb
https://jobs.lever.co/palantir
https://jobs.ashbyhq.com/openai
https://apply.workable.com/modash
https://crazygames.recruitee.com
https://jobs.smartrecruiters.com/BoschGroup
https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite
```

**Or just name the company.** `stripe.com`, `https://stripe.com/careers` and
`stripe` all work: the domain is reduced to a board identifier and tried against
every platform, so you never need to know which ATS a company uses. A miss costs
one cheap 404.

Or set **Search the built-in company list** and skip URLs entirely.

***

### Running it as a feed: only pay for what changed

Set **`onlyNewOrChanged`** and the Actor remembers what it returned last time. On
the next run it returns only postings that are new, or whose title, location,
pay, work arrangement or description has moved, and **skips the rest without
billing you**. Each row gains `changeType: "new" | "changed"`.

Measured on two consecutive runs over Stripe and OpenAI:

```
run 1   1410 new,   0 changed,      0 unchanged   ->  1410 rows billed
run 2      0 new,   0 changed,   1410 unchanged   ->     0 rows billed
```

That is the difference between a daily feed costing you a full scan every day and
costing you a full scan once. Postings that were in your last run's results and
are not in this one are listed in the `REMOVED_SINCE_LAST_RUN` key-value record,
so you can close them out of your own index. Read it as exactly that: usually the
employer took the posting down, but a posting that ages past `postedWithinDays`,
or that is retitled out of a keyword match, leaves your feed the same way. A board
that could not be read this run is excluded from it, so a vendor outage does not
report a whole company as having closed.

History is namespaced by your query, so two differently-filtered feeds never
contaminate each other; set `stateKey` explicitly only if you want two runs to
deliberately share one. If a run is cut short by `maxItems` it merges rather than
replaces its history and reports no removals, because it did not see every board.

***

### Recipes

**A niche job board: remote AI roles, refreshed daily**

```json
{
  "useBuiltInCorpus": true,
  "corpusSample": 0,
  "keywords": ["machine learning", "llm", "artificial intelligence"],
  "remoteOnly": true,
  "onlyNewOrChanged": true,
  "maxItems": 5000
}
```

Put it on a daily schedule. The first run establishes the baseline; every run
after it returns, and bills, only the movement. Dedupe on `id` downstream.

**Buying signals: who is hiring for a skill you sell into**

```json
{
  "useBuiltInCorpus": true,
  "corpusSample": 1000,
  "keywords": ["snowflake", "dbt"],
  "includeDescriptions": true,
  "maxItems": 1000
}
```

A company posting its first data-engineering role is a buying signal with a date
on it. Descriptions have to stay **on** here: a named tool almost never appears in
the job title, and `keywords` can only search text it was given. If you are
looking for a *role type* rather than a tool, use `titleInclude` instead and turn
descriptions off. That is much cheaper.

**Competitive and market intelligence**

```json
{
  "companies": ["stripe.com", "adyen.com", "checkout.com", "mollie.com"],
  "includeDescriptions": false,
  "maxItems": 5000
}
```

Headcount plans are public if you read job boards. Run it weekly and diff.

**Salary benchmarking**

```json
{
  "useBuiltInCorpus": true,
  "corpusSample": 1000,
  "atsPlatforms": ["ashby", "lever", "recruitee", "pinpoint"],
  "titleInclude": ["engineer"],
  "withSalaryOnly": true
}
```

Those four publish structured compensation, so filtering to them gives by far the
densest pay data. Pinpoint is the strongest of them.

**One company, everything**

```json
{ "companies": ["stripe.com"] }
```

***

### Output

```json
{
  "id": "ashby:openai:8fb1615c-34bf-47c4-a1d1-b7b2f836bbd3",
  "title": "Technical Program Manager, Compute Infrastructure",
  "company": "OpenAI",
  "companyToken": "openai",
  "ats": "ashby",
  "atsLabel": "Ashby",
  "department": "Technical Program Management",
  "locationRaw": "San Francisco",
  "locations": ["San Francisco"],
  "city": "San Francisco",
  "region": "California",
  "country": "United States",
  "isRemote": false,
  "workplaceType": "Hybrid",
  "employmentType": "FULL_TIME",
  "employmentTypeRaw": "FullTime",
  "compensation": { "min": 257000, "max": 335000, "currency": "USD", "interval": "YEARLY", "raw": null },
  "applyUrl": "https://jobs.ashbyhq.com/openai/8fb1615c-.../application",
  "jobUrl": "https://jobs.ashbyhq.com/openai/8fb1615c-...",
  "boardUrl": "https://jobs.ashbyhq.com/openai",
  "postedAt": "2026-03-12T16:38:15.322Z",
  "scrapedAt": "2026-09-11T04:41:03.118Z"
}
```

Every run also writes **`RUN_SUMMARY`** to the key-value store: boards live, not
found, rate-limited and unreachable, boards that answered with nothing open,
postings scanned, and the filters applied. On a scheduled feed that is your
monitoring. A jump in `boardsNotFound` means employers moved ATS, not that the
Actor broke, and `boardsReadEmpty` names the ones that are still there and
currently hiring nobody.

***

### Cost

Billing is **per job row returned**, plus a small fee per completed run. Filters
are applied *before* you are charged, so a narrow query is a cheap query, and the
run fee is charged only when a run finishes. A crash or an empty result costs
nothing.

The run fee exists because an incremental feed can legitimately scan 190,000
postings and return four rows; without it, a run that did real work would bill
almost nothing. It is set to disappear against any bulk pull.

Three levers, in order of how much they matter:

- **`onlyNewOrChanged`** on a recurring run. Unchanged postings are skipped and
  not billed, which on a daily feed is the difference between paying for a full
  scan every day and paying for one once. See the section above.
- **`includeDescriptions: false`** when you only need listing metadata. Much
  faster, much smaller.
- **`maxItems`** is a hard ceiling. It defaults to 1,000 so an over-broad filter
  cannot produce a surprise bill. Raise it deliberately.

***

### Notes and limits

The honest version, because you will find these out anyway.

- **This is not a whole-market index.** The bundled corpus is 23,737 boards and
  the full list ships with the Actor, so you can check it before you pay. If what
  you need is every job in the market, that is a different product. What this
  does is *the companies you name*: read live at request time rather than from a
  cache, with the fields read correctly.
- **Rate limits are real, in this order.** Workable throttles first (Cloudflare
  challenge), then Personio; both are internally capped to a low concurrency and
  back off on `429`. **Workday throttles across tenants** and reports it as an
  unreachable board rather than a `429`, which is the nastiest version of this
  problem. Measured across the 120 Workday boards the corpus held at the time, an
  uncapped run reached 107 and a capped one 119, so it is capped too. Greenhouse tolerates far more but
  is behind a CloudFront WAF that will `403` a client it decides is a crawler. All
  three are reported separately in `RUN_SUMMARY` as `boardsRateLimited` and
  `boardsForbidden`, and never counted as "board not found", because a blocked
  source and a closed board need opposite responses.
- **Two platforms return no description: Breezy and Personio.** Breezy has no
  public per-posting endpoint at all. Personio has the field and leaves it empty:
  it came back blank on every posting of all three boards checked. Everything else
  has a description, and four of them (SmartRecruiters, Workday, BambooHR,
  Rippling) carry it only on the detail endpoint, which is fetched under the
  default `includeDescriptions: true`. Check the capability matrix above before
  relying on a keyword filter, which needs description text.
- **Coverage is not even across the thirteen.** The bundled list runs from 5,158
  BambooHR boards down to 14 on Teamtailor, and `COVERAGE.md` gives the count for
  every platform. Two shapes explain most of it. SmartRecruiters and Workable
  identify a board by company name rather than domain (`BoschGroup`, not `bosch`),
  which resists the domain-based discovery that built most of the corpus.
  Teamtailor is worse: most of its customers are not on a subdomain derived from
  their domain name at all, so a probe of 104,000 candidates returned three. The
  adapters are the same quality everywhere. If you know a company uses one of
  these and it is not in the list, pass it explicitly and it will be read.
- **Workday pages 20 at a time**, so a 2,000-role Workday board is 100 round
  trips. It is the slowest of the thirteen by a wide margin.
- **`postedWithinDays` drops undated rows.** Pinpoint and Personio publish no
  date at all, so those postings cannot satisfy a recency filter. BambooHR and
  Rippling publish one only on the detail endpoint, so they need
  `includeDescriptions` on for a recency filter to work. Workday's listing date is
  relative ("Posted 3 Days Ago"), so it is read to the day and not the second, and
  "Posted 30+ Days Ago" gives no date at all rather than a false "exactly 30 days".
  With `includeDescriptions` on it is replaced by the exact date from the detail
  endpoint.
- **Boards disappear.** Companies switch ATS constantly. Those return 404, are
  counted in `RUN_SUMMARY.boardsNotFound`, and do not fail the run.
- **Incremental mode compares a fingerprint, not the whole posting.** Title,
  location, pay, work arrangement, department, **posted** date and description
  *length* are watched. A description rewritten to the same length is not detected.
  That is a deliberate tradeoff, because several boards embed timestamps and tracking ids
  in the body and hashing it whole would report every row as changed every day.
  **`updatedAt` is deliberately not watched.** Greenhouse moves it in bulk without
  the posting changing. In one sample, five boards had every posting restamped
  within the same hour, so including it would have billed you for most of
  Greenhouse, which is 43% of the corpus, every single day.
- **Public data only.** Every endpoint is public and unauthenticated. Ten of the
  thirteen are the vendors' own documented job-board feeds. For Workday,
  BambooHR and Personio it is the request the employer's careers page makes to
  draw its own list, which is public but is not offered for syndication.
  Nothing authenticates, submits applications, or touches candidate data, and
  the Actor sends a User-Agent that names itself.
- **Opening a CSV export by double-clicking it.** Vendor text is delivered as
  published, and a few employers start a field with `=`, `+`, `-` or `@`, which
  Excel and Sheets read as the beginning of a formula. Measured on a 20,000-row
  sample: six descriptions begin with a run of `+`, and an earlier 83,000-row
  sample had 27 locations beginning with `-`. None of it is executable on its
  own, but import the file as text rather than opening it directly if that
  matters to you.

***

### Using this from an AI agent

Exposed through Apify's MCP server this works as a tool with no special handling.
Two things worth telling the model:

- Pass companies as **plain domains** (`"stripe.com"`) unless you already have an
  ATS board URL. The Actor resolves them itself, so the agent never has to know
  or guess which system a company uses.
- Set **`includeDescriptions: false`** for questions about *which* companies are
  hiring for *what*. Descriptions dominate both the payload and the cost, and are
  only needed when the question is about the content of a posting.
- A **keyword filter needs description text**, so it matches titles only on the
  two platforms that publish none: **Breezy** (no per-posting endpoint exists) and
  **Personio** (its `description` field came back empty on every posting of all
  three boards checked). SmartRecruiters, Workday, BambooHR and Rippling carry no
  description in the listing but do have one behind the detail endpoint, which is
  fetched under the default `includeDescriptions: true`, so keywords work there.
  Prefer `titleInclude` when the intent is a role type, and reserve `keywords` for
  skills that appear in the body.
- `isRemote` is deliberately **three-valued**: `true`, `false`, or `null` when the
  employer did not say. Do not treat `null` as `false` when summarising.
- Leave **`onlyNewOrChanged` off** for one-shot questions. It is for recurring
  feeds, and on a first run it returns everything anyway. Switching it on for an
  ad-hoc query silently creates a history the next ad-hoc query will inherit.

### The 300 largest employers in the bundled list

Ctrl+F for a company. **Not being listed here is not a limit**: pass any company
directly and it is fetched whether or not it ships in the corpus.

...and the next 260, by size:

Anduril Industries · Gevernova · Clevelandcliniccareers · Asmglobal · Jllcareers · Leidos · Eiffage · Walmart · Amat · Wvumedicine · CW · Bilh · Adventhealth · Genpact · Uhaul · Accenture · Trinityhealth · Massgeneralbrigham · Cecentertainment · Page · PNC · Hitachi · Jabil · AAH · Tapestry · Sysco · Bridgestone · Slihrms · Circlek · Sentara · Dickssportinggoods · Target · Pacs · Drivenbrands · Bjswholesaleclub · Freseniusmedicalcare · Genpt · Michaels · Fivebelow · Sanford · Citi · Lateral US · Tmobile · Sunriseseniorliving · Fourseasons · Soprasteria1 · MMC · Spectrumhealth · IQVIA · Capitalone · Ochsner · Highmarkhealth · Parsons · Fielmann · Caci · Abbvie · Humana · Labcorp · JJ · Hmgroup · Squircleitconsultingservicespvtltd · Pilotcompany · Amgen · WealthBridge Financial Group · Brookshires · Jdgroupnam · Wellsfargojobs · Carvana · KBR · TD Bank · Republic · Egisgroup · CRM Experienced · Umiami · Cityofnewyork · Collabera2 · Skechers · Blue Origin · Mango · Tranetechnologies · Hyvee · Papajohns · Flextronics · Metromakro · Campus · CNX · Signet Jewelers · Alphabeinsightinc · Greystar · PVH · Wawa · Inetum2 · Hcmportal · Interpublic · Paloaltonetworks · Galileo · Usm2 · Meijer · Brownhealth · Northwesternmedicine · Stryker · Salesforce · PSU Staff · Dreessommerse · Livenation · BHS · Geisinger · DBS · CXG · 2020companies · Rochester · Intermountaincareers · Rrhs · Nordstrom · Calibercollision · U Rcareers · Maersk · RBC · MS · Usbank · Jeffersonhealth · Redrobin · Danaher · Acjobsite · Cisco · Levistraussandco · VFC · WF Mathpe · Benchmark · Roche · Eoctebp · Statestreet · Abbott · Centria Autism · Redbull · Attgeneral · Securitas · Upstream Rehabilitation · Onetp · MSD · Gdit · Ramboll3 · Bannerhealth · Jobsathpe · Carmax · Alten · Medtronic · HP · AstraZeneca · Lvhn · Private Equity Insights · Firststudent · Truist · Chanel · Deutsche Bank · Daveandbusters · Veterinary Emergency Group (VEG) · SCI · Airliquidehr · Sggovterp · Jcrew · Spartannash · Carrier · Speechify · ALO · Homedepot · Mastercard · Kansashealthsystem · WPP Media · Ummh · Uobgroup · BMO · Santander · Weis · Valeo · Kyndryl · Theapexgroup · AIA · KLA · Osucareers · Barclays · EquipmentShare · Deliveryhero · KION Group · Ilitch · Gehc Externalsite · Bertelsmann Jobs · RHG · Usphysicaltherapy2 · Afry · Baywaag · Sluhn · Cencora · Sunbeltrentals · RSM · BlueSky Telepsych · Carters · Boydgroup · Caterpillarcareers · Renesaselectronics · Dxctechnology · RHE · Heinz · Boydgaming · Super Soccer Stars · Devoteam · Bagelbrands · Kone · Dentsuaegis · Assystem · Novartis · Dutchbros · Nagarro1 · Wellstar · Databricks · MTB · Primark · Motorolasolutions · Broadbean · Heidelbergmaterials · Brighthorizons · Nationalvision1 · ZEISS Group · Wynd · BMC · Atuauto Teile Unger · Sanofi · Generalmotors · USC · Vumccareers · Wshgroup · Meridial · NC · Guzmanygomez · Ecolab · Teamcarcare · Philips · Analog Devices · Academy · Continental · Goldbeck · PG · Dinebrands · Best Version Media · PLS · Cardinalhealth · Mars · ITW · NXP · Mcdonaldsoesterreich · Campingworld · Essentiahealth · Nike · Guidehouse · Gartner

That is the 300 largest of 23,737 bundled boards. Greenhouse, Workable, Recruitee,
Breezy, Teamtailor and SmartRecruiters publish a company display name, and it is
used verbatim on 7,024 of 23,737 boards. Lever, Ashby and Workday publish none,
so those names are derived from the board identifier and are sometimes
abbreviated as a result.

***

**Unofficial.** This Actor is not affiliated with, endorsed by, or sponsored by
Greenhouse, Lever, Ashby, Workable, Recruitee, SmartRecruiters, Workday,
Teamtailor, Breezy, Personio, BambooHR, Pinpoint or Rippling. All product names
are trademarks of their respective owners. It reads only public, unauthenticated
job-board endpoints.

# Actor input Schema

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

Which employers to read. Three forms, mixed freely: (1) an ATS board URL such as https://job-boards.greenhouse.io/stripe or https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite; (2) a plain company domain such as stripe.com, usually easiest since the domain is tried against every platform so you need not know which ATS a company uses; (3) a bare name such as stripe. Leave empty and set useBuiltInCorpus to search the bundled board list instead.

## `useBuiltInCorpus` (type: `boolean`):

Read the bundled list of verified live job boards, in addition to any companies given above. Use this for market-wide questions such as who is hiring Rust developers. Use the companies field instead when you already know which employers you care about. COVERAGE.md lists exactly which companies are in the bundled list.

## `corpusSample` (type: `integer`):

How many of the bundled boards to read, largest employers first. 250 covers about 40% of all bundled postings, 500 about 53%, 1000 about 65% and 2000 about 77%, so a small number goes a long way. Set 0 for every board, which is 23,737 of them and will not finish inside a default run timeout. This applies whenever the bundled list is read, which is when you switch on the built-in list and also when you name no companies at all.

## `atsPlatforms` (type: `array`):

Which applicant tracking systems to read. Narrowing this speeds up runs. Ashby, Lever, Recruitee and Pinpoint publish structured salary. SmartRecruiters, BambooHR, Rippling and Workday carry descriptions only on a second per-posting request, made when includeDescriptions is on. Breezy and Personio return no description at all.

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

Keep only postings containing at least one of these, case-insensitively, in the job title or description. Example: kubernetes, terraform. Descriptions are only searched when includeDescriptions is on; with it off this matches titles only.

## `keywordsMatchAll` (type: `boolean`):

When on, a posting must contain all of the keywords rather than any one of them.

## `titleInclude` (type: `array`):

Keep only postings whose title contains one of these, case-insensitively. Narrower and more reliable than keywords because it ignores the description. Example: engineer, developer.

## `titleExclude` (type: `array`):

Drop postings whose title contains any of these. Useful for stripping out seniority levels or non-target roles. Example: intern, manager, director.

## `location` (type: `array`):

Keep only postings whose location text contains one of these, case-insensitively. Matches the raw location string as the employer wrote it, so prefer a city or country name over a postal code. Example: Berlin, Germany.

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

ISO code or country name. "US", "United States" and "GB" all work. Most boards publish a structured country; Greenhouse, Pinpoint, Rippling and BambooHR do not, so it is read off the location string instead. "Hawthorne, CA" and "Seoul, South Korea" resolve; "London" and "Tbilisi, Georgia" stay unknown rather than being guessed. How much of a board resolves depends heavily on the board: most Greenhouse boards resolve 94-100%, but one large employer writing locations as internal store codes resolves 3%. Rows resolved this way are marked countryInferred, so you can exclude them. Personio publishes neither and will not match.

## `workplaceTypes` (type: `array`):

Keep only postings with this work arrangement, as the vendor stated it. Lever, Recruitee, Pinpoint and Ashby publish it on nearly every posting; SmartRecruiters and Workable on a small minority; Workday has the field but fills it in on well under 1% of postings. Greenhouse, Breezy, BambooHR, Rippling, Personio and Teamtailor do not publish it at all, and their postings are excluded rather than guessed at. Use this rather than remoteOnly when you want Hybrid.

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

Keep only postings that are definitely remote. Hybrid roles are excluded. Postings where the employer did not state a work arrangement have isRemote = null and are also excluded, because guessing would be wrong more often than right.

## `withSalaryOnly` (type: `boolean`):

Keep only postings with a published pay range, and only when it carries actual numbers. A compensation field reading "Competitive" does not count. Ashby, Lever, Recruitee and Pinpoint publish pay as a structured field; elsewhere a range is read from the description only when it is explicitly currency-marked. Measured share of postings that carry one: Greenhouse, Lever, Recruitee and Ashby around half, Workable a little under, BambooHR about a quarter, and Pinpoint, Breezy, Rippling, SmartRecruiters and Workday in the single digits. Teamtailor and Personio publish none and will not match.

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

Keep only postings published in the last N days. 0 disables the filter. On a scheduled daily feed set this to 1 or 2, which turns a whole-market run into just the new rows. Postings from boards that publish no date cannot satisfy this and are dropped.

## `onlyNewOrChanged` (type: `boolean`):

Return only postings that are new, or whose title, location, pay, work arrangement or description has changed since the last run with the same settings. Unchanged rows are skipped and not billed, which is what makes a daily feed cheap. The first run has no history, so it returns everything and establishes the baseline. Each returned row gains a changeType field of new or changed.

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

Names the history that incremental mode reads and writes. Leave blank and one is derived from your settings, so two differently-filtered feeds never contaminate each other. Set it explicitly only if you want two runs to deliberately share a history.

## `includeDescriptions` (type: `boolean`):

Return the full job description as both HTML and plain text. Turn this OFF when you only need to know which companies are hiring for what. It is much faster and cheaper. On SmartRecruiters, BambooHR, Rippling and Workday this switch also controls a second request per posting, which is the only way to get a description and, on BambooHR and Rippling, the posting date.

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

Hard ceiling on results, so an over-broad filter cannot produce a surprise bill. The run stops as soon as it is reached. 0 means no limit. Set that only with a narrow filter in place.

## `maxPerCompany` (type: `integer`):

Stops a single large board filling the whole result. Leave at 0 and a market-wide run with a ceiling spreads it over at least 40 boards on its own: without that, the bare default returned 1,000 rows from one or two companies, because one Greenhouse board arrives in a single page and takes the whole ceiling. Counted per board, not per employer, so a company that runs two boards can return twice this. It is never applied when you name companies yourself, or when "maxItems" is 0.

## `concurrency` (type: `integer`):

How many job boards to read at once. The default suits most runs. Workable and Personio rate-limit aggressively and are internally capped below this regardless of what you set.

## Actor input object example

```json
{
  "companies": [
    "stripe.com",
    "https://jobs.ashbyhq.com/openai",
    "databricks.com"
  ],
  "useBuiltInCorpus": false,
  "corpusSample": 500,
  "atsPlatforms": [
    "greenhouse",
    "lever",
    "ashby",
    "workable",
    "recruitee",
    "smartrecruiters",
    "workday",
    "teamtailor",
    "breezy",
    "personio",
    "bamboohr",
    "pinpoint",
    "rippling"
  ],
  "keywordsMatchAll": false,
  "workplaceTypes": [],
  "remoteOnly": false,
  "withSalaryOnly": false,
  "postedWithinDays": 0,
  "onlyNewOrChanged": false,
  "stateKey": "",
  "includeDescriptions": true,
  "maxItems": 1000,
  "maxPerCompany": 0,
  "concurrency": 20
}
```

# Actor output Schema

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

One item per posting, in a single schema across all supported applicant tracking systems.

## `runSummary` (type: `string`):

Counts of boards read, not found, rate-limited, forbidden and read with nothing open, postings scanned, the filters applied, and any diagnostics explaining an empty result.

## `removedSinceLastRun` (type: `string`):

Written on every incremental run, empty array included: the ids your previous run returned that this one did not. Usually the employer took the posting down, but a posting that ages past postedWithinDays or is retitled out of a keyword match leaves the same way. Boards this run could not read are excluded, so a vendor outage does not empty your feed on paper.

# 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": [
        "stripe.com",
        "https://jobs.ashbyhq.com/openai",
        "databricks.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("firstpartydata/ats-job-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "companies": [
        "stripe.com",
        "https://jobs.ashbyhq.com/openai",
        "databricks.com",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("firstpartydata/ats-job-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "companies": [
    "stripe.com",
    "https://jobs.ashbyhq.com/openai",
    "databricks.com"
  ]
}' |
apify call firstpartydata/ats-job-scraper --silent --output-dataset

```

## MCP server setup

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