# ATS Jobs API — Greenhouse, Lever, Ashby & more (`bigdavidson/ats-jobs-api`) Actor

Open jobs from Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Recruitee and Personio as one JSON schema: title, location, remote flag, salary when published, apply URL. Diff mode flags new and closed roles. Official public job-board APIs only, no login.

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

## Pricing

from $1.50 / 1,000 job records

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## ATS Jobs API — Greenhouse, Lever, Ashby & more

Give it a list of companies and get back **every open role on their public career boards** as one clean, flat JSON schema: title, department, location, remote flag, **salary when the company publishes it** (from the ATS's pay field, or else a pay range stated in the posting text; `salary_source` says which), posted date, and the apply link. It works with **Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Recruitee and Personio**. You can pass a board token (`gitlab`), a prefixed token (`lever:palantir`), a careers URL (`https://jobs.ashbyhq.com/openai`) or a plain company name (`Scale AI`). The ATS is detected automatically, and a plain name is checked against the company name each ATS reports before you're charged for it.

Turn on **diff mode** and each run tells you which roles are **new** and which **closed** since the last run. That makes it a hiring-signal monitor for a watchlist of companies. The new/closed flags are free.

It reads each ATS's own documented, public job-board API. No login, no API key, no browser, no scraping of HTML pages.

**Who it's for:** recruiters and sourcers tracking target companies, sales teams using hiring as a buying signal, job-board and aggregator builders, VC/market researchers, job seekers watching a shortlist, and AI agents that need "what is company X hiring for?" in one call.

### Supported job boards

| ATS | Pass any of these | Salary | Notes |
|---|---|---|---|
| Greenhouse | `gitlab`, `greenhouse:gitlab`, `boards.greenhouse.io/gitlab`, `job-boards.greenhouse.io/gitlab` | pay-transparency ranges (placeholder ranges such as `USD 1-2` are ignored); pay written only in the description is read with `includeDescription` (the plain job list has no text) | department from the board's departments list (on rare jobs this differs from the job page's own department, an upstream inconsistency) |
| Lever | `lever:palantir`, `jobs.lever.co/palantir` | `salaryRange`, else a range stated in the posting text (Palantir: "$60,000 - $97,000/year") | EU-hosted boards (`jobs.eu.lever.co`) handled, with a fallback to the global host |
| Ashby | `ashby:openai`, `jobs.ashbyhq.com/openai` | compensation tiers, else a range stated in the posting text (Notion) | |
| SmartRecruiters | `sr:Equinox`, `jobs.smartrecruiters.com/Equinox` | a salary custom field on the posting list (Wise's "Job Ad Salary Range"); with `includeDescription`, also each posting's structured `compensation`, else a range in its text | descriptions (and that pay) cost 1 extra request per job |
| Workable | `workable:blueground`, `apply.workable.com/blueground` | only a range stated in the posting text, with `includeDescription` | |
| Recruitee | `recruitee:bunq`, `bunq.recruitee.com` | salary field, else a range stated in the posting text | |
| Personio | `personio:1komma5grad`, `1komma5grad.jobs.personio.de` | only a range stated in the posting text (the feed has no pay field) | XML feed; some companies disable it. Descriptions missing from the English feed come from the board's default-language (often German) feed |

#### How auto-detect picks a board

A token or name without an ATS prefix is tried on all seven ATSs. The actor keeps the board whose company name (as the ATS reports it) matches what you typed, and among equally good matches the one with the most open roles. For example, `Wise` resolves to SmartRecruiters `Wise` (415 roles), not to the Greenhouse board `wise`, which belongs to "Wise Worksite Field Sales".

Some boards exist but can't be trusted to be the company you meant. Workable, Recruitee, Personio and SmartRecruiters let anyone open an account, so big-brand names there are often empty or hold sample postings. In a check on 2026-09-24, `microsoft`, `apple`, `nike` and 15 other large employers were empty Workable accounts, Recruitee `google` held one "Senior Marketer (Sample)" posting, and Recruitee `meta` belonged to a university. A board like that comes back as a **free `unconfirmed` row**, with no job rows. There are three cases:

- the board has no open roles,
- its postings look like placeholders (sample/test titles or lorem-ipsum text),
- the ATS's company name is a different company.

If the ATS's name only partly matches (for example `xAI` finds the Greenhouse board named "SpaceXAI"), the jobs are delivered and the company row's `message` carries a **Caution** note.

To skip the guessing, use a prefix (`greenhouse:wise`) or the careers URL. Those always use exactly that board. A careers page on the company's own domain (`https://careers.airbnb.com/`) is turned into a token guess (`airbnb`) and auto-detected.

### Input

| Field | Type | Default | What it does |
|---|---|---|---|
| `companies` | array of strings | demo: `gitlab`, `lever:palantir`, `https://jobs.ashbyhq.com/openai` | Board tokens, `ats:token` prefixes, careers URLs or company names, one per item. Up to 500 per run (the input schema rejects longer lists; split them across runs or diff keys). A comma inside an item is part of the name (`Stripe, Inc.`). Newlines or `;` inside an item separate entries. Leave it out for the demo. An empty list, or only blank entries, gives one free explanatory row and no demo run. |
| `ats` | `auto` / `greenhouse` / `lever` / `ashby` / `smartrecruiters` / `workable` / `recruitee` / `personio` | `auto` | Which ATS plain tokens belong to. Prefixes and URLs always win. |
| `maxJobsPerCompany` | integer | `20` | Newest roles returned per company. `0` = all. |
| `maxRecords` | integer | `0` (no limit) | Cap on job records across the whole run, which caps the cost. |
| `titleKeywords` | array | — | Keep jobs whose title contains any keyword (`engineer`, `sales`, `data`). |
| `locationKeywords` | array | — | Keep jobs whose location contains any keyword (`London`, `UK`, `Remote`). Common place spellings count as one: `UK` = `United Kingdom` = `Great Britain` (plus England, Scotland, Wales, Northern Ireland), `US` = `USA` = `United States` plus US-state places written `City, ST` (`Austin, TX`), `California` = `CA`, `NYC` = `New York`, `Canada` plus `Toronto, ON`, and about 40 countries' names and codes (`Germany` = `Deutschland`). Keywords of 3 letters or fewer must match a whole word. A location written only as a city (`San Francisco`) matches only the city name, not `US`. |
| `remoteOnly` | boolean | `false` | Keep only roles that can be done remotely (see `remote` below). |
| `includeDescription` | boolean | `false` | Add the plain-text description (HTML stripped). On SmartRecruiters it also fills pay from each posting's published compensation (1 extra request per job). |
| `descriptionMaxChars` | integer | `3000` | Trim descriptions to this length (`0` = full text). |
| `diffMode` | boolean | `false` | Remember open roles between runs and flag `new` / `unchanged`, plus a free `closed` row per role that disappeared. |
| `onlyChanges` | boolean | `true` | In diff mode, return only new roles (plus closed rows) instead of every open role. |
| `diffKey` | string | hash of companies + filters | Name for the saved state. Set it (e.g. `my-watchlist`) to keep history while you edit the company list. |

The platform checks input types before the run starts, so `companies` must be a JSON array and the number fields must be numbers. Within that, the actor is lenient. Keys are case-insensitive. `snake_case` spellings such as `max_jobs_per_company` also work, and they take priority over a default the platform fills in. Entries are trimmed and case-insensitive. An unknown or broken entry, even a placeholder like `https://[company].greenhouse.io`, becomes a free explanatory row instead of failing the run.

Example: a daily watchlist that returns only new engineering roles:

```json
{
  "companies": ["gitlab", "lever:palantir", "https://jobs.ashbyhq.com/openai", "sr:Equinox"],
  "titleKeywords": ["engineer"],
  "diffMode": true,
  "diffKey": "eng-watchlist",
  "maxJobsPerCompany": 0
}
```

### Output

Every row has the **same 29 keys**, so CSV/Excel exports and agents always see one schema. A key is `null` when it doesn't apply. `record_type` says what the row is:

- `job` with `status: "open"`: an open role (billed as `job-record`).
- `job` with `status: "closed"`: diff mode only, a role that was open last run and is gone now (free).
- `company`: one free summary row per company. `message` explains it in plain English. `status` is one of:
  - `ok`: board found, and its jobs were delivered.
  - `no_open_jobs`: the board you named exists but has 0 roles.
  - `unconfirmed`: auto-detect found a board it can't trust (see above).
  - `not_found`: no board anywhere.
  - `error`: the ATS was unreachable, or its board is over the size cap (the message says which; retrying won't help for the size cap).
  - `duplicate`: the same board as an earlier entry.
  - `not_processed`: charge limit, maxRecords or timeout reached.
  - `invalid_input`: the entry couldn't be used.

| Field | Example | Notes |
|---|---|---|
| `record_type` | `job` | `job` or `company` |
| `company` | `GitLab` | display name when the ATS gives one, else the board token. Personio: the hiring legal entity (`<subcompany>`) |
| `company_slug` | `gitlab` | the board token that answered |
| `ats` | `greenhouse` | `greenhouse`, `lever`, `ashby`, `smartrecruiters`, `workable`, `recruitee`, `personio` |
| `job_id` | `8821526002` | the ATS's own posting id (string) |
| `title` | `Intermediate Security Analyst…` | |
| `department` | `Product Security` | department, or team where that's all the ATS has |
| `location` | `Remote, Canada; Remote, United States` | primary location as the company wrote it |
| `all_locations` | `Istanbul; Sofia` | every listed location, `; `-separated. Greenhouse adds its office entries only with `includeDescription`, because the plain job list doesn't carry them |
| `remote` | `true` | `true` when the role can be done remotely, `false` on-site/hybrid only, `null` unknown. `true` when the board's own workplace field says remote (the ATS remote flag, or a Greenhouse custom field such as "Workplace Type" or "Location Type"), or when one of the role's listed locations is explicitly remote. For example, an Ashby or Lever Hybrid role with a `Remote (US)` location is `remote: true` with `workplace_type: "hybrid"`. On Greenhouse the board's own field wins over the location text, so an Anthropic role whose Location Type is On-Site stays `false` even when its location reads "Remote-Friendly". Boards with no workplace field fall back to the location text |
| `workplace_type` | `hybrid` | `remote`, `hybrid`, `onsite` or `null`, as the ATS's own workplace field states it |
| `employment_type` | `Full-time` | as the ATS words it. Personio combines contract type and schedule (`Internship, full-time`, `Working student, part-time`; permanent roles show only the schedule, `Full-time`) |
| `salary_min` / `salary_max` | `115000` / `150000` | numbers in `salary_currency`; `null` if not published. A single stated figure fills both (`$10,000/month`); `From SGD 245,000` fills only `salary_min`. When a Greenhouse job lists several regional ranges, these come from the range whose title names the job's (first-listed) location, including sentence titles such as "...in the locations of Hawaii, Washington DC, Texas, Colorado is:"; else the first range. For an Ashby sales role that publishes on-target earnings, these are the OTE (base + commission) figures and `salary_text` starts with `OTE` |
| `salary_currency` | `USD` | ISO code. In posting text a currency code or symbol is required. A code next to the figure wins (`$8,500 SGD/month` is `SGD`). A bare `$` is read from the job's location: the local currency when every listed location is in one of Canada, Australia, New Zealand, Singapore, Hong Kong or Mexico (`$17.95/hour` in `Toronto, ON, Canada` is `CAD`). Canada is recognised by its name, a province, a major city (Toronto, Montreal, Calgary, Ottawa, Edmonton, Winnipeg, or Vancouver unless it's in Washington state) or a `CAN:` prefix. Otherwise it's `USD` (US, mixed, remote-only or other unrecognised locations) |
| `salary_interval` | `year` | `year`, `month`, `week`, `day`, `hour`. When the source states no interval, a figure of 20,000 or more in USD, EUR, GBP, CAD, AUD, NZD or CHF is taken as `year` (GitLab's Greenhouse ranges); otherwise `null`. `salary_text` keeps the source wording and doesn't add "per year" in that case |
| `salary_text` | `USD 115,000-150,000 (United States Salary Range)` | human-readable range(s), all regional ranges included, each labelled with its place list. Starts with `OTE` when an Ashby role publishes on-target earnings (base + commission). SmartRecruiters custom-field pay keeps the company's own wording (`118500 - 164000 GBP Annual`), even when no figure in it can be read. Pay read from the posting text is formatted like `USD 60,000-97,000 per year`, with up to 3 distinct ranges `;`-separated (the first one listed fills the numbers, whichever region the job is in) and `OTE` in front of on-target earnings |
| `salary_source` | `ats` | `ats`: the ATS's own pay field (Greenhouse ranges, Lever `salaryRange`, Ashby compensation, Recruitee salary, SmartRecruiters custom field or `compensation`). `description`: read from the posting text because the ATS field was empty. `null`: no pay found. Text parsing is conservative: it needs a currency, a pay word such as salary/pay/compensation/OTE/rate close before the figure, and skips bonuses, stipends, budgets and company money figures |
| `posted_at` | `2026-09-24T16:03:09Z` | UTC. Publish time for Greenhouse, Ashby, SmartRecruiters and Recruitee, publish date for Workable (`2026-09-24`), creation time for Lever and Personio |
| `job_url` | `https://job-boards.greenhouse.io/gitlab/jobs/8821526002` | posting page. When Greenhouse or Ashby omit it, it is built from the board token and job id; on the other ATSs it can be `null` if the upstream item has none (still a delivered, billed row) |
| `apply_url` | `…/application` | application form (same as `job_url` where the ATS has no separate form) |
| `description` | `null` | plain text, only with `includeDescription` |
| `diff_status` | `new` | `new`, `unchanged`, `closed` in diff mode, else `null` |
| `status` | `open` | job: `open`/`closed`; company: see above |
| `open_jobs_total` | `319` | company rows: open roles on the board, before filters. Never less than the roles actually read (a missing or junk SmartRecruiters `totalFound` doesn't produce "0 open roles; 1 returned") |
| `jobs_returned` | `20` | company rows: job records delivered for that company |
| `message` | `Lever board 'palantir': 319 open role(s); 20 returned…` | company rows: plain-English explanation |
| `input` | `lever:palantir` | the input entry this row came from |
| `fetched_at` | `2026-09-25T01:43:16Z` | run time (UTC) |

Real output from the default input `{}` (local `apify run -i '{}'`, fetched 2026-09-25 01:43 UTC, about 10 s; the GitLab row's `salary_interval` is shown as the current version fills it). These are three of the 63 rows (60 jobs + 3 company rows). The Palantir row's pay comes from its posting text, because Lever's pay field is empty on that board:

```json
[
  {
    "record_type": "job", "company": "GitLab", "company_slug": "gitlab", "ats": "greenhouse",
    "job_id": "8821526002",
    "title": "Intermediate Security Analyst, Vulnerability Operations (North America)",
    "department": "Product Security",
    "location": "Remote, Canada; Remote, United States",
    "all_locations": "Remote, Canada; Remote, United States",
    "remote": true, "workplace_type": "remote", "employment_type": null,
    "salary_min": 115000, "salary_max": 150000, "salary_currency": "USD", "salary_interval": "year",
    "salary_text": "USD 115,000-150,000 (United States Salary Range)", "salary_source": "ats",
    "posted_at": "2026-09-24T16:03:09Z",
    "job_url": "https://job-boards.greenhouse.io/gitlab/jobs/8821526002",
    "apply_url": "https://job-boards.greenhouse.io/gitlab/jobs/8821526002",
    "description": null, "diff_status": null, "status": "open",
    "open_jobs_total": null, "jobs_returned": null, "message": null,
    "input": "gitlab", "fetched_at": "2026-09-25T01:43:16Z"
  },
  {
    "record_type": "job", "company": "palantir", "company_slug": "palantir", "ats": "lever",
    "job_id": "802add74-04cf-479c-9479-ff3043940e29",
    "title": "Deployment Strategist - US Government",
    "department": "Echo",
    "location": "Kitsap, WA",
    "all_locations": "Kitsap, WA",
    "remote": false, "workplace_type": "onsite", "employment_type": "Full-time",
    "salary_min": 110000, "salary_max": 170000, "salary_currency": "USD", "salary_interval": "year",
    "salary_text": "USD 110,000-170,000 per year", "salary_source": "description",
    "posted_at": "2026-09-24T14:53:54Z",
    "job_url": "https://jobs.lever.co/palantir/802add74-04cf-479c-9479-ff3043940e29",
    "apply_url": "https://jobs.lever.co/palantir/802add74-04cf-479c-9479-ff3043940e29/apply",
    "description": null, "diff_status": null, "status": "open",
    "open_jobs_total": null, "jobs_returned": null, "message": null,
    "input": "lever:palantir", "fetched_at": "2026-09-25T01:43:16Z"
  },
  {
    "record_type": "company", "company": "openai", "company_slug": "openai", "ats": "ashby",
    "job_id": null, "title": null, "department": null, "location": null, "all_locations": null,
    "remote": null, "workplace_type": null, "employment_type": null,
    "salary_min": null, "salary_max": null, "salary_currency": null, "salary_interval": null,
    "salary_text": null, "salary_source": null, "posted_at": null, "job_url": null, "apply_url": null,
    "description": null, "diff_status": null, "status": "ok",
    "open_jobs_total": 830, "jobs_returned": 20,
    "message": "Ashby board 'openai': 830 open role(s); 20 returned (newest first, maxJobsPerCompany=20).",
    "input": "https://jobs.ashbyhq.com/openai", "fetched_at": "2026-09-25T01:43:16Z"
  }
]
```

A plain name that only matches an untrusted board gives a free row like this one, from a real run with `Microsoft` (other keys `null`): `{"record_type": "company", "status": "unconfirmed", "input": "Microsoft", "ats": "workable", "company": "Microsoft", "company_slug": "microsoft", "open_jobs_total": 0, "jobs_returned": 0, "message": "Found Workable board 'microsoft' (Microsoft) for 'Microsoft', but it has no open roles, so it isn't confirmed as the company you meant: no job rows, not charged. If it is the right board, pass 'workable:microsoft' (or its careers URL). Large employers often use Workday, iCIMS, Taleo or SuccessFactors, which this actor doesn't cover.", ...}`.

An unknown token gives a free `not_found` row. Its `message` lists the ATSs and tokens tried.

The run's key-value store also gets an `OUTPUT` record with a per-company summary: input, ATS, token, status, open roles and roles returned.

### Diff mode (new / closed since last run)

With `diffMode: true` the actor saves each company's open role IDs in a named key-value store (`ats-jobs-api-diff`) in **your** Apify account. The state is keyed by a hash of your companies and filters, or by `diffKey` if you set one. Each company gets its own record (`<diffKey>--greenhouse.gitlab`), read and saved while that company is processed, plus a small index record named after the key. So memory doesn't grow with the size of the watchlist, and a run stopped part-way keeps the state of the companies it finished. On the next run with the same key:

- roles not seen before get `diff_status: "new"`.
- roles still open get `"unchanged"`. They are returned only when `onlyChanges` is `false`.
- roles that disappeared get a **free** row with `status: "closed"`, `diff_status: "closed"`, plus the title and URL remembered from the last run.

The first run for a company records all its current roles as the baseline and returns them flagged `new`, up to `maxJobsPerCompany`. After that you only pay for what changed. On later runs, a new role that isn't returned because of `maxJobsPerCompany`, `maxRecords` or your charge limit is not marked as seen: the next run reports it as `new`, and the company row's `message` says how many were held back. (If the first run itself is cut short by `maxRecords` or the charge limit, only the roles actually returned are recorded, and the rest come back as `new` next time.) A company that errors or isn't found keeps its previous state, so an outage never shows up as a wave of "closed" roles. A board that a previous run confirmed stays trusted even when it empties out, so its last roles are reported as closed. If a company's state can't be read or saved (a key-value store error, or a record over 8 MB even with titles and URLs dropped), its company row says so, the run's status message and the `OUTPUT` record (`diff_state_problems`) list it, and the next run may report (and charge) some of its roles as `new` again. A record over 8 MB with titles and URLs is saved with job ids only, and the company row says its later `closed` rows will carry just the id. Schedule it daily (Apify Schedules) for a hiring-signal feed.

### Pricing

Pay per event. No subscription, no rental.

| Event | Price | When |
|---|---|---|
| `actor-start` | $0.005 | once per run, when the first job board answers |
| `company-resolved` | $0.002 | per company whose board was found and confirmed. It is also charged when your `titleKeywords`, `locationKeywords`, `remoteOnly` or diff mode leave 0 of its roles to return (the board was still checked, and its company row says how many roles it has), and when the board has 0 open roles if you named the ATS or diff mode confirmed the board earlier. When the company has job rows to deliver, it is only charged if your remaining budget also covers at least one of them, and only after its first rows are stored |
| `job-record` | $0.0015 | per open-role row delivered |
| company summary rows, `unconfirmed` / `not_found` / `error` / `duplicate` / `not_processed` / invalid rows, diff `closed` rows, new/unchanged flags | free | |

Worked examples:

| Run | Cost |
|---|---|
| Default demo: 3 companies × 20 newest roles | $0.005 + 3 × $0.002 + 60 × $0.0015 = **$0.101** |
| One big board, every role (OpenAI on Ashby, 829 roles on 2026-09-24) | $0.005 + $0.002 + 829 × $0.0015 = **$1.25** |
| 50-company watchlist, daily diff, ~30 new roles/day | $0.005 + 50 × $0.002 + 30 × $0.0015 = **$0.15/day** (≈ $4.50/month) |
| 5 typo'd tokens, or 5 big brands that only have empty squatted accounts on these ATSs | **$0.005** (the actor-start fee only) |
| 2 companies with a title filter that matches nothing | $0.005 + 2 × $0.002 = **$0.009** (no job rows) |
| `companies: []` or only blank entries | **$0** (one free explanatory row; nothing is fetched) |
| The same board given twice (`gitlab` and `greenhouse:gitlab`), 5 roles | $0.005 + $0.002 + 5 × $0.0015 = **$0.0145** (the second entry is a free `duplicate` row) |

Set `maxRecords` or the run's maximum charge to cap spend. When the limit is reached the run stops cleanly. Companies it didn't reach get a free `not_processed` row, and nothing is charged for them.

### FAQ

#### Where do I find a company's board token?

It's in the careers URL: `boards.greenhouse.io/<token>`, `jobs.lever.co/<token>`, `jobs.ashbyhq.com/<token>`, `jobs.smartrecruiters.com/<token>`, `apply.workable.com/<token>`, `<token>.recruitee.com`, `<token>.jobs.personio.de`. You can also paste the whole URL.

#### Can I just pass company names?

Yes. A plain name (`Scale AI`) is turned into token guesses (`scaleai`, `scale-ai`), and each board found is checked against the company name its ATS reports. Name lookups are best effort: a company whose token differs from its name won't be found, and one whose board sits on an ATS that doesn't report a company name (Lever, Ashby, Personio) is matched on the token alone. For monitoring, pin each company with a prefix or careers URL.

#### Why is a company "not\_found" or "unconfirmed" when it clearly has jobs?

It probably uses an ATS this actor doesn't cover (Workday, iCIMS, Taleo, SuccessFactors…). Many large employers do, and their names on Workable or Recruitee are often empty or sample accounts, which come back as `unconfirmed`. It could also be that its board token differs from its name, or that it has turned off its public feed (some Personio companies do). Both rows are free and say what was tried.

#### Why are some salaries empty?

Pay is filled from the ATS's own pay field first: Greenhouse pay-transparency ranges, Lever salary ranges, Ashby compensation, Recruitee salary, and on SmartRecruiters a salary custom field on the posting list (Wise: "Job Ad Salary Range", `118500 - 164000 GBP Annual`; AbbVie: "Salary Min"/"Salary Max") or, with `includeDescription`, each posting's structured `compensation`. Those rows have `salary_source: "ats"`.

Many companies write pay only in the posting text instead (Palantir on Lever, Notion on Ashby, some Stripe roles on Greenhouse). When the pay field is empty, the actor reads a range stated there, such as "The estimated salary range for this position is $60,000 - $97,000/year", and marks the row `salary_source: "description"`. Lever, Ashby, Recruitee and Personio always send the text, so this works on every run. Greenhouse, Workable and SmartRecruiters only send it with `includeDescription` on. On 2026-09-25 this filled pay for 238 of Palantir's 319 roles and 92 of Notion's 130.

The text reader is deliberately conservative. It needs a currency and a pay word (salary, pay, compensation, wage, OTE, rate, Gehalt, salaire and similar) close before the figure. It skips bonuses, stipends, budgets and company money figures, and it skips malformed text such as `$184,050 $262,928`. So some roles whose text mentions pay stay `null`. Text that lists one range per region as bullet points under a line such as "The expected base salary range for this role in:" is read too: each bullet's range goes into `salary_text`, and the first bullet fills the numbers. A bare `$` is USD unless a currency code sits next to it or every location of the job is in Canada, Australia, New Zealand, Singapore, Hong Kong or Mexico, where it becomes that country's currency. Canada is recognised by its name, its provinces, its largest cities or a `CAN:` prefix; a location the actor doesn't recognise leaves a bare `$` as USD. Placeholder ranges that some Greenhouse boards leave at the form minimum (`USD 1-2 per year`) are ignored. Greenhouse ranges often don't state an interval. A figure of 20,000 or more in USD, EUR, GBP, CAD, AUD, NZD or CHF is then taken as annual; a smaller one, or one in another currency, leaves `salary_interval` `null`.

#### How fresh is the data?

Live. Every run reads the boards at run time. `posted_at` is the publish time where the ATS gives one, and the creation time for Lever and Personio.

#### Does it get every job or only 20?

`maxJobsPerCompany` defaults to 20 (newest first) to keep default runs cheap. Set it to `0` for every open role. The company row's `open_jobs_total` always shows the full count.

#### Is the SmartRecruiters "not\_found" reliable?

SmartRecruiters returns the same empty answer for an unknown company ID and for a real company with no open roles. So those companies are reported `not_found` (free) with a note explaining this.

#### Are descriptions included?

Only with `includeDescription: true`. They come back as plain text (HTML stripped), trimmed to `descriptionMaxChars` (3,000 by default, `0` = full). They're off by default because they make boards large: with descriptions, SpaceX's Greenhouse board is 29 MB and Anduril's is 42 MB. Those two are over the 25 MB per-response cap, so a Greenhouse or Workable board that is too large with descriptions is read without them: its roles come back with `description: null`, and the company row's `message` says so. SmartRecruiters is the exception to "one request per company": each description there is one extra request (about 4 per second), so keep `maxJobsPerCompany` modest when you ask for SmartRecruiters descriptions. A description that can't be fetched comes back as `null`. Personio's English feed leaves out descriptions written only in German, so those are read from the board's default-language feed (one extra request per Personio board). A posting with no description in either feed stays `null`, and the company row's `message` says how many.

### Use with AI agents / MCP

This actor is built for agents. It has a flat, stable schema, lenient inputs and plain-English `message` fields. It never fails a run over one bad company or one bad upstream response, and it doesn't charge for boards it can't confirm. Connect the Apify MCP server (`https://mcp.apify.com`) to Claude, ChatGPT, Cursor or any MCP client, add this actor as a tool, then ask things like:

- "What is OpenAI hiring for in London? (board: ashby:openai)"
- "Which of gitlab, stripe and lever:palantir posted remote engineering roles this week?"
- "Track these 20 companies daily and tell me about new sales roles."

The agent fills `companies`, `titleKeywords`, `locationKeywords`, `remoteOnly` or `diffMode` itself. `locationKeywords` understands common spellings (`UK` finds "London, United Kingdom", `US` finds "Austin, TX"). Costs are stated in the input descriptions, so the agent can budget: $0.005 per run, $0.002 per confirmed company (even when its filters match nothing), $0.0015 per job row. Use `salary_source` to tell pay from the ATS's field (`ats`) from pay read out of the posting text (`description`). Agents should read the company rows: `unconfirmed` means "not verified, ask the user for the careers URL", and a **Caution** note in `message` means the board's name only partly matched. Keep `includeDescription` off unless the agent needs the full text, which keeps responses small.

### Sources, limits and legal

Data comes from each vendor's documented public job-board endpoint: the Greenhouse Job Board API, Lever Postings API, Ashby Job Postings API, SmartRecruiters Posting API, Workable's public accounts endpoint, the Recruitee Careers Site API and the Personio XML feed. These are the unauthenticated endpoints companies use to publish their own openings. Requests identify themselves with an honest User-Agent, are paced per host, and back off on HTTP 429/5xx, honouring `Retry-After`. The actor only returns what companies have chosen to publish on their public career boards. It doesn't collect applicant or employee data, and it strips internal fields such as per-job recruiter mailboxes.

**Unofficial — not affiliated with, endorsed by or sponsored by Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Recruitee (Tellent) or Personio.** Company names belong to their owners.

Limits: 500 companies per run, 10,000 roles per board (the first 10,000 the board lists; `open_jobs_total` still shows the full count and the company row notes the limit; the largest real board seen is SmartRecruiters `BoschGroup` with about 4,800), and 25 MB per board response. With descriptions on, Greenhouse boards can pass 25 MB (SpaceX 29 MB, Anduril 42 MB); those are read without descriptions, as above. Lever, Ashby and Recruitee always send descriptions and can't be read without them. OpenAI's Ashby board, one of the demo boards, is 14 MB at about 830 roles, so an Ashby board past roughly 1,500 roles would exceed the cap. A board over the cap gets a free `error` row saying it is too large, and retrying won't help. A Personio feed is also capped at 400,000 XML elements, which is about 10,000 positions. A run finishes and returns what it has before its timeout, even when a job board stops answering: no request is allowed to run past a margin before the timeout, and companies it didn't reach get free `not_processed` rows. With SmartRecruiters descriptions on, roles whose details weren't fetched before the timeout are not returned or charged, and the company row says so. A posting a board lists twice is returned and charged once.

Memory, measured locally as peak working set including the Python runtime (about 75 MB on its own): the default demo `{}` peaks at about 120 MB, because OpenAI's 14 MB board is one of its three. The largest real boards (OpenAI, Palantir, Stripe and Airbnb, all with full descriptions) peak at about 120 MB. SpaceX and Anduril with `includeDescription` (read without descriptions after the first response passes the cap) peak at about 125 MB. Diff mode adds almost nothing, because only one company's state is in memory at a time: 500 boards of 2,000 roles each peak at about 78 MB with or without diff mode. Synthetic boards near the 25 MB cap peak at about 150 MB (emoji-only or CJK text), about 210 MB for a mix of emoji and ASCII, and about 275 MB for the densest case, 570,000 tiny postings (of which only the first 10,000 are read). Synthetic Personio feeds near the caps peak at about 195 MB with descriptions on, including dense feeds that stop at the XML element cap. Those synthetic figures include a second copy of the board held by the test harness. The default 256 MB covers every real board measured; give a run 512 MB only if it targets a board near the 25 MB cap. At the 128 MB minimum, keep to boards of a few MB (not OpenAI).

### Changelog

- **0.1, pre-release fixes, round 5 (2026-09-25)**: A Greenhouse or Workable board that is over the 25 MB cap with descriptions (SpaceX, Anduril) is now read without them. Its rows come back with `description: null` and a note, instead of an `error` row. This also stops auto-detect from sending `SpaceX` to an empty Workable account. An `error` row for a board over the size cap now says retrying won't help, and auto-detect `unconfirmed` rows list the boards that couldn't be checked. Personio feeds are parsed incrementally and capped at 400,000 XML elements: a dense 25 MB feed went from about 520-680 MB peak to under 200 MB. `salary_interval` matches whole words, and a yearly word beats "day": a Greenhouse range titled "Dayton, OH" is no longer per day, and "per annum + 25 days holiday" is yearly. Posting text that lists one pay range per region as bullets (Xero) keeps every range, in order. A bare `$` at a major Canadian city or a `CAN:` location is CAD. Ashby boards over the per-board cap keep the first 10,000 roles listed, as documented.
- **0.1, pre-release fixes, round 4 (2026-09-25)**: Diff state is now stored per company (one key-value record each, plus a small index), so a large watchlist no longer holds every company's state in memory: 500 boards × 2,000 roles went from about 608 MB to about 78 MB peak. A company whose diff state can't be read or saved is named on its company row, in the status message and in `OUTPUT.diff_state_problems`, instead of only in the log. Boards are read up to 10,000 roles, which bounds memory on dense boards near the 25 MB cap. A bare `$` in a job located only in Canada, Australia, New Zealand, Singapore, Hong Kong or Mexico now gets that country's currency (Equinox Toronto: `CAD 17.95 per hour`; Wise Singapore: `SGD`). Salary figures of 20,000+ in major currencies with no stated interval get `salary_interval: "year"` (GitLab's 86 Greenhouse ranges). SmartRecruiters `open_jobs_total` is never below the roles returned. Greenhouse and Ashby rows missing a URL get one built from the board token and job id. Personio permanent roles show `Full-time` / `Part-time` like the other ATSs. The input schema now enforces the 500-company limit.
- **0.1, pre-release fixes (2026-09-25)**: Pay stated only in the posting text is now read when the ATS pay field is empty (Lever, Ashby, Recruitee and Personio always; Greenhouse, Workable and SmartRecruiters with `includeDescription`). The new `salary_source` field (`ats` / `description`) says where it came from. `locationKeywords` matches common spellings (`UK` = `United Kingdom`, `US` = `United States` plus `City, ST`, `California` = `CA`, about 40 countries). SmartRecruiters custom-field pay now reads single amounts, currency symbols and codes glued to numbers, and keeps unparsed values as `salary_text`. Greenhouse placeholder ranges (`USD 1-2`) are ignored. Repeated postings are returned and charged once, and SmartRecruiters paging stops on a repeated page. Non-ASCII and full-width names are transliterated (`Über` becomes `uber`). An empty or blank `companies` list gives a free explanatory row instead of the paid demo. An unreadable run input gives a free row. SmartRecruiters rows whose details weren't fetched before the timeout are no longer billed. Lower peak memory on emoji- or CJK-dense boards. The pricing docs now say that `company-resolved` is charged when filters match 0 roles.
- **0.1 (2026-09-24)**: First release. Supports 7 ATSs with auto-detect and careers-URL parsing. Auto-detect checks the company name each ATS reports and returns free `unconfirmed` rows for empty, placeholder or other-company boards. Also includes salary normalisation, workplace flags from each board's own fields, keyword and remote filters, optional plain-text descriptions, diff mode with free closed rows, free explanatory rows for bad or unknown input and duplicate boards, and pay-per-event pricing (`actor-start`, `company-resolved`, `job-record`) that never charges a company without room for its rows. Verification fixes before release: `remote` is also `true` when a listed location is explicitly remote (Ashby/Lever Hybrid roles with a `Remote (US)` location); SmartRecruiters pay from custom fields and posting compensation; Personio descriptions fall back to the default-language feed, and Personio internships and working-student roles keep that status; Greenhouse multi-range pay picks the range named after the job's location even when range titles are sentences; diff mode no longer loses new roles held back by `maxJobsPerCompany`/`maxRecords`; lone UTF-16 surrogates in upstream data or input can't fail a run or a batch; `company-resolved` is charged only after the company's first rows are stored; `Retry-After` up to 60 s is honoured in full; requests stop at the run's timeout margin; lower peak memory on large boards.

# Actor input Schema

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

One entry per item: a board token/slug (gitlab), an ATS-prefixed token (lever:palantir, ashby:openai, sr:Equinox, workable:blueground, recruitee:bunq, personio:1komma5grad), a careers URL (boards.greenhouse.io/gitlab, jobs.lever.co/palantir, jobs.ashbyhq.com/openai, ...) or a company name (Scale AI). Entries without a prefix or URL are auto-detected across Greenhouse, Lever, Ashby, Workable, Recruitee, Personio and SmartRecruiters and checked against the company name each ATS reports; a board that is empty, holds placeholder postings or belongs to another company comes back as a free 'unconfirmed' row. Prefixes and URLs pin an exact board. Case and spacing don't matter; a comma inside an item is part of the name. Up to 500 per run. Leave it out for the demo (gitlab, palantir, openai); an empty list or only blank entries gives one free explanatory row and no demo. Cost: $0.005 once per run (actor-start, when the first board answers) + $0.002 per company whose board is found and confirmed (also when your filters leave 0 of its roles) + $0.0015 per job row. Not-found, unconfirmed, duplicate and blank entries are free.

## `ats` (type: `string`):

Which job-board system plain tokens belong to. 'auto' probes Greenhouse, Lever, Ashby, Workable, Recruitee, Personio and SmartRecruiters and keeps the board whose company name matches (then the one with the most open roles). Prefixes and URLs in the list always win.

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

Newest open roles returned per company (0 = all). Each returned job costs $0.0015 (plus $0.005 per run and $0.002 per confirmed company).

## `maxRecords` (type: `integer`):

Stop after this many job records across all companies (0 = no limit). Caps the job-record cost of a run.

## `titleKeywords` (type: `array`):

Keep only jobs whose title contains any of these words (case-insensitive), e.g. engineer, data, sales. Filtered-out jobs are not charged; the company's $0.002 is still charged when its board is confirmed, even if no job matches.

## `locationKeywords` (type: `array`):

Keep only jobs whose location contains any of these (case-insensitive), e.g. London, UK, Remote. Common spellings count as one: UK = United Kingdom = Great Britain (plus England/Scotland/Wales), US = USA = United States plus US places written 'City, ST' (Austin, TX), California = CA, NYC = New York, Canada plus 'Toronto, ON', and about 40 countries' names and codes (Germany = Deutschland). A location written only as a city (San Francisco) matches only that city name. Filtered-out jobs are not charged; the company's $0.002 still is.

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

Keep only jobs that can be done remotely: marked remote by the board's own workplace field (ATS remote flag, or a Greenhouse field such as Workplace Type / Location Type), or listing an explicitly remote location (e.g. an Ashby or Lever Hybrid role with a 'Remote (US)' location), or, on boards without a workplace field, whose location says Remote.

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

Add the plain-text job description (HTML stripped, trimmed to Description max characters). Off by default to keep records small. It also lets pay written only in the posting text be read on Greenhouse, Workable and SmartRecruiters (Lever, Ashby, Recruitee and Personio always send the text; salary\_source = 'description' marks such rows). On SmartRecruiters this adds one request per returned job, which also fills the salary fields from the posting's published compensation. On Personio, descriptions missing from the English feed are taken from the board's default-language (often German) feed. A Greenhouse or Workable board that is over the 25 MB response cap with descriptions (e.g. SpaceX) is returned without them (description null, noted on its company row).

## `descriptionMaxChars` (type: `integer`):

Trim each description to this many characters (0 = full text). Only used with Include job descriptions.

## `diffMode` (type: `boolean`):

Remember each company's open roles between runs (in a named key-value store keyed by this input, or by Diff key) and flag jobs as new / unchanged, plus a free row for each job that closed. The first run records the baseline. A new job not returned because of Max jobs per company, Max job records or the charge limit is reported as new by the next run. The flags and closed rows are free.

## `onlyChanges` (type: `boolean`):

With diff mode on: return only new jobs (+ free closed rows) instead of every open job flagged new/unchanged. Great for daily scheduled monitoring.

## `diffKey` (type: `string`):

Name for this monitor's saved state. By default the state is keyed by a hash of the companies + filters, so editing the list starts a new baseline; set a fixed name (e.g. 'my-watchlist') to keep history while you add or remove companies.

## Actor input object example

```json
{
  "companies": [
    "gitlab",
    "lever:palantir",
    "https://jobs.ashbyhq.com/openai"
  ],
  "ats": "auto",
  "maxJobsPerCompany": 20,
  "remoteOnly": false,
  "includeDescription": false,
  "descriptionMaxChars": 3000,
  "diffMode": false,
  "onlyChanges": true
}
```

# Actor output Schema

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

No description

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

No description

## `companies` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "companies": [
        "gitlab",
        "lever:palantir",
        "https://jobs.ashbyhq.com/openai"
    ],
    "maxJobsPerCompany": 20
};

// Run the Actor and wait for it to finish
const run = await client.actor("bigdavidson/ats-jobs-api").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": [
        "gitlab",
        "lever:palantir",
        "https://jobs.ashbyhq.com/openai",
    ],
    "maxJobsPerCompany": 20,
}

# Run the Actor and wait for it to finish
run = client.actor("bigdavidson/ats-jobs-api").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": [
    "gitlab",
    "lever:palantir",
    "https://jobs.ashbyhq.com/openai"
  ],
  "maxJobsPerCompany": 20
}' |
apify call bigdavidson/ats-jobs-api --silent --output-dataset

```

## MCP server setup

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

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/tXd4nrl4Dq0iRVwwy/builds/S2gCdm5smqYcipdNK/openapi.json
