# Greenhouse Scraper · Lever, Ashby & Workable Jobs + Salary (`thequietstack/ats-direct-jobs`) Actor

Jobs straight from the official Greenhouse, Lever, Ashby, Workable, Recruitee and Personio job-board APIs, in one schema, with parsed salary min/max/currency/period. Keyword, location, remote, department and date filters. maxResults is a hard cap. Missing or empty boards are never charged.

- **URL**: https://apify.com/thequietstack/ats-direct-jobs.md
- **Developed by:** [TheQuietStack](https://apify.com/thequietstack) (community)
- **Categories:** Jobs
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $3.00 / 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

## Greenhouse Scraper · Lever, Ashby & Workable Jobs + Salary

**Greenhouse scraper, Lever scraper and Ashby/Workable job scraper in one:** jobs straight from the company job boards, one schema, salary parsed into numbers.

Get every open job of the companies you care about **straight from their applicant tracking system**: Greenhouse, Lever, Ashby, Workable, Recruitee and Personio. One schema for all six, with the **salary range parsed into numbers** (`salaryMin`, `salaryMax`, `salaryCurrency`, `salaryPeriod`), filters for keywords, location, remote, department and posting date, and a **hard result cap that really stops the run**.

No login, no API key, no browser. The Actor reads the official public job-board APIs the companies themselves publish for their career pages:

| ATS | Endpoint used |
|---|---|
| Greenhouse | `boards-api.greenhouse.io/v1/boards/<slug>/jobs?content=true&pay_transparency=true` |
| Lever | `api.lever.co/v0/postings/<slug>?mode=json` |
| Ashby | `api.ashbyhq.com/posting-api/job-board/<slug>?includeCompensation=true` |
| Workable | `apply.workable.com/api/v1/widget/accounts/<slug>?details=true` |
| Recruitee | `<slug>.recruitee.com/api/offers/` |
| Personio | `<slug>.jobs.personio.de/xml?language=en` (and `?language=de` when a posting exists only in German) |

### What you get

- **Salary as numbers, with a receipt.** First from the ATS's own pay fields (Greenhouse pay transparency ranges, Lever `salaryRange`, Ashby compensation tiers, Recruitee `salary`, Personio `salaryInformation`; Workable's public feed has no pay field, so there it is always the text). If the company only wrote the range into the posting text, it is parsed from there, and `salaryText` shows the exact sentence it came from. `salarySource` says which (`structured` or `text`). Equity, bonuses, welcome bonuses, relocation money, meal vouchers, learning budgets, "per session" rates and "$100B market" style numbers are ignored. Austrian-style minimums ("Mindestgehalt € 3.200,- brutto monatlich", "Minimum EUR 48,000 gross annually") fill `salaryMin` only, because the posting names no upper bound. Nothing is estimated: no range in the posting → the salary fields are `null`.
- **Pay zones kept.** When a company lists several ranges (pay zones, tiers), `salaryMin`/`salaryMax` span them and every range is in `salaryRanges` with its label.
- **`maxResults` is a hard cap.** Set 50 and you get 50 rows, even with several boards running in parallel. Each board is sorted newest first, so a small cap keeps the freshest jobs.
- **You pay for jobs, not for attempts.** A board that does not exist (HTTP 404; on Personio a redirect away from the board), a board that exists but has 0 open jobs, a failed request, a filtered-out job or a job you already received (`onlyNew`) is listed in the run summary and never charged. Missing and empty boards are reported separately, so a typo in a slug never looks like "no openings".
- **Only new jobs, on a schedule.** Turn on `onlyNew` and schedule the Actor daily: it remembers the job IDs it returned (per board, in a named key-value store in your own account) and next time returns only postings it has not shown you before.
- **Paste whatever you have.** `greenhouse:stripe`, `personio:prewave`, a board URL (`https://jobs.lever.co/zoox`, `https://jobs.ashbyhq.com/ramp`, `https://apply.workable.com/skroutz`, `https://channable.recruitee.com`, `https://prewave.jobs.personio.de`, EU hosts), a company careers page (a linked board of any of the six is detected in its HTML), or just a slug (tried on Greenhouse, Lever, Ashby, Workable, Recruitee, then Personio).
- **Strong in Europe.** Recruitee (Netherlands) and Personio (Germany, Austria) are where European companies post; both carry a structured pay field, and Austrian postings state a legal minimum salary that is parsed from the text.
- **Polite by design.** At most one request per second per ATS service (all Recruitee and all Personio company subdomains count as one service each). One request returns a company's whole board (Personio: at most two, English and German).

