# ATS Jobs Feed: Greenhouse & Lever Job Postings (`madpai/ats-jobs-feed`) Actor

Get job postings from company Greenhouse and Lever boards through their official public APIs (no login, no API key), normalized to one clean JSON shape, with new and closed job detection between runs. Polite and robots.txt-aware.

- **URL**: https://apify.com/madpai/ats-jobs-feed.md
- **Developed by:** [William Schultz](https://apify.com/madpai) (community)
- **Categories:** Jobs, Automation
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 20.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.50 / 1,000 job delivereds

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 Job Postings

### What does it do?

It reads the public job boards of the companies you list and returns one clean JSON item per open job, in the same shape for both systems. It also remembers which jobs were on each board last time, so each job is marked **new**, **existing** or **closed**.

> **Change tracking and the cap:** with `changesOnly` on, `maxJobsPerBoard` caps the number of *changes* per run, not the board. The first run records every job on the board, delivers up to the cap as `new` and records the rest silently (not output, not charged). After that you get, and pay for, only jobs that really appeared or closed since the last run.

- **Greenhouse:** the official Job Board API (`boards-api.greenhouse.io/v1/boards/{token}/jobs`).
- **Lever:** the official Postings API (`api.lever.co/v0/postings/{company}`, or `api.eu.lever.co` for EU-hosted boards).

Both APIs are public and read-only. The Actor uses no login, no API key and no account on either system, and it only reads what the company already publishes on its careers page. Ashby, Workday and other systems are not supported.

### Who is it for?

Recruiting and sales-intelligence teams tracking who is hiring, job-board and newsletter builders, and AI agents or automations that need a structured "what changed on these careers pages since yesterday" feed. Run it on a schedule with change tracking turned on to get only new and closed postings.

### Input

| Field | Default | What it does |
|---|---|---|
| `boards` (required) | prefill `greenhouse:airbnb`, `lever:spotify` | Board URLs (`job-boards.greenhouse.io/stripe`, `boards.greenhouse.io/stripe`, `jobs.lever.co/spotify`, `jobs.eu.lever.co/company`) or `greenhouse:token`, `lever:company`, `lever-eu:company`. Duplicates are fetched once. |
| `maxJobsPerBoard` | 500 (prefill 20, max 5,000) | Default mode: most open jobs delivered per board after filters. With `changesOnly`: most changes (new + closed) delivered per board per run; changes over the cap wait for the next run. Either way this caps cost. |
| `keywords` | none | Keep jobs whose title, department or team contains any of these words (case-insensitive). |
| `locations` | none | Keep jobs whose location contains any of these (case-insensitive). |
| `remoteOnly` | false | Keep only jobs the board marks as remote. |
| `includeDescription` | false | Add the description as plain text, up to 5,000 characters, with email addresses and phone numbers replaced by `[redacted]`. Descriptions may contain names from the original posting. |
| `changesOnly` | false | Output only jobs that are new or closed since the previous run with the same state key. |
| `stateKey` | derived from boards and filters | Name of the saved state used for change detection. |
| `leverEu` | false | Use Lever's EU API for every `lever:` board. |

Example:

```json
{
  "boards": ["https://job-boards.greenhouse.io/airbnb", "https://jobs.lever.co/spotify"],
  "maxJobsPerBoard": 20,
  "keywords": ["engineer", "data"],
  "changesOnly": false
}
```

### Output

One item per job. Real items from a local test run on 2 Nov 2026 (second run, so both are `existing`):

```json
[
  {
    "source": "greenhouse",
    "board": "greenhouse:airbnb",
    "company": "Airbnb",
    "jobId": "8306554",
    "title": "AirCover Specialist, Claims Experience",
    "department": "Community Support",
    "team": null,
    "location": "Canada",
    "remote": true,
    "employmentType": null,
    "url": "https://careers.airbnb.com/positions/8306554?gh_jid=8306554",
    "applyUrl": "https://careers.airbnb.com/positions/8306554?gh_jid=8306554",
    "postedAt": "2026-10-28T14:52:05.000Z",
    "updatedAt": "2026-10-28T14:52:05.000Z",
    "changeType": "existing",
    "firstSeenAt": "2026-11-03T01:02:28.641Z",
    "checkedAt": "2026-11-03T01:02:32.997Z",
    "error": null,
    "errorCode": null,
    "notice": "Public job postings from the company's official board API. Not affiliated with Greenhouse or Lever. Not for AI training."
  },
  {
    "source": "lever",
    "board": "lever:spotify",
    "company": "spotify",
    "jobId": "2a55d6c9-c564-443a-be3e-a3851db5a93b",
    "title": "Account Executive - Commercial, Spotify Technology",
    "department": "Operations and Business Support",
    "team": "Platform",
    "location": "New York, NY",
    "remote": false,
    "employmentType": "Permanent",
    "url": "https://jobs.lever.co/spotify/2a55d6c9-c564-443a-be3e-a3851db5a93b",
    "applyUrl": "https://jobs.lever.co/spotify/2a55d6c9-c564-443a-be3e-a3851db5a93b/apply",
    "postedAt": "2026-10-23T15:00:40.768Z",
    "updatedAt": null,
    "changeType": "existing",
    "firstSeenAt": "2026-11-03T01:02:28.641Z",
    "checkedAt": "2026-11-03T01:02:32.997Z",
    "error": null,
    "errorCode": null,
    "notice": "Public job postings from the company's official board API. Not affiliated with Greenhouse or Lever. Not for AI training."
  }
]
```

Field notes:

- `remote` is `true` when the board marks the job remote (workplace type, or "Remote" in the location), `false` for hybrid or on-site, and `null` when the board doesn't say.
- `company`: Lever's public API doesn't return a display name, so Lever items carry the board's company slug (`spotify`).
- `updatedAt` is `null` for Lever, which doesn't publish an update time.
- Greenhouse has no separate apply link: `applyUrl` is the posting page, which carries the application form.
- `description` appears only with `includeDescription`.
- **Closed items** carry only `source`, `board`, `jobId`, `url` (the board's posting URL rebuilt from the ID: `https://job-boards.greenhouse.io/{token}/jobs/{id}` or `https://jobs.lever.co/{company}/{id}`; it usually no longer opens, since the job is closed), `changeType`, `firstSeenAt`, `lastSeenAt` (last run the job was still on the board), `checkedAt` and `notice`. Every other field (`title`, `company`, `location`, `applyUrl` and so on) is `null`, because the saved state keeps no job text.

The run also saves `OUTPUT_SUMMARY` in the default key-value store: per-board results (including `baselined`, `notDeliveredCap` and `notDeliveredBudget`), `jobsFound`, `jobsMatched`, `new`, `existing`, `closed`, `failedBoards`, `chargedEvent`, `chargedJobs`, `budgetLimitReached`, `stateKey`, the politeness settings used, the notice and `finishedAt`.

### Change detection

The Actor keeps the state of each board in the named key-value store `ats-jobs-feed-state`, under the state key. The default key is derived from the boards and filters. Set your own `stateKey` to keep one feed stable across schedule or input edits. Under Apify's limited Actor permissions, if a pre-existing store with that name can't be opened, the Actor falls back to `ats-jobs-feed-state-lp`; if the run's token doesn't allow named stores at all, the run still completes as a first run (`statePersisted: false` and `stateStoreError` in `OUTPUT_SUMMARY`).

**What the state holds:** per job, only its ID, its status (`open`, or `closed` while its closed item is still waiting to be delivered) and two timestamps (`firstSeenAt`, `lastSeenAt`). No titles, descriptions, locations, departments or other job text. About 150 bytes per job, so a 1,000-job board takes about 150 KB.

- **First run for a board** (new state key, or a board added to the input): every job that matches the filters is recorded. Up to `maxJobsPerBoard` are delivered as `new`, and the rest, including any held back by your max charge, are recorded silently (not output, not charged). In the default mode they're delivered as `existing` on later runs, and with `changesOnly` they're never sold to you as `new`.
- **Later runs:** a job in the state is `existing`. A job that isn't in the state is `new`. A job in the state that is no longer on the board at all is `closed`, reported once, then dropped from the state.
- **Cap with `changesOnly`:** `maxJobsPerBoard` limits the changes delivered per board per run, closed first, then new. Changes over the cap, or past your max charge, are kept pending and delivered (and charged) on a later run, never lost and never charged twice: a pending new job stays out of the state until it's delivered, and a pending closed job stays in it with status `closed`. If a pending closed job comes back on the board, it's simply open again.
- **Cap in the default mode:** unchanged. `maxJobsPerBoard` limits the open jobs delivered, and closed items (free) all go out. A truly new job over the cap isn't recorded, so it's still `new` when it first gets delivered.
- If a board can't be read in a run, its state is kept unchanged, so its jobs aren't reported as closed.
- State saved by 0.1.0/0.1.1 (which also stored titles, URLs and locations) is migrated on the next run: the extra fields are dropped and the record is rewritten. Because the old state only held jobs it had delivered, the first run after the upgrade records the board's other jobs silently instead of selling them as `new`. Closed jobs are still reported.

### Errors

A board that can't be read gives one item with `error` and `errorCode`, and is never charged:

- `board-not-found`: the board token or company doesn't exist (HTTP 404).
- `robots-disallowed`: the API host's robots.txt disallows the request, or robots.txt couldn't be fetched (RFC 9309 treats that as "disallow all").
- `fetch-failed`: network error, server error, a response that isn't JSON, or the host was rate-limited.
- `invalid-input`: not a Greenhouse or Lever board (Ashby and other systems included), or `boards` was left empty.

### Pricing

$1.50 per 1,000 jobs. In change-tracking mode (`changesOnly`), only new and closed postings are charged. In the default mode you pay for each open job delivered (`new` or `existing`), and closed-job items are free. Error items are always free. The Actor stops delivering jobs when your run's max charge is reached, and says so in `OUTPUT_SUMMARY`.

### Compliance and data use

- **Not affiliated** with Greenhouse, Lever or any company whose board you read, and not an integration partner of either.
- **Not for AI training.** The data is the companies' public job postings, delivered for job search, hiring-market research and monitoring. Don't use it to train AI models. Lever's job-board site declares `Content-Signal: ai-train=no` in its robots.txt (`jobs.lever.co/robots.txt`).
- **No logins, no API keys, read-only.** Only public GET endpoints are called.
- **Polite:** about 1 request per second per API host. The robots.txt of each API host is read first (RFC 9309: a server error or unreachable robots.txt means "disallow", a 4xx means "allow"), and its `Crawl-delay` is honoured, up to 30 seconds (Lever's robots.txt sets 1 second). `Retry-After` is honoured, up to 30 seconds, and a host that answers HTTP 429 three times in a row gets no more requests in that run. User-Agent: `ATSJobsFeed/0.1 (+Apify Actor; polite, robots-aware)`.
- **Personal data stripped:** items are built from a fixed list of job fields, so recruiter, hiring-manager, owner and follower fields in the API data (Lever `owner`, `hiringManager`, `followers`; Greenhouse metadata such as "Recruiter") are never output, and no person's name field is ever output. In descriptions, email addresses (including forms like `name [at] company dot com`) and phone numbers are replaced with `[redacted]`. Descriptions are off by default. Descriptions may contain names from the original posting: a name written into the description's prose (for example "created by …") is not detected, so leave `includeDescription` off if you don't need it.
- Every item and `OUTPUT_SUMMARY` carries the notice: "Public job postings from the company's official board API. Not affiliated with Greenhouse or Lever. Not for AI training."

### Limits

- Greenhouse and Lever only. Ashby is not supported.
- Each board is one API request (plus one robots.txt request per host per run). Boards on the same host are read one after another, about 1 second apart, so 100 Lever boards take about 2 minutes.
- Memory: default 256 MB. In local tests, the largest board tried (Stripe, 740 jobs, with descriptions) peaked at 180 MB.
- Descriptions are plain text, cut at 5,000 characters.

### Local test runs (2 Nov 2026)

With the prefill input (`greenhouse:airbnb` and `lever:spotify`, `maxJobsPerBoard` 20), the first run delivered 40 `new` jobs and the second delivered the same 40 as `existing`. Each run took about 2 seconds inside the Actor, and memory peaked at 124 MB.

Change tracking with a small cap (0.1.2): `changesOnly` with `maxJobsPerBoard` 10 on the same two boards and a fresh state key. The first run delivered 20 `new` jobs (10 per board) and recorded the other 276 silently (Airbnb 211 jobs, Spotify 85). The second run delivered 0 items and charged nothing. The saved state for both boards (296 jobs: IDs, status and timestamps only) is 48 KB. Each run took 2 to 5 seconds inside the Actor, and memory peaked at 134 MB.

# Changelog

This Actor's version history is a separate document: https://apify.com/madpai/ats-jobs-feed/changelog.md

# Actor input Schema

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

Greenhouse or Lever boards: a board URL (job-boards.greenhouse.io/stripe, boards.greenhouse.io/stripe, jobs.lever.co/spotify, jobs.eu.lever.co/company) or source:name (greenhouse:stripe, lever:spotify, lever-eu:company). Ashby and other systems are not supported.

## `maxJobsPerBoard` (type: `integer`):

Default mode: most open jobs (new or existing) delivered per board, after filters; closed-job items don't count. With changesOnly: most changes (new + closed) delivered per board per run; extra changes wait for the next run. Caps cost either way.

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

Keep only jobs whose title, department or team contains any of these words (case-insensitive), e.g. engineer, data.

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

Keep only jobs whose location contains any of these (case-insensitive), e.g. London, Remote, United States.

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

Keep only jobs the board marks as remote (workplace type, or 'Remote' in the location).

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

Add the job description as plain text (up to 5,000 characters). Email addresses and phone numbers in it are replaced with \[redacted].

## `changesOnly` (type: `boolean`):

Output only jobs that are new or closed since the previous run with the same state key. Unchanged jobs are not output and not charged. The first run records every job on the board, outputs up to maxJobsPerBoard as new and records the rest silently (not output, not charged).

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

Name of the saved state (job IDs, status and timestamps only) used for change detection (named key-value store 'ats-jobs-feed-state'). Leave empty to derive it from the boards and filters. Use the same key in a scheduled task to track one feed over time.

## `leverEu` (type: `boolean`):

Use Lever's EU API (api.eu.lever.co) for every Lever board given as lever:name. jobs.eu.lever.co URLs and lever-eu:name always use it.

## Actor input object example

```json
{
  "boards": [
    "greenhouse:airbnb",
    "lever:spotify"
  ],
  "maxJobsPerBoard": 20,
  "remoteOnly": false,
  "includeDescription": false,
  "changesOnly": false,
  "leverEu": false
}
```

# Actor output Schema

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

Dataset with one item per job: source, board, company, jobId, title, department, team, location, remote, employmentType, url, applyUrl, postedAt, updatedAt, optional description, changeType (new, existing, closed), firstSeenAt, lastSeenAt (closed items; closed items carry only IDs, URL and timestamps), checkedAt, error, errorCode, notice. Boards that could not be read get one item with error and errorCode (board-not-found, robots-disallowed, fetch-failed or invalid-input), never charged.

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

JSON summary: per-board results, jobsFound, jobsMatched, new, existing, closed, failedBoards, chargedEvent, chargedJobs, budgetLimitReached, stateKey, politeness used, notice, finishedAt.

# 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",
        "lever:spotify"
    ],
    "maxJobsPerBoard": 20
};

// Run the Actor and wait for it to finish
const run = await client.actor("madpai/ats-jobs-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 = {
    "boards": [
        "greenhouse:airbnb",
        "lever:spotify",
    ],
    "maxJobsPerBoard": 20,
}

# Run the Actor and wait for it to finish
run = client.actor("madpai/ats-jobs-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 '{
  "boards": [
    "greenhouse:airbnb",
    "lever:spotify"
  ],
  "maxJobsPerBoard": 20
}' |
apify call madpai/ats-jobs-feed --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,madpai/ats-jobs-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/ESoI7CYzGRB2Wmhqm/builds/0Q47jvcsEFWo3AaJu/openapi.json
