# ATS Job Watch — Greenhouse & Lever job change monitor (`rounded_phlogopite/ats-job-watch`) Actor

Monitor Greenhouse and Lever career boards through their public job-board APIs. By default each run returns only NEW, CHANGED and CLOSED postings since the previous run. No login, no proxies, no personal contact data.

- **URL**: https://apify.com/rounded_phlogopite/ats-job-watch.md
- **Developed by:** [Yorozuya Neko Labs](https://apify.com/rounded_phlogopite) (community)
- **Categories:** Jobs, Lead generation, Automation
- **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 board checkeds

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 Job Watch: Greenhouse & Lever job change monitor

ATS Job Watch checks company career boards hosted on **Greenhouse** and **Lever** through their public job-board APIs. In the default `changes` mode, each run returns only the postings that are **NEW**, **CHANGED** or **CLOSED** since the previous run of the same watchlist (`full` mode also returns unchanged jobs).

Put it on a schedule (for example once a day) to follow hiring at competitors, customers or companies you want to work for.

#### How it works / first run

- **The first run saves a baseline and reports no changes.** It records the jobs that are open now, outputs one free `BASELINE_SAVED` row per board, and its status says: *"First run: saved a baseline for N boards (M open jobs). No changes are reported on the first run. Schedule this Actor (e.g. daily) with the same companies and watchlistId, and the next runs will output NEW / CHANGED / CLOSED jobs."*

- **Every later run** compares the boards with the saved baseline and outputs only `NEW`, `CHANGED` and `CLOSED` jobs.

- **Want the current jobs right away?** Turn on `emitBaseline` (*Output current jobs on the first run*): the first run then also outputs every open job as a `BASELINE` row, charged as `snapshot-job`.

- Uses the public job-board endpoints that Greenhouse and Lever provide for publishing jobs. No login, no cookies, no proxies.

- Recruiter names, e-mails and other contact fields are not output.

- Ashby, SmartRecruiters and Recruitee are deliberately **not supported** (their API terms do not allow, or do not clearly allow, this kind of third-party use). Such inputs are reported as input errors and never requested. Workday and custom career pages are not supported either.

### Quick start

1. Add boards to **Companies**, for example `greenhouse:gitlab`, `lever:palantir`, or a board URL such as `https://job-boards.greenhouse.io/gitlab` or `https://jobs.lever.co/palantir`.
2. Run once. **By default (`mode: changes`), the first run saves a baseline and outputs no job rows** — you get one free `BASELINE_SAVED` row per board and the status message says how many jobs were saved. (Want the current jobs too? Turn on *Output current jobs on the first run*. In `mode: full` the first run always outputs them, charged as snapshot rows.)
3. Create a **schedule** (Console → Schedules) that runs a saved task daily. Keep the same companies and the same `watchlistId`. From the second run on you get only changes.

#### Example input

The smallest useful input (this is also the default, so an empty input runs the same two boards):

```json
{
  "companies": ["greenhouse:gitlab", "lever:palantir"]
}
```

A typical daily scheduled task — engineering roles only, interns excluded, remote or Tokyo, with a fixed watchlist name so history is kept when you add boards later:

```json
{
  "companies": ["greenhouse:gitlab", "lever:palantir"],
  "mode": "changes",
  "titleKeywords": ["engineer", "developer"],
  "excludeTitleKeywords": ["intern"],
  "locations": ["Remote", "Tokyo"],
  "watchlistId": "daily-engineering-watch"
}
```

### Input

| Field | Description |
|---|---|
| `companies` (required) | `greenhouse:<slug>`, `lever:<slug>` or a public board URL. Default when omitted: `greenhouse:gitlab`, `lever:palantir`. Up to 500 entries, 300 characters each. A board that does not exist (404) is charged as a board check. EU Lever boards (`jobs.eu.lever.co/<slug>`) are read from Lever's EU API (`api.eu.lever.co`) and appear as board `<slug>@eu`. **EU-hosted Greenhouse boards (`*.eu.greenhouse.io`) are not supported yet**: Greenhouse documents no EU job-board API host, so they are reported as a free input error and never requested or charged. |
| `mode` | `changes` (default): only NEW / CHANGED / CLOSED. `full`: also every open job as `UNCHANGED`. In `full` mode a board's first check always outputs its current jobs as `BASELINE` (charged as snapshot rows), whatever `emitBaseline` is. Only jobs that match your filters are output and charged (the counts in `OUTPUT` are before filtering). |
| `emitBaseline` | Default **off**. On: in `changes` mode the first run also outputs current jobs as `BASELINE` (charged as snapshot rows). (`full` mode always does.) |
| `emitStatusRows` | Default on. Writes free status rows (`BASELINE_SAVED`, `NO_CHANGES`, `RUN_SKIPPED`) so a run where boards were checked but nothing changed still leaves one row. A `NO_CHANGES` row is written only when at least one board was checked successfully and no other row was written; if every board failed (or the input had only unsupported ATSs), the dataset can be empty — see `OUTPUT` for the reason. |
| `titleKeywords` / `excludeTitleKeywords` | Title must / must not contain (case-insensitive). Up to 100 entries, 200 characters each. |
| `locations` / `remoteOnly` | Location substring filter (up to 100 entries, 200 characters each), remote jobs only. |
| `includeDescription` | Adds the job description as plain text (at most 50,000 characters per job, then ` [description truncated]`; at most 200,000 characters of source text are read per field). |
| `escapeFormulas` | Default **on**: text values that look like a spreadsheet formula get a leading `'` so spreadsheets don't run them (see *Spreadsheet exports*). Ordinary text such as `- Bullet point` or `-Remote` is not changed. Turn off for the exact original text. |
| `watchlistId` | Name of the saved history the changes are compared against. Derived from the company list if empty; set it explicitly for scheduled tasks. |
| `maxJobsPerBoard` / `delayBetweenBoardsSecs` | Safety limit per board; wait between boards (minimum and default 1 s, maximum 5 s). |

### Output

One dataset row per job change, plus free status rows. The run summary (per-board counts, errors) is in the `OUTPUT` record of the default key-value store.

| `changeType` | Meaning | Charged |
|---|---|---|
| `NEW` / `CHANGED` / `CLOSED` | Change since the previous run | 1 × `change-event` |
| `BASELINE` | Current job on a board's first check, only with `emitBaseline` on or in `mode: full`. Never written after lost history | 1 × `snapshot-job` |
| `UNCHANGED` | Unchanged job, only in `mode: full` | 1 × `snapshot-job` |
| `BASELINE_SAVED` | Status row: first check of a board (`changes` mode, `emitBaseline` off), baseline saved, no job rows | free |
| `BASELINE_RESET` | Status row: the saved history for this board was missing or unreadable; a new baseline was saved and existing jobs were **not** re-sent (no NEW, `BASELINE` or `UNCHANGED` rows for that board in that run, in any mode) | free |
| `NO_CHANGES` | Status row: at least one board was checked and nothing changed (boards that could not be checked are counted in `boardsFailed`) | free |
| `RUN_SKIPPED` | Status row: another run of the same watchlist was still in progress, so this run checked nothing | free |

Inputs over these limits (more than 500 companies, more than 100 entries in a filter list, a longer entry, or a non-text entry) are rejected before anything is checked: the run fails with an explanation and nothing is charged by this Actor (only Apify's standard start event).

Job rows contain: `jobKey`, `ats`, `board`, `company`, `jobId`, `title`, `department`, `team`, `locations`, `remote`, `workplaceType`, `employmentType`, `salary` (`min`, `max`, `currency`, `interval`, `raw`), `postedAt`, `updatedAt`, `url`, `applyUrl`, `firstSeenAt`, `checkedAt`, `isBaseline`, `contentHash`, optional `description`. Status rows have `jobKey: null`.

Example (real public postings, change simulated):

```json
{"changeType": "CHANGED", "jobKey": "greenhouse:gitlab:8716179002", "ats": "greenhouse", "board": "gitlab",
 "company": "GitLab", "title": "AI Transformation Owner, Product & Design (Senior)", "department": "AI",
 "locations": ["Remote, Canada; Remote, United Kingdom; Remote, United States"], "remote": true,
 "url": "https://job-boards.greenhouse.io/gitlab/jobs/8716179002", "firstSeenAt": "2026-10-31T08:42:28Z",
 "checkedAt": "2026-10-31T08:42:30Z", "isBaseline": false}
{"changeType": "NO_CHANGES", "jobKey": null, "boardsChecked": 2, "boardsFailed": 0, "checkedAt": "2026-10-31T08:42:33Z"}
```

### Spreadsheet exports (CSV / Excel)

Job fields are text written by the companies that publish the postings. With `escapeFormulas` on (default), a text value that starts with `=` or `@`, a tab or a carriage return, or starts with `+` / `-` followed by a digit, `(`, `=`, `+`, `-`, `@`, `.` or a function-call shape such as `-SUM(` is written with a leading apostrophe (for example `=HYPERLINK(...)` becomes `'=HYPERLINK(...)` and `-SUM(1,2)` becomes `'-SUM(1,2)`), so Excel, LibreOffice and Google Sheets show it as text instead of running it as a formula. Ordinary text is left alone: `- Bullet point`, `-Remote`, `+ benefits` and `Senior - Platform` are not changed. This applies to `title`, `company`, `department`, `team`, `locations`, `workplaceType`, `employmentType`, `salary.raw` / `currency` / `interval`, `url`, `applyUrl`, `description` and `jobId`. All other values are unchanged. Change detection and `contentHash` always use the original text, so switching the option never creates `CHANGED` rows. If you read the JSON directly and need the exact text, turn the option off (or remove one leading `'` from these fields). Even with escaping on, treat exported job text as untrusted content.

### Pricing (pay per event, public beta)

| Event | Price |
|---|---|
| Board checked (`board-checked`) — one board the ATS API answered, including a board that does not exist (404) | $0.002 |
| Change event (`change-event`) — one NEW / CHANGED / CLOSED row | $0.001 |
| Snapshot row (`snapshot-job`) — `BASELINE` / `UNCHANGED`, only with `emitBaseline` on or `mode: full` (including the first `full` run). Never for lost history | $0.001 |
| Actor start (Apify's standard start event) | $0.00005 per run |

- A board check is charged whenever the ATS API answered, **including boards with 0 jobs, boards where nothing changed, and boards that do not exist (404)** — a real check was made. 404 board checks count toward your max total charge.
- **Not charged:** network errors, timeouts, server errors (5xx), 429 Too Many Requests, redirects (3xx, never followed), responses over 32 MB, boards that hit the 90 s per-board limit, unexpected responses, inputs rejected for size limits, EU-hosted Greenhouse URLs, boards not checked because of a stop below, unsupported-ATS inputs, skipped runs, and all status rows (`BASELINE_SAVED`, `BASELINE_RESET`, `NO_CHANGES`, `RUN_SKIPPED`).
- **Lost history:** a board whose saved history was lost or unreadable outputs no job rows in that run, in any mode, so it costs only its board check ($0.002) — no change or snapshot charges.
- Each row is charged at most once. There is no additional per-dataset-item charge.
- The run stops at your *max total charge* and never exceeds it. Changes that did not fit are delivered on the next run and are not charged twice.
- Examples: 10 boards checked daily with no changes ≈ $0.02 per day; 50 boards ≈ $0.10 per day, plus $0.001 per change.

### How changes are detected, and when one can be missed

- A job is identified by its ATS job ID. CHANGED means one of: title, department, team, locations, remote, workplace type, employment type, salary, URL.
- **Partial or failed responses never produce CLOSED.** If a board errors, returns an unexpected shape, or is cut off (job limit reached, or fewer jobs than Greenhouse's reported total), its saved history is kept and no closures are reported for that run.
- **Sudden large drops are confirmed first.** If a board loses at least 3 jobs and falls below 80% of its previous count, CLOSED rows are held back until the next run confirms the drop (reported one run later).
- **Lost or unreadable history:** existing jobs are never re-sent — not as NEW, and also not as `BASELINE` or `UNCHANGED` in `mode: full` or with `emitBaseline` on. You get one free `BASELINE_RESET` row per affected board, and no change or snapshot events are charged for it. From the next run on, the board behaves normally (in `mode: full`, one `snapshot-job` per open job again). Changes that happened between the last good run and the reset are **not reported**.
- **Deleting the state store (`ats-job-watch-state`) or changing `watchlistId` starts a new watchlist.** It behaves like a first run (`BASELINE_SAVED` rows and no job rows by default; with `mode: full` or `emitBaseline` on, current jobs are output as `BASELINE` and charged as `snapshot-job`). Changes in that gap are not reported.
- A job that appears and disappears between two runs is never seen. Run more often if that matters.
- Lever does not report a total job count. If Lever returns a well-formed but shortened list on two runs in a row, it cannot be told apart from real removals.
- A failed board is retried on the next run; the history from the last good check is used for comparison.

### Safety limits

- **One run per watchlist at a time.** A lock (lease of 300 s, renewed at the start of a board when more than 60 s have passed since the last renewal) is kept in the state store. A second run that overlaps exits without checking or charging anything and writes a free `RUN_SKIPPED` row. The lock is a best-effort lease, not an atomic lock (the key-value store has no compare-and-swap): a run writes it, waits 1 s and reads it back. In our tests with the production settings, overlapping runs did not report or charge the same change twice, but a duplicate is still possible in rare cases, e.g. when a run takes more than the 1 s wait between reading the lock and writing it, when the store returns a stale value on the read-back, or when one board's processing outlasts the lease window (the lease is renewed only when a board starts, after 60 s; since 0.1.11 fetching one board is limited to 90 s, so this needs a very large board). The lease of a crashed run expires after at most 300 s; until then, runs of that watchlist are skipped (free).
- **20 failed boards in a row** (including 404s) stop the run; the remaining boards are not checked and not charged (`OUTPUT.stoppedEarly`).
- **429 Too Many Requests:** each board is retried at most twice. If 3 boards in a row on the same ATS end in 429, that ATS is not called again in this run (those boards are not charged).
- **Requests:** only `boards-api.greenhouse.io`, `api.lever.co` and `api.eu.lever.co` over HTTPS. Redirects are not followed (a 3xx answer is a failed, uncharged check). One response may be at most 32 MB (decoded); one board, including retries and waits, at most 90 s. A board over either limit is a failed check and is not charged.
- `OUTPUT.stats` lists requests, retries, 429s, 5xx responses, network errors, 404 boards, stopped ATSs and, when they happen, refused redirects, oversized responses and boards over the time limit.
- Delay between boards: 1–5 s. Memory: 256 MB by default, 1024 MB at most.
- On an unexpected internal error the run fails with the error type only (no stack trace). Boards checked and rows output before that were charged once and are not charged again.

### Testing and limits

- Tested on labeled synthetic scenarios (9 boards × 29 runs, 4 random seeds, including failures, truncation, lost and corrupted history, budget stops and crashes): 0 wrong NEW / CHANGED / CLOSED rows, and charged change events equal output change rows.
- Real boards: cloud runs on 28 boards (about 4,400 jobs) and a side-by-side check of 3 other Actors on the same boards — **3 products × 2 runs only. That does not prove long-term reliability**, and no real job changes happened during that check.
- Salary is filled only when the ATS provides it in structured form (Lever) or the posting states an explicit amount range. Nothing is guessed.
- Please do not schedule more often than needed (every minute is not recommended). Job content belongs to the posting companies; check their terms before redistributing it.

### Support

Report problems in the **Issues** tab. Please include the run ID and the board (for example `greenhouse:gitlab`).

# Actor input Schema

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

One entry per company board: 'greenhouse:slug' / 'lever:slug' or a public board URL (job-boards.greenhouse.io/gitlab, jobs.lever.co/palantir, jobs.eu.lever.co/acme). Max 500 entries, 300 characters each. A board that does not exist (404) is charged as a normal board check ($0.002). EU-hosted Greenhouse boards (\*.eu.greenhouse.io) are rejected as a free input error (not requested, not charged). Ashby, SmartRecruiters and Recruitee are intentionally not supported (their API terms restrict or don't clearly allow third-party commercial use).

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

'changes' (default) = only NEW / CHANGED / CLOSED since the previous run of the same watchlist (charged as 'change-event'). 'full' = additionally every open job as UNCHANGED (charged as 'snapshot-job', $0.001 each). In 'full' mode the first check of a board (including the first run) always outputs its current jobs as BASELINE, charged as 'snapshot-job' ($0.001 each), whatever 'Output current jobs on the first run' is set to. If stored history is lost, no job rows are output or charged for that board in that run.

## `emitBaseline` (type: `boolean`):

Applies to 'changes' mode ('full' mode always outputs BASELINE rows on a board's first check). Off (default): the first run of a watchlist saves the current jobs as a baseline and outputs no job rows - only one free BASELINE_SAVED status row per board. On: the first run also outputs every current job with changeType BASELINE, charged as 'snapshot-job' ($0.001 each). BASELINE rows are not change events. If stored history is ever lost or unreadable, existing jobs are never re-sent in any mode (no NEW, BASELINE or UNCHANGED rows, no change-event or snapshot-job charges for that board in that run): one free BASELINE_RESET row per affected board is written instead.

## `emitStatusRows` (type: `boolean`):

On (default): when there is nothing else to report, the dataset gets a free status row so you can tell a quiet run from a broken one: BASELINE_SAVED (first check of a board), NO_CHANGES (at least one board was checked and nothing changed; boards that could not be checked are counted in boardsFailed) or RUN_SKIPPED (another run of the same watchlist was still in progress, so nothing was checked or charged). Status rows have jobKey null and are never charged. Turn off if your integration should receive job rows only (BASELINE_RESET rows are always written).

## `titleKeywords` (type: `array`):

Case-insensitive. Empty = all titles. Max 100 entries, 200 characters each.

## `excludeTitleKeywords` (type: `array`):

Case-insensitive, e.g. 'intern'. Max 100 entries, 200 characters each.

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

Case-insensitive substring match on the location text, e.g. 'Tokyo', 'Germany', 'Remote'. Max 100 entries, 200 characters each.

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

Keep only postings the ATS marks as remote (or whose location says Remote).

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

Adds the job description as plain text. Makes items larger.

## `escapeFormulas` (type: `boolean`):

On (default): a text field that looks like a spreadsheet formula gets a leading apostrophe ('), so Excel / Google Sheets do not run it when you open a CSV/XLSX export. That is a value starting with = or @, a tab or a carriage return, or starting with + / - followed by a digit, ( = + - @ . or a function call such as -SUM(. Ordinary text like '- Bullet point' or '-Remote' is not changed, and change detection is unaffected. Turn off if you read the JSON directly and need the exact original text.

## `watchlistId` (type: `string`):

Name for the stored state. Leave empty to derive it from the board list. Use the same ID in a scheduled task to keep history even if you add boards.

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

Safety cap. If a board is truncated, CLOSED detection is skipped for it to avoid false alerts.

## `delayBetweenBoardsSecs` (type: `number`):

Politeness delay between API calls: min 1 s (the providers' robots.txt Crawl-delay), max 5 s.

## Actor input object example

```json
{
  "companies": [
    "greenhouse:gitlab",
    "lever:palantir"
  ],
  "mode": "changes",
  "emitBaseline": false,
  "emitStatusRows": true,
  "remoteOnly": false,
  "includeDescription": false,
  "escapeFormulas": true,
  "maxJobsPerBoard": 2000,
  "delayBetweenBoardsSecs": 1
}
```

# Actor output Schema

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

Default dataset. One row per job change; status rows have jobKey null.

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

Per-board counts, errors, input errors, historyReset, baselineSaved, stoppedEarly.

# 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": [
        "greenhouse:gitlab",
        "lever:palantir"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("rounded_phlogopite/ats-job-watch").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": [
        "greenhouse:gitlab",
        "lever:palantir",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("rounded_phlogopite/ats-job-watch").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": [
    "greenhouse:gitlab",
    "lever:palantir"
  ]
}' |
apify call rounded_phlogopite/ats-job-watch --silent --output-dataset

```

## MCP server setup

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

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/kcUBGFIub2BGjUvyI/builds/o7FCp62umspU7b5Ew/openapi.json