### Input example

```json
{
    "boards": [
        "greenhouse:airbnb",
        "https://jobs.lever.co/zoox",
        "ashby:ramp",
        "https://www.duolingo.com/careers",
        "personio:prewave",
        "recruitee:channable"
    ],
    "keywords": ["engineer", "data"],
    "locations": ["New York", "Remote"],
    "remote": "any",
    "newSinceDays": 14,
    "onlyWithSalary": true,
    "maxResults": 200
}
```

Watch list, only new postings, run daily:

```json
{
    "boards": ["ashby:openai", "greenhouse:anthropic", "lever:palantir"],
    "departments": ["Engineering"],
    "onlyNew": true,
    "seenStoreName": "my-ai-labs-watchlist",
    "includeDescription": false
}
```

Via the API you can pass objects and set the company name yourself:

```json
{ "boards": [{ "ats": "lever", "slug": "palantir", "company": "Palantir" }] }
```

### Output example (one row, shortened)

```json
{
    "ats": "ashby",
    "companySlug": "ramp",
    "jobId": "465799ed-c58b-4c6a-a290-2e95b52ef0bb",
    "title": "Tech Lead and Manager (TLM), Production Engineering",
    "company": "Ramp",
    "url": "https://jobs.ashbyhq.com/ramp/465799ed-c58b-4c6a-a290-2e95b52ef0bb",
    "location": "New York, NY (HQ)",
    "locations": ["New York, NY (HQ)"],
    "country": "USA",
    "remote": true,
    "workplaceType": "remote",
    "department": "Engineering",
    "team": "Engineering",
    "employmentType": "FULL_TIME",
    "postedAt": "2026-09-28T23:10:15.801Z",
    "updatedAt": null,
    "salaryMin": 240000,
    "salaryMax": 330000,
    "salaryCurrency": "USD",
    "salaryPeriod": "YEAR",
    "salarySource": "structured",
    "salaryRanges": null,
    "salaryText": null,
    "descriptionText": "ABOUT RAMP\n\nRamp is building ...",
    "descriptionHtml": null,
    "applyUrl": "https://jobs.ashbyhq.com/ramp/465799ed-c58b-4c6a-a290-2e95b52ef0bb/application",
    "hasSalary": true,
    "scrapedAt": "2026-09-30T10:49:28.604Z"
}
```

| Field | Meaning |
|---|---|
| `jobId` + `ats` | Stable ID of the posting in its ATS. Used for de-duplication within a run and across runs (`onlyNew`). |
| `salaryMin` / `salaryMax` | Numbers in the posting's own currency and period. No conversion. A single stated amount fills `salaryMax` (and `salaryMin` unless it says "up to"). |
| `salaryPeriod` | `YEAR`, `MONTH`, `WEEK`, `DAY` or `HOUR`. |
| `salarySource` | `structured` = the ATS pay field; `text` = parsed from the posting, see `salaryText`. |
| `salaryRanges` | All ranges when the posting lists several (pay zones, tiers). |
| `remote` / `workplaceType` | From the ATS's workplace field; otherwise `remote` when a location says remote/anywhere. |
| `employmentType` | `FULL_TIME`, `PART_TIME`, `CONTRACT`, `INTERN`. Greenhouse has no standard field for it, so it is only set when the company added it as a custom field. Personio working students are `PART_TIME`. |
| `postedAt` | Greenhouse `first_published`, Lever `createdAt`, Ashby `publishedAt`, Workable `published_on` (date only), Recruitee `published_at`, Personio `createdAt`. |
| `locations` | Every location of the posting. Workable's feed repeats a job once per location; those copies are merged into one row with all locations. |

The run summary (key-value store record `SUMMARY`) lists per board: jobs on the board, jobs that matched your filters, jobs written; plus boards not found, empty boards, failed requests, careers pages without a detectable board, counts per filter, and how many events were charged.

### How much salary data is there?

