# ATS Jobs Feed – Greenhouse, Lever, Ashby & Workable Jobs (`staticxviper/ats-jobs`) Actor

Open jobs from the public Greenhouse, Lever, Ashby, and Workable job-board APIs for a list of companies. Salary, remote flag, and new-since-last-run.

- **URL**: https://apify.com/staticxviper/ats-jobs.md
- **Developed by:** [CJ Moulton](https://apify.com/staticxviper) (community)
- **Categories:** Lead generation
- **Security:** Limited permissions
- **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 rows

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

Open jobs for a list of companies, read straight from each company's public applicant-tracking-system job board. One dataset row per job with title, company, location, remote flag, salary when the employer publishes it, department, posted date, and the job URL. Turn on `newSinceLastRun` and a scheduled task becomes a feed of only the jobs it has not shown you before.

The Actor calls the official public job-board APIs that these ATS vendors publish for career pages. It does not scrape HTML, launch a browser, use a proxy, or solve CAPTCHAs. Requests run one at a time with a jittered 1–2 second pause.

### What it does

Every endpoint below was checked with a live request on 2026-11-02. An unknown slug answered HTTP 404 on all four.

| ATS | Endpoint called | Docs |
|-----|-----------------|------|
| Greenhouse | `https://boards-api.greenhouse.io/v1/boards/{slug}/jobs?content=true&pay_transparency=true` | [Job Board API](https://developers.greenhouse.io/job-board.html) |
| Lever | `https://api.lever.co/v0/postings/{slug}?mode=json` | [lever/postings-api](https://github.com/lever/postings-api) |
| Lever (EU) | `https://api.eu.lever.co/v0/postings/{slug}?mode=json` | same README, EU instance links |
| Ashby | `https://api.ashbyhq.com/posting-api/job-board/{slug}?includeCompensation=true` | [Public Job Posting API](https://developers.ashbyhq.com/docs/public-job-posting-api) |
| Workable | `https://apply.workable.com/api/v1/widget/accounts/{slug}` | Not documented by Workable (see Limitations) |

Notes from that check:

- Greenhouse documents `pay_transparency=true` on the single-job endpoint only. On 2026-11-02 the list endpoint accepted it too and returned `pay_input_ranges` on all 213 Airbnb jobs, so one request per board still gets structured pay. When the ranges are empty, the Actor looks for a "$120,000 – $150,000" pattern in the job content.
- Lever returns the whole board in one response unless `limit` is passed. The Actor does not page. `createdAt` is epoch milliseconds and is converted to ISO UTC.
- Ashby's documented job object has no `id`, but the live response includes one. The Actor uses `id` and falls back to `jobUrl`. Jobs with `isListed: false` are skipped because Ashby says they are direct-link only.
- Workable's widget feed is the JSON its embeddable careers widget calls. It answered 200 for `huggingface` and 404 with plain text for an unknown slug. It has no salary field.

Rows are filtered by location, remote, and keyword, then deduped on `(ats, company slug, job id)`. The same job id at two companies stays two rows.

### Input

`fixtures/INPUT.json`:

```json
{
  "greenhouse": ["airbnb"],
  "lever": [],
  "leverEu": [],
  "ashby": [],
  "workable": [],
  "location": "",
  "remote": "any",
  "keywords": [],
  "newSinceLastRun": false,
  "stateStoreName": "ats-jobs-state",
  "maxItems": 3,
  "delaySeconds": 1,
  "maxSpendUsd": 1
}
```

This file is the CI fixture: one Greenhouse board and three jobs. Add Lever, Ashby, or Workable slugs for a wider run. `keywords` of `engineer` and `engineering` keep both word forms.

- Slugs are the last part of the public board URL: `boards.greenhouse.io/airbnb`, `jobs.lever.co/leverdemo`, `jobs.ashbyhq.com/Ashby`, `apply.workable.com/huggingface`. A full board URL is accepted and reduced to its slug. A `jobs.eu.lever.co` URL, or a name under `leverEu`, goes to Lever's EU API.
- `location` keeps a job when any of its locations contains the text (case-insensitive). Commas separate alternatives. The alternative `Remote` also matches any job whose ATS flags it as remote (Ashby `isRemote`, Workable `telecommuting`, a remote workplace type), even when the location text does not say "Remote".
- `remote`: `any`, `remote` (fully remote only), or `exclude`. Hybrid counts as not remote.
- `keywords` keeps a job whose title or department contains any of them as a whole word (case-insensitive). `ai` does not match "Maintainer" and `engineer` does not match "Engineering", so list both forms when you want both. A single comma-separated `keyword` string is accepted too.
- `newSinceLastRun` skips jobs pushed by an earlier run. Seen keys and their `firstSeen` time live in the named key-value store `stateStoreName` (default `ats-jobs-state`), which persists between runs. Use a different store name per saved task to keep feeds apart.
- `maxItems` is an exact cap on job rows pushed (default 100, maximum 10,000). Status rows do not count. When the cap is reached, later boards are not requested.
- `maxSpendUsd` defaults to 5 (minimum 0.01, maximum 100). A job row is pushed only when its charge fits under this cap. Once it does not, the job feed stops and one free `spend_capped` row says how many matching jobs were held back.

### Output

One dataset item per job, plus a status row for each board that was unknown, disallowed, or failed, one `blocked` row per blocked host, and one `spend_capped` row if the spend cap ended the job feed. Key-value `SUMMARY` records counts (`found`, `newJobs`, `repeatJobs`, `billedJobs`, `heldBackJobs`, `notFound`, `blocked`, `errors`, `mergedDuplicates`, `filteredOut`, `skippedSeen`, `skippedBlockedHost`, `truncated`, `chargeCapped`), spend, and one report per board (`ats`, `slug`, `status`, `fetched`, `kept`).

```json
{
  "ats": "greenhouse",
  "company": "Airbnb",
  "companySlug": "airbnb",
  "jobId": "1234567",
  "title": "AirCover Specialist, Claims Experience",
  "location": "Canada",
  "locations": ["Canada"],
  "remote": true,
  "workplaceType": "remote",
  "salary": {
    "min": 58000,
    "max": 68000,
    "currency": "CAD",
    "interval": "year",
    "raw": "Canada Annual Pay Range: CAD 58,000 - CAD 68,000"
  },
  "department": "Community Support",
  "postedDate": "2026-10-28T14:52:05+00:00",
  "url": "https://careers.airbnb.com/positions/1234567?gh_jid=1234567",
  "firstSeen": "2026-11-02T12:00:00+00:00",
  "sourceStatus": "found",
  "decision": "new",
  "ok": true,
  "error": "",
  "scrapedAt": "2026-11-02T12:00:00+00:00"
}
```

`salary` is `null` when the job lists nothing. `min`, `max`, `currency`, and `interval` are filled when the ATS publishes structured pay (Greenhouse pay ranges, Lever `salaryRange`, Ashby salary components) and are parsed from text otherwise. `interval` is one of `year`, `month`, `week`, `day`, `hour`, or null when the source does not say. `postedDate` is ISO UTC; Workable publishes a date only (`YYYY-MM-DD`). `decision` is `new` the first time a job is pushed and `repeat` after that (only when `newSinceLastRun` is off).

| sourceStatus | Meaning | job event |
|--------------|---------|-----------|
| `found` | A job was returned. Pushed only when `maxSpendUsd` covers its charge | yes |
| `not_found` | The ATS has no board with that slug (HTTP 404), or nothing matched the filters | no |
| `blocked` | HTTP 401, 403, or 429 after retries, or a challenge page instead of JSON. Later boards on that host are skipped; other hosts still run | no |
| `spend_capped` | `maxSpendUsd` or the platform charge limit ended the job feed; `error` says how many jobs were held back | no |
| `robots_disallowed` | robots.txt disallows the board URL | no |
| `error` | Bad slug, invalid JSON, a 5xx after retries, or robots.txt could not be read | no |

### Limitations

- Coverage is the four ATSs above. Workday, iCIMS, SmartRecruiters, and others are not read.
- A board is read in full in one request. Lever and Workable boards are not paged, and a very large board is capped at 32 MB.
- Workable does not document the widget endpoint. Its [documented API](https://help.workable.com/hc/en-us/articles/115013356548-Workable-API-Documentation) needs an employer token. The widget feed can change without notice, and Workable sits behind Cloudflare, which may answer 429 from cloud IPs. That skips the remaining Workable boards with one `blocked` row.
- Remote is the ATS's own workplace field when it has one (Greenhouse "Workplace Type" metadata, Lever and Ashby `workplaceType`, Workable `telecommuting`), else the word "remote" in a location.
- Text salary parsing reads the first currency range it finds. A `$` amount is reported as USD.
- `newSinceLastRun` state keeps up to 200,000 keys per store and drops the oldest first. Only jobs that were pushed and charged are recorded, so a job held back by `maxItems` or `maxSpendUsd` is still new next run.
- On a block the Actor pushes one `blocked` row for that host and skips its remaining boards. Boards on other ATS hosts still run, and the run exits 0. It does not retry through a proxy.

### Robots and terms

robots.txt is fetched once per host and evaluated with `scripts/lead_automation/crm_enrich_robots.py` (longest match, `User-agent: *`). A 4xx robots answer means no rules (RFC 9309). A 5xx or network failure skips that host for the run. On 2026-11-02, `boards-api.greenhouse.io` disallowed only `/embed/`, `api.lever.co` and `api.eu.lever.co` allowed `/` with Crawl-delay 1, `apply.workable.com` allowed everything, and `api.ashbyhq.com/robots.txt` answered 401.

Greenhouse says job board data is publicly available with no auth on GET. Lever and Ashby publish these endpoints for customers' own career sites. Job content belongs to the employer. Use it to link back to the original posting.

### Pricing

Pay per event from `.actor/pay_per_event.json`.

| Event | Price | When it is charged |
|-------|-------|--------------------|
| `job` (primary) | $0.002 (placeholder) | A job row is pushed |

**The $0.002 price is a placeholder pending a pricing decision.** Apify Store, read on 2026-11-02:

- [fantastic-jobs/career-site-job-listing-api](https://apify.com/fantastic-jobs/career-site-job-listing-api): "from $4.00 / 1,000 jobs", pay per event, 1,649 monthly users, 4.11 stars.
- [fantastic-jobs/greenhouse-jobs-api](https://apify.com/fantastic-jobs/greenhouse-jobs-api): "from $1.20 / 1,000 jobs".
- [fantastic-jobs/lever-co-jobs-api](https://apify.com/fantastic-jobs/lever-co-jobs-api): "from $0.80 / 1,000 jobs".

"From" is the lowest Store-discount tier. The undiscounted per-event price was not visible on those pages, so it is unverified. A third-party tracker (apifystats.com) lists the Career Site API at $12 / 1,000. That figure is not confirmed.

There are no `proxy` or `captcha` events because this Actor never uses either. `not_found`, `blocked`, `robots_disallowed`, `spend_capped`, and `error` rows are saved and not billed. Once `maxSpendUsd` cannot cover the next job, no further job rows are pushed. Dataset items are not a charge event. The platform still applies its start event. Memory default is 512 MB and the default timeout is 1,800 seconds.

#### Deviation from the template contract

The template keeps `headless`, `maxProxyCostUsd`, `allowCaptcha`, `proxyConfiguration`, and `proxyGroups` in every input schema. This Actor drops them. It reads public JSON APIs with plain HTTP, never launches a browser, and stops on a block instead of spending to get past it. That is the same stop-on-challenge path `contractor-license` takes. Only `maxSpendUsd` is kept.

Two further differences:

- The shared `SpendLedger` counts whole cents, so $0.002 would round to 0 and the cap would never stop charging. `src/spend.py` subclasses it as `JobLedger` in micro-dollars with the same `can()` / `commit()` contract, and `push_charged_rows` uses it.
- The Dockerfile uses `apify/actor-python:3.12` instead of the shared Playwright image. It still leaves `WORKDIR` at `/usr/src/app`, copies only files inside this folder, and sets `CJM_REPO_ROOT=/usr/src/app/platform_bundle`. The slim image has no `xvfb-entrypoint.sh`.

### Categories

Set in the Apify Console, not in `actor.json`. Suggested: Jobs, Lead generation, Automation.

### Local run

```bash
cd scripts/apify-scripts/ats-jobs
mkdir -p storage/key_value_stores/default
cp fixtures/INPUT.json storage/key_value_stores/default/INPUT.json
apify run --purge
```

`--purge` clears the default stores only. The named `ats-jobs-state` store under `storage/key_value_stores/` survives, so a second local run with `newSinceLastRun: true` returns only new jobs. Delete that folder to start fresh.

Unit tests read fixtures only:

```bash
python -m pytest unittests/apify_scripts/test_ats_jobs_actor.py -q
```

Publish with `APIFY_TOKEN` (`apify push`) only after a listing is requested. See `scripts/apify-scripts/README.md`.

# Changelog

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

# Actor input Schema

## `greenhouse` (type: `array`):

Greenhouse board tokens, such as airbnb from boards.greenhouse.io/airbnb. A board URL is accepted too.

## `lever` (type: `array`):

Lever site names, such as leverdemo from jobs.lever.co/leverdemo. A jobs.eu.lever.co URL is sent to the EU API.

## `leverEu` (type: `array`):

Lever site names hosted on the EU instance (jobs.eu.lever.co). Read from api.eu.lever.co.

## `ashby` (type: `array`):

Ashby job board names, such as Ashby from jobs.ashbyhq.com/Ashby. Compensation is requested.

## `workable` (type: `array`):

Workable account slugs, such as huggingface from apply.workable.com/huggingface.

## `location` (type: `string`):

Optional. Keep jobs where any listed location contains this text (case-insensitive). Separate alternatives with commas, for example: New York, Remote. Remote also matches jobs the ATS flags as remote.

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

Any keeps every job. Remote only keeps fully remote jobs. Exclude remote drops them. Hybrid counts as not remote.

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

Optional. Keep jobs whose title or department contains any of these as a whole word (case-insensitive). engineer does not match Engineering; list both if you want both.

## `newSinceLastRun` (type: `boolean`):

Skip jobs already pushed by an earlier run. Seen job keys and their firstSeen time are kept in the named key-value store below, so they survive between runs.

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

Named key-value store that holds seen jobs. Use a different name per saved task to keep separate feeds.

## `maxItems` (type: `integer`):

Hard cap on job rows pushed this run, after filters and dedupe. Status rows do not count and are not billed.

## `delaySeconds` (type: `number`):

Seconds to wait before each request after the first. Requests run one at a time. The pause jitters upward by up to 1 second.

## `maxSpendUsd` (type: `number`):

Job rows are pushed only while their charge fits under this amount. When the next job would pass it, the job feed stops and one free spend_capped row records how many jobs were held back. Default 5, minimum 0.01, maximum 100.

## Actor input object example

```json
{
  "greenhouse": [
    "airbnb"
  ],
  "remote": "any",
  "newSinceLastRun": false,
  "stateStoreName": "ats-jobs-state",
  "maxItems": 100,
  "delaySeconds": 1,
  "maxSpendUsd": 5
}
```

# Actor output Schema

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

Dataset containing all scraped items

## `files` (type: `string`):

Key-value store containing the SUMMARY record for this run

# 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 = {
    "greenhouse": [
        "airbnb"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("staticxviper/ats-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 = { "greenhouse": ["airbnb"] }

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

```

## MCP server setup

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