# Job Listings from Career Pages (Greenhouse, Lever, Ashby) (`groundtruth/ats-job-listings`) Actor

Read any company's open jobs straight from the applicant-tracking system behind its careers page. One schema across Greenhouse, Lever and Ashby, with salaries parsed into numbers. No login, no scraping, no LinkedIn.

- **URL**: https://apify.com/groundtruth/ats-job-listings.md
- **Developed by:** [Ground Truth](https://apify.com/groundtruth) (community)
- **Categories:** Jobs, AI, MCP servers
- **Stats:** 1 total users, 0 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 job postings

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

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

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

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

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Job Listings from Company Career Pages (Greenhouse, Lever, Ashby)

Read any company's open jobs **straight from the applicant-tracking system its
careers page runs on**, in one consistent schema, with salaries parsed into
numbers.

Two ways in. **Search 7,500 companies by name**, or paste a careers URL.

```
company_search: "anthropic"        -> finds the board, reads every open role
boards: ["https://boards.greenhouse.io/stripe", "ashby:ramp", "spotify"]
```

And two ways to run it:

| mode | returns | good for |
|---|---|---|
| `all` | every open job | a one-off pull, a first sync |
| **`new`** | **only postings you have not been sent before** | **running it every morning** |

In `new` mode a quiet day costs you nothing but the Actor start, because you
are charged per job returned and there are none. That is what makes this
affordable as a daily feed rather than a one-off query.

### The directory: 7,500 companies, and why it has to exist

Greenhouse, Lever, Ashby and Workable all publish open, documented job board
APIs. All four are **useless unless you already know the company's board
name** — that Anthropic's is `anthropic`, that Ramp's is `ramp`, that Mistral's
is `mistral.ai`.

**None of them publishes a directory of its customers.** Checked 2026-08-24:
`sitemap.xml` on job-boards.greenhouse.io, boards.greenhouse.io,
jobs.lever.co and apply.workable.com all 404 or redirect. And no vendor could
publish one anyway, because no vendor knows about the others.

So this Actor ships one: **7,500 boards, every one confirmed against the
vendor's own API**, with the date it was confirmed. `company_search` uses it,
and it also means a bare company name resolves instantly instead of costing
three board downloads while the tool guesses which system you meant.

### Why this and not a LinkedIn or Indeed scraper

Because a job aggregator built on scraping breaks, and this does not.

Greenhouse, Lever and Ashby each publish a job board API **so that third
parties can build job sites from their customers' openings**. Lever's own
documentation says it in one line: "This API is designed to help you create a
job site." Ashby's says "if you host your own careers page, you can use this
data to populate it."

So there is no bot protection to fight, no login, no cookies to keep alive, no
rate-limit arms race, and nothing that can be switched off because it was
never sneaking in. The data comes from the employer, first-hand, the moment
they publish it, which is also **before** it reaches the big job boards.

`SOURCES.md` records the vendors' exact words, their crawl directives, and the
date each was checked.

**SmartRecruiters is not covered, on purpose.** Its API tells crawlers
`User-agent: LinkedInBot / Allow`, then `User-agent: * / Disallow`. It permits
LinkedIn by name and refuses everyone else, so this tool does not read it. It
seemed worth saying out loud which door is shut.

### Covered

| System | Who is on it | Reads |
|---|---|---|
| **Greenhouse** | Stripe, Figma, Airbnb, Cloudflare, Databricks, Dropbox | title, department, location, dates, and pay where the board has pay transparency on |
| **Lever** | Spotify, Palantir | title, department, team, employment type, workplace type, ISO country, date |
| **Ashby** | Ramp, OpenAI, Linear, Vercel | everything above plus city, remote flag, and **structured pay** |

Workable and Personio are next; both endpoints are verified and open.

### Salaries, parsed properly

Ashby prints pay as `"$211.4K - $290.6K"`. Greenhouse prints it in cents,
sometimes as several bands at once. Neither is filterable.

You get:

```json
"salary_min": 211400.0,
"salary_max": 290600.0,
"salary_currency": "USD",
"salary_text": "$211.4K - $290.6K"
```

The parser refuses more often than it guesses, and that is the design:

- `$50 - $80 / hour` gives **no salary**. An hourly rate is a different
  quantity and reporting it as an annual figure would be a confident lie.
- `600 000 kr` gives **no salary**. `kr` is Swedish, Norwegian *and* Danish.
- `0.5% – 1.75% • Offers Equity` gives **no salary**. Equity is not pay.
- A posting with several bands in different currencies is never spanned.
  Airbnb prints a German band in EUR beside a US band in USD; taking the
  minimum of one and the maximum of the other would invent a range that
  exists nowhere.

`salary_text` always carries the original string, so nothing is lost when the
parser declines.

Every parsed figure is tested against the employer's own structured
`baseSalary` block, published for Google to index. Not against a number
someone typed into a test.

### Output

One record per job, identical fields from every system:

| Field | Meaning |
|---|---|
| `title` | Job title |
| `company` | Employer. Greenhouse gives a display name; Lever and Ashby give the board name |
| `ats`, `board` | Which system and which board this came from |
| `job_id` | The posting's id on that system |
| `department`, `team` | As the employer files it |
| `employment_type` | full\_time, part\_time, contract, temporary, internship, apprenticeship, volunteer, other |
| `workplace_type` | on\_site, remote, hybrid, unknown |
| `is_remote` | Boolean, where the system states one |
| `location_raw`, `location_city`, `location_country` | Location as written, and split where the system splits it |
| `salary_min`, `salary_max`, `salary_currency`, `salary_text` | See above |
| `posted_date`, `updated_date` | ISO dates |
| `first_seen` | The date this feed first saw the posting. Only in `new` mode, and the one fact the vendors cannot give you: they publish when a job was posted, not when you first heard of it |
| `description_text` | Full description, **only if you ask for it** |
| `apply_url` | Where a candidate applies |
| `verify_url` | The employer's own posting. Open it and check any field |

Employment and workplace types are normalised to a single vocabulary. Three
systems use about thirty words between them for eight real distinctions, and
somebody comparing boards needs eight buckets, not thirty.

### Input

| Field | Meaning |
|---|---|
| `company_search` | A company name to look up in the built-in directory. |
| `boards` | One entry per company. A careers URL, or `system:name`, or just the board name. Up to 500. |
| `mode` | `all`, or `new` for only what has appeared since your last run. |
| `feed_id` | Names your feed, so two schedules do not consume each other's "already seen". |
| `max_boards` | Caps how many search matches are read. Default 25. |
| `limit` | Total jobs across every company. 1 to 50,000. You are charged per job. |
| `include_description` | Off by default. A description is most of a record's size and you pay per record. |

**A bare company name is now free to resolve** if it is one of the 7,500 in
the directory. If it is not, the tool falls back to asking each system in turn,
which costs a whole board download per system tried. `greenhouse:stripe` is
always exactly one request.

### What it does not collect

**No personal data, and that took two pieces of code rather than one rule.**

Recruiter names and contact details are never mapped. That part is the schema.

The harder part is prose: job descriptions genuinely say things like
"questions? email careers@acme.com", and a column of those is a marketing
list. So **email addresses and phone numbers are stripped out of every
description** before it is published, whether you asked for descriptions or
not.

Unlisted Ashby postings are dropped, because Ashby's documentation says
plainly that they should not be shown publicly.

### Reliability

**One company failing does not lose the rest.** A run of 200 companies where
three have closed their boards returns the other 197 and says what happened to
the three.

A board with no open jobs returns nothing and the log says so in those words.
That is an answer, not a bug, and the tool does not dress it up as a failure.

If a system changes the shape of its response, the run says which system and
why. It will never return an empty dataset that reads as "this company is not
hiring".

# Actor input Schema

## `company_search` (type: `string`):

Find companies in the built-in directory of job boards and read all of them. Try 'stripe', 'anthropic', or a word that several companies share. Leave empty if you are naming boards directly below.

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

One entry per company. Paste a careers URL (https://boards.greenhouse.io/stripe, https://jobs.ashbyhq.com/ramp), or write system:name (ashby:ramp), or just the company's board name (stripe). Up to 500 per run.

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

'all' returns every open job. 'new' returns only postings this feed has not sent you before, which is what makes a daily run cheap: a quiet day costs you nothing but the Actor start.

## `feed_id` (type: `string`):

Keeps separate schedules from interfering. Two pipelines using different feed names each get their own 'what I have already seen'. Letters, numbers, dashes and underscores.

## `max_boards` (type: `integer`):

Only applies to 'Search companies by name'. Caps how many matching boards are read.

## `limit` (type: `integer`):

Total jobs to return across every company. You are charged per job returned.

## `include_description` (type: `boolean`):

Off by default, because a description is most of a record's size and you are charged per record. Email addresses and phone numbers are removed from descriptions either way.

## Actor input object example

```json
{
  "company_search": "anthropic",
  "boards": [
    "https://boards.greenhouse.io/stripe",
    "https://jobs.ashbyhq.com/ramp",
    "spotify"
  ],
  "mode": "all",
  "feed_id": "default",
  "max_boards": 25,
  "limit": 1000,
  "include_description": false
}
```

# Actor output Schema

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

No description

## `jobsCsv` (type: `string`):

No description

## `runDetails` (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 = {
    "boards": [
        "https://boards.greenhouse.io/stripe",
        "https://jobs.ashbyhq.com/ramp",
        "spotify"
    ],
    "limit": 1000
};

// Run the Actor and wait for it to finish
const run = await client.actor("groundtruth/ats-job-listings").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": [
        "https://boards.greenhouse.io/stripe",
        "https://jobs.ashbyhq.com/ramp",
        "spotify",
    ],
    "limit": 1000,
}

# Run the Actor and wait for it to finish
run = client.actor("groundtruth/ats-job-listings").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": [
    "https://boards.greenhouse.io/stripe",
    "https://jobs.ashbyhq.com/ramp",
    "spotify"
  ],
  "limit": 1000
}' |
apify call groundtruth/ats-job-listings --silent --output-dataset

```

## MCP server setup

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

```

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/JIVH1NsDTsLDz9gmf/builds/yk3ruh9blQsvXcqkq/openapi.json