Measured on 30 Sep 2026 across 40 well-known company boards (20 Greenhouse, 10 Lever, 10 Ashby): **about 61 % of Greenhouse jobs, 62 % of Lever jobs and 69 % of Ashby jobs came with a salary range**, and about a fifth of those ranges existed **only in the posting text**, not in the ATS pay field.

The same day, 10 company boards with open jobs per European-heavy ATS:

| ATS | Jobs | With salary | From pay field / text | Boards with any salary |
|---|---|---|---|---|
| Workable | 187 | 19 (10 %) | 0 / 19 | 5 of 10 |
| Recruitee | 238 | 17 (7 %) | 17 / 0 | 4 of 10 |
| Personio | 370 | 12 (3 %) | 2 / 10 | 5 of 10 |

How much you get depends on the company and the country, not on the ATS: US postings almost always carry a range (pay transparency laws), many European ones do not. Austrian postings carry a legal minimum (one Austrian Personio board: 8 of 8 jobs), while one German board with 319 jobs had a range on only 1. Use `onlyWithSalary` to keep just the jobs that state pay.

### Compared with other ways to get these jobs

- **Job aggregators and pre-built job databases** cover far more companies and ATS platforms (including Workday and others). This Actor covers only Greenhouse, Lever, Ashby, Workable, Recruitee and Personio, and only the companies you name, but reads them **live at run time**, so a posting closed this morning is not in your data.
- **Single-ATS scrapers** need one run and one output format per ATS. Here all six share one schema.
- **Generic scrapers** often stop at the raw salary text. Here you get numbers you can sort and filter on, plus the sentence they came from.

If you need Workday, iCIMS or SmartRecruiters, or discovery of companies you do not know yet, a broader tool is a better fit. (SmartRecruiters was tested and left out on purpose: its public API answers any company name, real or invented, with "0 jobs", so a typo cannot be told apart from a quiet board, and every job needs a second request for its text.)

### Pricing

Pay per event: one event per job returned and one per company that returned at least one job. Boards that are missing, empty or fail, and jobs you filtered out or already saw, cost nothing. Your "Max cost per run" is respected: the Actor stops before pushing a row it cannot charge.

### Limits and honesty

- Only boards the company publishes publicly. Nothing behind a login, no application forms, no applicant data.
- Careers-page detection reads the page HTML. Boards that are loaded only by JavaScript are not found; use the board URL or `ats:slug` instead. The summary tells you when this happens.
- Lever answers some company slugs with an empty list (HTTP 200, 0 jobs) instead of 404. Those are reported as empty, not as missing. Workable and Personio answer many existing accounts with 0 jobs (HTTP 200); also reported as empty, never as missing.
- Personio's feed carries a posting's text only in the language it was written in. The Actor reads the English feed and, only if a posting came back without text, the German one.
- Recruitee boards on a company's own domain (without `.recruitee.com`) are not detected from the careers page; pass `recruitee:<slug>`.
- Some company websites refuse non-browser clients (HTTP 403/404/429). Careers-page detection then fails with that status in the summary; the board itself is unaffected, pass its URL or `ats:slug`.
- Salary text parsing covers the common English and German phrasings (`$120,000 - $150,000`, `$120-150k per year`, `CAD $79,600 - CAD $99,500`, `70.000 € - 90.000 € brutto`, `$37.63/hr—$77.48/hr`, `€ 3.200,- brutto monatlich`, `Jahresbruttogehalt von mindestens EUR 45.000`). Unusual wordings stay `null` rather than guessed.

# Actor input Schema

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

