# ATS Jobs Feed (Greenhouse, Lever, Ashby & more) (`vellumlabs/ats-job-boards-feed`) Actor

Job listings from the public JSON APIs of Greenhouse, Lever, Ashby, Workable and SmartRecruiters. One clean record per job (Markdown description, department, locations, remote flag, salary when published, apply URL) plus a diff mode that flags new, updated and closed jobs between runs.

- **URL**: https://apify.com/vellumlabs/ats-job-boards-feed.md
- **Developed by:** [Vellum Kasane](https://apify.com/vellumlabs) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 job exporteds

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 Feed (Greenhouse, Lever, Ashby, Workable, SmartRecruiters)

Get **every open job of any company that runs its careers page on Greenhouse, Lever, Ashby, Workable or SmartRecruiters** as clean, structured records, straight from the vendors' public job-board JSON APIs. No HTML scraping, no browser, no proxies: a run over four boards finishes in a few seconds.

Each job comes out as one record with the **description converted to Markdown**, department and team, all locations, an inferred **remote flag**, employment type, the **published salary range** when the company discloses one, the hosted job URL and the apply URL. Turn on **diff mode** and a scheduled run becomes a change feed: every record is marked `new`, `updated` or `closed` compared with the previous run, so you only pay for what changed.

Built for:

- **Job boards and aggregators** that want one normalised schema across five ATS vendors instead of five parsers.
- **Recruiting and sales intelligence**: watch competitors' or target accounts' hiring (`new` engineering roles in Berlin, closed positions, salary bands).
- **AI agents and MCP clients** that need job descriptions as Markdown (call it from the [Apify MCP server](https://mcp.apify.com), read the dataset).
- **Alerts**: schedule hourly with `diff: true`, plug the dataset into Slack, email or a webhook via Apify integrations.

### How it works

1. Give the Actor a list of companies. Each entry is `{ "ats": "greenhouse", "slug": "gitlab" }` or just the careers URL (`https://jobs.lever.co/spotify`, `https://jobs.ashbyhq.com/linear`, `https://apply.workable.com/blueground/`, `https://careers.smartrecruiters.com/Bosch`). The ATS and the board slug are detected from the URL.
2. The Actor calls the vendor's public JSON endpoint for that board (see **Data sources**), normalises every job and converts the HTML description to GitHub-flavoured Markdown.
3. Filters (keywords, location, remote, department) are applied, then records are written to the dataset, one per job, and billed per record.
4. In diff mode the Actor keeps a compact snapshot of every job it has seen in a named key-value store and emits only changes.

### Input

| Field              | Type     | Default               | Notes                                                                                                                                                                                                                          |
| ------------------ | -------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `companies`        | array    | required              | `{ ats, slug }` objects and/or board URLs. `ats` is one of `greenhouse`, `lever`, `ashby`, `workable`, `smartrecruiters`. Lever EU boards: `{ "ats": "lever", "slug": "acme", "region": "eu" }` or the `jobs.eu.lever.co` URL. |
| `keywords`         | string\[] | `[]`                  | Keep jobs whose title or description contains any of these (case-insensitive).                                                                                                                                                 |
| `locationContains` | string\[] | `[]`                  | Keep jobs whose location contains any of these.                                                                                                                                                                                |
| `remoteOnly`       | boolean  | `false`               | Keep only jobs with `remote: true`.                                                                                                                                                                                            |
| `departments`      | string\[] | `[]`                  | Keep jobs whose department / team contains any of these.                                                                                                                                                                       |
| `maxJobs`          | integer  | 500                   | Hard cap on exported records per run (all boards). Caps your cost.                                                                                                                                                             |
| `diff`             | boolean  | `false`               | Diff mode: mark `new` / `updated` / `closed` versus the previous run.                                                                                                                                                          |
| `emitUnchanged`    | boolean  | `false`               | Diff mode: also export unchanged jobs (status `unchanged`).                                                                                                                                                                    |
| `stateStoreName`   | string   | `ats-jobs-feed-state` | Named key-value store holding the diff state. Use one per schedule / filter set.                                                                                                                                               |
| `includeHtml`      | boolean  | `false`               | Add the original description HTML (`description_html`).                                                                                                                                                                        |

Example input:

```json
{
    "companies": [
        { "ats": "greenhouse", "slug": "greenhouse" },
        "https://jobs.ashbyhq.com/posthog",
        { "ats": "workable", "slug": "blueground" },
        { "ats": "smartrecruiters", "slug": "smartrecruiters" }
    ],
    "keywords": ["engineer"],
    "remoteOnly": true,
    "maxJobs": 200,
    "diff": true
}
```

Finding the slug: it is the token in the company's careers URL, e.g. `boards.greenhouse.io/**gitlab**`, `job-boards.greenhouse.io/**gitlab**`, `jobs.lever.co/**spotify**`, `jobs.ashbyhq.com/**linear**`, `apply.workable.com/**blueground**`, `careers.smartrecruiters.com/**Bosch**`. Unknown boards are reported per board in `SUMMARY.json` and skipped; the run still succeeds for the other boards.

### Output

One dataset item per job (you can download it as JSON, CSV, Excel or HTML):

```json
{
    "company": "PostHog",
    "company_slug": "posthog",
    "ats": "ashby",
    "board_url": "https://api.ashbyhq.com/posting-api/job-board/posthog?includeCompensation=true",
    "job_id": "2f1e5d7c-3c1e-4a0f-9b1d-6f0a3d2c1b9e",
    "title": "Product Engineer",
    "department": "Engineering",
    "team": "Product",
    "location": "Remote - EMEA",
    "locations": ["Remote - EMEA", "Remote - US"],
    "remote": true,
    "workplace_type": "remote",
    "employment_type": "Full-time",
    "salary": {
        "min": 150000,
        "max": 220000,
        "currency": "USD",
        "interval": "year",
        "summary": "$150K - $220K",
        "source": "ats"
    },
    "description_markdown": "## About the role\n\nYou will ship features end to end...\n\n- Own a product area\n- Talk to users every week",
    "job_url": "https://jobs.ashbyhq.com/posthog/2f1e5d7c-3c1e-4a0f-9b1d-6f0a3d2c1b9e",
    "apply_url": "https://jobs.ashbyhq.com/posthog/2f1e5d7c-3c1e-4a0f-9b1d-6f0a3d2c1b9e/application",
    "published_at": "2026-08-12T09:14:02.000Z",
    "updated_at": null,
    "first_seen": "2026-08-12T09:14:02.000Z",
    "status": "listed",
    "closed_at": null,
    "content_hash": "6d0c5c0b8f7c0d5c3c1e9a3b0f2a7d4e1c8b6a9f0e3d2c1b4a5f6e7d8c9b0a1f",
    "fetched_at": "2026-09-22T14:46:40.278Z"
}
```

| Field                       | Meaning                                                                                                                                                                                                                                                                               |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `status`                    | `listed` (diff off) or `new` / `updated` / `unchanged` / `closed` (diff on). Closed records carry the last known title, location, department and URLs; `description_markdown` is `null`.                                                                                              |
| `remote` / `workplace_type` | From the ATS flag when it exists (Ashby `isRemote` / `workplaceType`, Lever `workplaceType`, Workable `telecommuting`, SmartRecruiters `location.remote`), otherwise inferred from the location and title ("Remote", "Hybrid", "Work from home", ...).                                |
| `salary`                    | Structured pay range from Greenhouse (`pay_input_ranges`), Lever (`salaryRange`) or Ashby (`compensation`), `source: "ats"`. When the ATS has no field but the description states a range such as `$120,000 - $160,000`, it is parsed with `source: "description"`. Otherwise `null`. |
| `updated_at`                | Only Greenhouse exposes it. Change detection uses `content_hash` (title + location + department + type + description), so it works for every vendor.                                                                                                                                  |
| `first_seen`                | Diff mode: the first run in which the job was seen. Otherwise the ATS publish date.                                                                                                                                                                                                   |

`SUMMARY.json` in the key-value store lists, per board, the API URL, jobs fetched, jobs matching the filters, jobs exported, and `new` / `updated` / `unchanged` / `closed` counts, plus any per-board error.

#### Diff mode

With `diff: true` the Actor stores `STATE` in the named key-value store (`stateStoreName`): one compact snapshot per job (title, location, URLs, `first_seen`, `content_hash`). On the next run:

- job not in the state: `new`
- job in the state with a different `content_hash`: `updated`
- job in the state that is no longer on the board: `closed` (with `closed_at`)
- same hash: `unchanged` (only exported when `emitUnchanged` is on)

Filters are applied before diffing, so the state only tracks jobs matching your filters; changing the filters therefore produces one wave of `new` / `closed` records. A board that fails to load leaves its part of the state untouched (no false closures). State is saved after every board, so a run that hits `maxJobs` or its budget resumes cleanly.

### Pricing

This Actor uses **pay-per-event** pricing:

| Event          | Price                                | When                                                                                                                                                                                         |
| -------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `job-exported` | **$0.002 per record** ($2 per 1,000) | Once per record written to the dataset, including `closed` records in diff mode. Filtered-out jobs, unchanged jobs (unless `emitUnchanged`), duplicates and failed boards are never charged. |
| Actor start    | $0.005 per run                       | Apify's standard start event; covers the first seconds of compute.                                                                                                                           |

Examples: a one-off pull of a 300-job board costs about $0.61. An hourly diff schedule over 50 boards that together change 40 jobs a day costs about $0.20 a day in events plus 24 starts ($0.12). Set **Max total charge per run** to hard-cap spend; the Actor stops gracefully before exceeding it.

### Data sources and limits

| ATS             | Endpoint used                                                                                                                               | Documentation                                              | Notes                                                                                                                                                                                                                                 |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Greenhouse      | `GET https://boards-api.greenhouse.io/v1/boards/{slug}/jobs?content=true&pay_transparency=true`                                             | https://docs.greenhouse.io/job-board.html                  | "Job Board data is publicly available, so authentication is not required." The description is delivered entity-encoded and is decoded before conversion. No pagination (whole board in one response, several MB for large companies). |
| Lever           | `GET https://api.lever.co/v0/postings/{slug}?mode=json` (EU: `api.eu.lever.co`)                                                             | https://github.com/lever/postings-api                      | Public, no key for reads. Description, sections (`lists`) and closing text are merged into one document. Boards with no public postings return `[]`.                                                                                  |
| Ashby           | `GET https://api.ashbyhq.com/posting-api/job-board/{slug}?includeCompensation=true`                                                         | https://developers.ashbyhq.com/docs/public-job-posting-api | Unlisted jobs (`isListed: false`) are skipped. Compensation tiers are summarised into `salary`.                                                                                                                                       |
| Workable        | `GET https://www.workable.com/api/accounts/{subdomain}?details=true` (redirects to `apply.workable.com/api/v1/widget/accounts/{subdomain}`) | https://workable.readme.io/reference/jobs-1                | Public widget endpoint (`"security": [{}]`), returns published jobs with descriptions. No structured salary. Accounts without a public careers page return `Not Found`.                                                               |
| SmartRecruiters | `GET https://api.smartrecruiters.com/v1/companies/{id}/postings?limit=100&offset=N` + `GET .../postings/{postingId}`                        | https://developers.smartrecruiters.com/docs/endpoints      | Public Posting API; the description lives in the detail call, so each job costs one extra request (fetched with concurrency 4, only for jobs that pass the metadata filters). No structured salary.                                   |

Other limits:

- The Actor reads only what the vendors publish on their public job-board APIs; it does not scrape careers pages, so companies on other ATS vendors (Workday, Taleo, iCIMS, BambooHR, Personio...) are not supported.
- Job ids are stable per vendor, but a company migrating from one ATS to another changes every id (one wave of `closed` + `new`).
- Vendors do not document rate limits for these endpoints; the Actor fetches boards sequentially and retries 429/5xx with backoff. Runs with hundreds of boards may take a few minutes.

### Tips

- **Follow a competitor set**: put 20-50 boards in `companies`, `diff: true`, schedule hourly, and connect the dataset to Slack. Only changes are billed.
- **Salary research**: `keywords: ["engineer"]`, then filter the dataset on `salary.source == "ats"` for disclosed ranges (Greenhouse pay-transparency boards and Ashby publish the most).
- **Remote roles only**: `remoteOnly: true` combines the vendor flag with text inference; check `workplace_type` for `hybrid`.
- **Different filter sets**: use a separate `stateStoreName` per schedule so the diffs do not interfere.

### Verified

Local run on 2026-09-22 with `apify run --purge` (Apify CLI 1.10.0, Apify SDK 3.7.2, Node 24), prefill input above (four boards, `maxJobs: 40`):

- 4 boards fetched (`greenhouse:greenhouse` 21 jobs, `ashby:posthog` 9, `workable:blueground` 7, `smartrecruiters:smartrecruiters` 1) - **38 records exported**, 0 errors, runtime about 3 seconds.
- Output spot-checked per vendor: Markdown descriptions without leftover tags or entities, locations, remote inference, employment type, salary from Ashby compensation, Greenhouse `updated_at`.
- Diff mode tested with a separate state store: first run marked every job `new`; after editing the stored state (one job removed, one hash changed, one phantom id added), the second run produced exactly one `new`, one `updated` and one `closed` record and no others.
- Pay-per-event path tested with `ACTOR_TEST_PAY_PER_EVENT=1 ACTOR_MAX_TOTAL_CHARGE_USD=5` (local test price $1/event): 5 `job-exported` events charged, then the Actor stopped with "Run budget reached (maxTotalChargeUsd)".
- Unit tests: `npm test` (13 tests over real API fixtures: ATS detection from URLs, all five normalisers, entity decoding, salary parsing, remote inference, filters, diff).
- Endpoint checks the same day: Greenhouse boards `greenhouse`, `gitlab`, `stripe`, `figma`, `duolingo`, `anthropic` respond; Lever `leverdemo`, `spotify`, `palantir` respond (`netlify`, `plaid` return 404 - those companies no longer use Lever); Ashby `notion`, `linear`, `posthog`, `ramp`, `openai` respond; Workable `blueground`, `skroutz`, `nuvei` respond; SmartRecruiters `smartrecruiters` responds.

### Support

Open an issue on the Actor's **Issues** tab. Issues are answered within one business day; bug fixes ship as new builds without changing the input schema.

Made by Vellum Labs.

# Actor input Schema

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

Objects `{ "ats": "greenhouse|lever|ashby|workable|smartrecruiters", "slug": "<board token>" }` or plain board URLs (ATS auto-detected). The slug is the board token in the company's careers URL: `boards.greenhouse.io/<slug>`, `jobs.lever.co/<slug>`, `jobs.ashbyhq.com/<slug>`, `apply.workable.com/<slug>`, `careers.smartrecruiters.com/<slug>`.

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

Keep only jobs whose title or description contains at least one of these words or phrases (case-insensitive). Leave empty for all jobs.

## `locationContains` (type: `array`):

Keep only jobs whose location text contains at least one of these strings (case-insensitive), e.g. `Berlin`, `United States`, `EMEA`.

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

Keep only jobs flagged remote by the ATS or whose location / title says remote.

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

Keep only jobs whose department or team contains one of these strings (case-insensitive), e.g. `Engineering`.

## `maxJobs` (type: `integer`):

Hard cap on job records exported in one run, across all boards. Each exported record is one `job-exported` billing event, so this also caps your cost.

## `diff` (type: `boolean`):

Remember every job seen (per board) in a named key-value store and mark each record `new`, `updated` or `closed` compared with the previous run. Unchanged jobs are not exported unless **Emit unchanged jobs** is on. Schedule the Actor and you get a change feed.

## `emitUnchanged` (type: `boolean`):

In diff mode, also export jobs that did not change (status `unchanged`). Off by default so a scheduled run only bills for changes.

## `stateStoreName` (type: `string`):

Name of the key-value store that keeps the seen-jobs state between runs. Use a different name per schedule / per filter set.

## `includeHtml` (type: `boolean`):

Also store the original job description HTML on each record (`description_html`). Increases dataset size.

## Actor input object example

```json
{
  "companies": [
    {
      "ats": "greenhouse",
      "slug": "greenhouse"
    },
    "https://jobs.ashbyhq.com/posthog",
    {
      "ats": "workable",
      "slug": "blueground"
    },
    {
      "ats": "smartrecruiters",
      "slug": "smartrecruiters"
    }
  ],
  "keywords": [],
  "locationContains": [],
  "remoteOnly": false,
  "departments": [],
  "maxJobs": 40,
  "diff": false,
  "emitUnchanged": false,
  "stateStoreName": "ats-jobs-feed-state",
  "includeHtml": false
}
```

# Actor output Schema

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

No description

## `summary` (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": [
        {
            "ats": "greenhouse",
            "slug": "greenhouse"
        },
        "https://jobs.ashbyhq.com/posthog",
        {
            "ats": "workable",
            "slug": "blueground"
        },
        {
            "ats": "smartrecruiters",
            "slug": "smartrecruiters"
        }
    ],
    "maxJobs": 40
};

// Run the Actor and wait for it to finish
const run = await client.actor("vellumlabs/ats-job-boards-feed").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": [
        {
            "ats": "greenhouse",
            "slug": "greenhouse",
        },
        "https://jobs.ashbyhq.com/posthog",
        {
            "ats": "workable",
            "slug": "blueground",
        },
        {
            "ats": "smartrecruiters",
            "slug": "smartrecruiters",
        },
    ],
    "maxJobs": 40,
}

# Run the Actor and wait for it to finish
run = client.actor("vellumlabs/ats-job-boards-feed").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": [
    {
      "ats": "greenhouse",
      "slug": "greenhouse"
    },
    "https://jobs.ashbyhq.com/posthog",
    {
      "ats": "workable",
      "slug": "blueground"
    },
    {
      "ats": "smartrecruiters",
      "slug": "smartrecruiters"
    }
  ],
  "maxJobs": 40
}' |
apify call vellumlabs/ats-job-boards-feed --silent --output-dataset

```

## MCP server setup

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

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/qFjKOQaca5X9dZvLD/builds/HfVX9hrt3poGQmXuU/openapi.json