One per line. Accepted: <code>greenhouse:stripe</code>, <code>lever:spotify</code>, <code>ashby:openai</code>, <code>workable:huggingface</code>, <code>recruitee:bunq</code>, <code>personio:1komma5grad</code>; any board URL (<code>https://boards.greenhouse.io/figma</code>, <code>https://jobs.lever.co/zoox</code>, <code>https://jobs.ashbyhq.com/ramp</code>, <code>https://apply.workable.com/skroutz</code>, <code>https://channable.recruitee.com</code>, <code>https://prewave.jobs.personio.de</code>, EU hosts too); a company careers page URL (a board of any of the six linked in its HTML is detected); or a bare slug (tried on Greenhouse, Lever, Ashby, Workable, Recruitee, then Personio). Via API you can also pass objects: <code>{"ats": "lever", "slug": "palantir", "company": "Palantir"}</code>.

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

Keep jobs whose title contains at least one of these (case-insensitive). Empty = all jobs.

## `keywordsInDescription` (type: `boolean`):

Off: keywords are matched against the title only (precise). On: title or full description.

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

Drop jobs whose title contains any of these, e.g. intern, senior, manager.

## `locations` (type: `array`):

Keep jobs whose location, any additional location or country contains one of these, e.g. London, Germany, New York, Remote.

## `remote` (type: `string`):

any = no remote filter. only = remote jobs only. exclude = no remote jobs. include = remote jobs pass the location filter too.

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

Keep jobs whose department or team contains one of these, e.g. Engineering, Sales, Design.

## `employmentTypes` (type: `array`):

Empty = all. Greenhouse boards only state this when the company adds it as a custom field; such jobs have employmentType null and are dropped by this filter. Personio working students count as PART\_TIME.

## `newSinceDays` (type: `integer`):

Only jobs first published within this many days (Greenhouse first\_published, Lever createdAt, Ashby publishedAt, Workable published\_on, Recruitee published\_at, Personio createdAt). Empty = any age.

## `onlyWithSalary` (type: `boolean`):

Drop jobs where neither the ATS pay fields nor the posting text contain a pay range.

## `maxResults` (type: `integer`):

The run stops at exactly this many jobs across all boards, even with parallel boards. Newest jobs of each board come first.

## `maxResultsPerCompany` (type: `integer`):

Optional cap per board, newest first. Empty = no per-company cap.

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

Remembers every job ID this Actor has returned (per board) in a named key-value store in your account. Schedule the Actor daily with this on to get only new postings; seen jobs are never charged again.

## `seenStoreName` (type: `string`):

Named key-value store used by "Only jobs not seen in earlier runs". Use different names for independent watch lists.

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

Plain-text job description. Turn off for smaller datasets.

## `includeDescriptionHtml` (type: `boolean`):

Also return the original HTML.

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

Requests to one ATS service are always spaced to at most 1 per second, whatever this value (all Recruitee and all Personio company subdomains count as one service each).

## `proxyConfiguration` (type: `object`):

Not needed: these are official public APIs. Leave off unless your network requires it.

## Actor input object example

```json
{
  "boards": [
    "greenhouse:airbnb",
    "https://jobs.lever.co/zoox",
    "ashby:ramp",
    "https://prewave.jobs.personio.de"
  ],
  "keywords": [],
  "keywordsInDescription": false,
  "remote": "any",
  "onlyWithSalary": false,
  "maxResults": 1000,
  "onlyNew": false,
  "seenStoreName": "ats-direct-jobs-seen",
  "includeDescription": true,
  "includeDescriptionHtml": false,
  "concurrency": 3,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

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

One row per open job with parsed salary range.

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

Per board: jobs on board, matched, written. Boards not found, empty or failed, filter counts, billing counts. None of those are charged.

# 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 = {
    "boards": [
        "greenhouse:airbnb",
        "https://jobs.lever.co/zoox",
        "ashby:ramp",
        "https://prewave.jobs.personio.de"
    ],
    "keywords": []
};

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

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

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

```

## Python example

```python
from apify_client import ApifyClient

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

# Prepare the Actor input
run_input = {
    "boards": [
        "greenhouse:airbnb",
        "https://jobs.lever.co/zoox",
        "ashby:ramp",
        "https://prewave.jobs.personio.de",
    ],
    "keywords": [],
}

# Run the Actor and wait for it to finish
run = client.actor("thequietstack/ats-direct-jobs").call(run_input=run_input)

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

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

```

## CLI example

```bash
echo '{
  "boards": [
    "greenhouse:airbnb",
    "https://jobs.lever.co/zoox",
    "ashby:ramp",
    "https://prewave.jobs.personio.de"
  ],
  "keywords": []
}' |
apify call thequietstack/ats-direct-jobs --silent --output-dataset

```

## MCP server setup

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

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/PWzMtwDxsfLIicXwS/builds/WPYQ1w2DMSw1iLkQa/openapi.json
