# ATS Job Board Monitor (`steadfeed/ats-job-board-monitor`) Actor

Monitor company job boards on Greenhouse, Lever (US + EU), Ashby, Workable and SmartRecruiters (Recruitee optional). Get only the new, changed and closed jobs since your last run, in one normalized format. By Steadfeed.

- **URL**: https://apify.com/steadfeed/ats-job-board-monitor.md
- **Developed by:** [Steadfeed](https://apify.com/steadfeed) (community)
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.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 Job Board Monitor: new, closed and changed jobs from company career pages

**By Steadfeed.** Watch the job boards of the companies you care about and get **only what changed since your last run**: new jobs, closed jobs, and jobs whose title, location, pay or description changed. Works with **Greenhouse, Lever (US and EU), Ashby, Workable and SmartRecruiters**, plus **Recruitee** as an option. It reads each company's own public job-board API live, so any company on a supported ATS works, not just companies in a pre-built database.

**Free to start:** the first check of every board you add is free, and you get 500 free job rows each month (see [Pricing](#pricing)).

### What you can use it for

- **Recruiting and sales signals:** see the day a target account opens (or quietly closes) roles.
- **Job seekers and job boards:** get an alert feed of new roles at a shortlist of companies, filtered by title, location, team or remote.
- **Market research:** track hiring velocity per company over time with stable first-seen and closed dates.

### How it works

1. You list companies as career-page URLs, `ats:slug` prefixes, or bare names.
2. Each run fetches every board through that ATS's official public job-board endpoint and normalizes the jobs to one schema.
3. In **diff mode** (default), the Actor compares each board with what it saw last time and outputs one row per **NEW**, **CHANGED** or **CLOSED** job. Its memory lives in a named key-value store in your account, so scheduled runs pick up where the last one left off.
4. Each company also gets a **status row** (`ok`, `not_found`, `error`…), so a broken board never looks like "no changes".

Run it on a **schedule** (for example daily or every 6 hours) and connect the dataset to email, Slack, Google Sheets or a webhook through Apify integrations.

### Supported ATSs

| ATS | Example input | Notes |
|---|---|---|
| Greenhouse | `https://boards.greenhouse.io/stripe`, `greenhouse:stripe`, `gh:stripe` | Optional pay ranges (one extra request per job). EU-hosted Greenhouse boards are not supported yet. |
| Lever | `https://jobs.lever.co/palantir`, `lever:palantir` | EU boards: `https://jobs.eu.lever.co/olx` or `lever-eu:olx`. |
| Ashby | `https://jobs.ashbyhq.com/openai`, `ashby:openai` | Includes compensation when the company publishes it. |
| Workable | `https://apply.workable.com/huggingface/`, `workable:huggingface` | Workable rate-limits hard, so requests are spaced out more. |
| SmartRecruiters | `https://careers.smartrecruiters.com/Ubisoft2`, `smartrecruiters:Ubisoft2` | Optional descriptions and apply links (one extra request per job). |
| Recruitee (optional) | `https://bunq.recruitee.com`, `recruitee:bunq` | Off by default; see the Recruitee section below. |

**Bare names** such as `notion` are probed in this order: Greenhouse, Lever US, Lever EU, Ashby, Workable, SmartRecruiters (then Recruitee if enabled). The first board with jobs wins. Probing costs extra requests and can match a *different* company that uses the same slug on another ATS, so use URLs or prefixes for anything important. The status row shows `detectedBy: "probe"`.

### Input example

```json
{
  "companies": [
    "https://boards.greenhouse.io/stripe",
    "lever:palantir",
    "lever-eu:olx",
    "https://jobs.ashbyhq.com/openai",
    "workable:huggingface",
    "smartrecruiters:Ubisoft2"
  ],
  "mode": "diff",
  "watchlistId": "my-targets",
  "titleKeywords": ["engineer", "developer"],
  "excludeTitleKeywords": ["intern"],
  "locationKeywords": ["London", "Remote"],
  "descriptionFormat": "text"
}
```

Main options:

- **mode**: `diff` (changes only) or `snapshot` (every current job on every run, still labelled NEW/CHANGED/UNCHANGED/CLOSED).
- **emitOnFirstRun** (diff mode): on the first run of a watchlist, output all current jobs as NEW (default) or just record them silently. Snapshot mode ignores this setting and always outputs every current job.
- **watchlistId**: keeps the memory of separate watchlists apart. The same company in two watchlists is tracked independently.
- **Filters**: title includes/excludes, location (matches "remote" for remote jobs too), department/team, workplace type (remote / hybrid / onsite), keep-unknown-workplace.
- **descriptionFormat**: `text`, `html`, `both` or `none`. Use `none` for the smallest, cheapest-to-store output.
- **maxJobRowsPerRun**: a hard cap on job rows per run. Rows that don't fit are not lost; they are reported on the next run.
- **Politeness**: parallel boards, minimum gap between requests to the same host, a separate Workable gap, and retries.

### Output

#### Job rows (`recordType: "job"`)

| Field | Meaning |
|---|---|
| `changeType` | `NEW`, `CHANGED` or `CLOSED`. Snapshot mode also outputs `UNCHANGED` rows, so you get every current job plus the ones that just closed. |
| `changedFields`, `previous` | For CHANGED rows: which fields changed, and their old values |
| `ats`, `region`, `companySlug`, `companyName` | Where the job came from |
| `jobId`, `jobKey` | The ATS's job ID and a stable global key (`ats:slug:jobId`, `lever-eu:…` for Lever EU) |
| `title`, `department`, `team` | As published by the company |
| `locations`, `location`, `workplaceType` | A list of locations, a joined string, and `remote` / `hybrid` / `onsite` / null |
| `employmentType`, `compensation` | When the ATS provides them |
| `postedAt`, `updatedAt` | From the ATS, when available |
| `firstSeenAt`, `lastSeenAt`, `closedAt` | Tracked by this Actor |
| `jobUrl`, `applyUrl` | Public links |
| `descriptionText` / `descriptionHtml` | According to `descriptionFormat` |
| `sourceApiUrl`, `checkedAt` | Audit trail |

Example CHANGED row (shortened):

```json
{
  "recordType": "job", "changeType": "CHANGED", "changedFields": ["title", "locations"],
  "previous": { "title": "Software Engineer", "locations": ["Berlin"] },
  "ats": "lever", "region": "eu", "companySlug": "olx", "jobId": "…",
  "title": "Senior Software Engineer", "locations": ["Berlin", "Remote"],
  "firstSeenAt": "2026-10-31T09:14:20.000Z", "lastSeenAt": "2026-11-01T09:14:03.000Z"
}
```

#### Company status rows (`recordType: "companyStatus"`)

One per company per run: `status`, `message`, `detectedBy`, `jobsFound`, `jobRowsOutput`, `freeJobRows`, `chargedJobRows`, `freeBaseline`, `freeBaselinePending`, `deferred`, `new`, `changed`, `closed`, `unchanged`, `firstRun`, `durationMs`. Turn them off with `statusRowsInDataset: false`. A run summary (`SUMMARY`) and all statuses (`COMPANY_STATUS`) are also saved to the run's key-value store.

| Status | Meaning |
|---|---|
| `ok` | The board was read. Zero jobs is possible. |
| `not_found` | The ATS says the board does not exist |
| `not_found_or_no_jobs` | SmartRecruiters returns an empty result both for unknown companies and for companies with no open jobs, so the two can't be told apart |
| `empty_unconfirmed` | A board that had jobs returned none; see the safety rules below |
| `auth_required` | Recruitee asked for a token |
| `error` | Network or server failure after retries. The board's memory is kept unchanged. |
| `invalid_input` / `skipped` | The entry was not understood, or it was skipped because Recruitee is off. When a run reaches its row limit or spending limit, the remaining boards are `skipped` with `deferred: true`. They are checked on the next run and are not counted as needing attention. |

#### Dataset views

In the Output tab, **Changes** shows the job rows and **Company status** shows the health of each board.

### Diff mode: the safety rules

- **Failed boards never close jobs.** If a board errors, its jobs stay as they were and you get an `error` status row.
- **An empty board must be empty twice.** If a board that had jobs suddenly returns none, the Actor waits for a second empty run before reporting everything as CLOSED. This guards against ATS outages.
- **Filters only affect output.** All jobs are tracked, so changing your filters never produces fake NEW or CLOSED rows.
- **Description changes** are detected from a hash of the published text. They show as `descriptionHash` in `changedFields` (the hash is not output).
- **Memory** is kept in the named key-value store `steadfeed-ats-monitor-state` (configurable), one record per watchlist and board. To start fresh, change the `watchlistId` or delete those records.

### Recruitee (optional)

From **10 February 2027**, Recruitee's public Careers Site API requires a token for each company (`X-Careers-Sites-Token`). Until then, public boards still answer without one. Turn on **Enable Recruitee** and, if you have one, add a token per company (`company=token`, one per line). **A Careers Site token has the same permissions as the Recruitee user who created it, so only use a token you are allowed to use, and store it in this secret field only.** Without a token after that date, boards will return `auth_required`.

### Privacy

This Actor only reads public job postings that companies publish for applicants. Email addresses, phone numbers and "Recruiter: / Contact: name" lines are removed from descriptions. This also removes generic company addresses such as accommodation or HR mailboxes. Recruiter and hiring-manager fields from the ATS are not output. Don't use the output to contact individuals in breach of applicable law.

### Memory and run time

The default of 512 MB is plenty for typical watchlists. In platform tests, 20 boards with 7,915 jobs (including a 5,019-job board) peaked at about 250 MB and took about 37–39 s. Small watchlists run fine at 256 MB. Raise memory only for very large watchlists with full descriptions.

### Rate limits and reliability

Requests are spaced per host (500 ms by default, 1,500 ms for Workable). Retries with backoff cover 408/425/429/5xx and network errors, and `Retry-After` is respected. Very large boards (5,000+ jobs, for example SmartRecruiters) take longer. Extra detail options (Greenhouse pay ranges, SmartRecruiters descriptions) make one request per job, capped by **maxDetailRequestsPerCompany**.

### Pricing

Pay per event. You pay only for these events; there is no separate charge for compute or storage.

| Event | Price | When |
|---|---|---|
| Run start | $0.005 per run | Once per run (twice if you raise memory above 1 GB) |
| Company checked | $0.0005 per board | Each board checked successfully |
| Job row | $0.001 per row | Each job row output (NEW, CHANGED, CLOSED, or a snapshot row) |

Status rows and unchanged jobs are never charged.

**Free tier**

- **Free first check of every board.** The first time your Apify account checks a board, that check and its baseline rows are free. This applies to up to 100 new boards per month. If a row limit or spending limit cuts the baseline short, the board keeps its free baseline. The rows not output yet, and the checks, stay free on your next runs (for up to 14 days). Adding the same board again later (for example in a new watchlist) is charged normally.
- **500 free job rows every month** (calendar month, UTC). After that, rows are charged.
- Each run's status message and `SUMMARY` show how many rows were free and how many free rows you have left. Each status row shows `freeJobRows`, `chargedJobRows` and `freeBaseline`.
- The allowance is tracked in a key-value store named `steadfeed-ats-monitor-billing` in your own account. Deleting it does not refund past charges. Two runs started at exactly the same time can share the same remaining free rows.

**Example costs (estimates, at the prices above)**

| Use | Estimate |
|---|---|
| First run on 20 boards | $0.005 (only the run start; the baseline is free) |
| Daily diff on 20 boards with about 10 changes per day | about $0.025 per run, about $0.45 per month after the 500 free rows |
| One snapshot of a 1,000-job board, after its free baseline | $0.005 + $0.0005 + about $0.50 for rows after the free allowance |

**Spending limit:** set a maximum charge per run. The Actor then outputs as many changes as fit, stops cleanly, and reports the rest on the next run, so nothing is lost.

### Known limits

- Lever and Ashby don't publish a company display name, so `companyName` is the board slug.
- Greenhouse has no structured workplace or employment type. `workplaceType` is inferred from location text ("Remote", "Hybrid") when possible.
- EU-hosted Greenhouse boards (`*.eu.greenhouse.io`) are not supported in this version.
- Only Greenhouse, Lever, Ashby, Workable, SmartRecruiters and Recruitee are supported. Workday, iCIMS and others are not.

### FAQ

**Can I get only new jobs?** Yes. That is the default (diff mode). Filter `changeType = NEW` in the dataset or integration.

**Do I get closed jobs without an add-on?** Yes. CLOSED rows with `closedAt` are part of the same output.

**What does the first run cost?** Only the run start. The first check of each new board, and its baseline rows, are free (up to 100 new boards a month).

**Why is there a `steadfeed-ats-monitor-billing` store in my account?** It counts your free rows and free baselines. It holds board names and counters only, no job data.

**My company isn't found.** Open its careers page, copy the job-board URL (for example `jobs.lever.co/…`) and use that instead of the name.

*Steadfeed is not affiliated with Greenhouse, Lever, Ashby, Workable, SmartRecruiters or Recruitee. Trademarks belong to their owners.*

# Actor input Schema

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

One entry per company. Paste the careers board URL (e.g. https://boards.greenhouse.io/stripe, https://jobs.lever.co/spotify, https://jobs.eu.lever.co/olx, https://jobs.ashbyhq.com/openai, https://apply.workable.com/huggingface, https://jobs.smartrecruiters.com/BoschGroup, https://vandebron.recruitee.com), or write "ats:slug" (greenhouse:, lever:, lever-eu:, ashby:, workable:, smartrecruiters:, recruitee:). A bare slug like "openai" is looked up on each supported ATS in turn (slower, and it can pick the wrong board if two ATSs use the same name).

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

"diff" outputs only jobs that are NEW, CHANGED or CLOSED since the last run with the same watchlist ID. "snapshot" outputs every open job (plus CLOSED ones) with its change type.

## `emitOnFirstRun` (type: `boolean`):

On the first run for a board, every job is NEW. Turn this off to only save a silent baseline and get changes from the second run on. Diff mode only: snapshot mode always outputs every current job.

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

Name for this watchlist's saved state. Use different IDs for separate schedules or filters so they don't share history. Letters, digits, '-', '\_' and '.' only.

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

Named key-value store in your Apify account where the last-seen jobs are kept between runs. Named stores are kept until you delete them.

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

Only output jobs whose title contains at least one of these words (case-insensitive). Changes are still tracked for all jobs.

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

Skip jobs whose title contains any of these words, e.g. "intern", "senior".

## `locationKeywords` (type: `array`):

Only output jobs whose location contains at least one of these, e.g. "London", "Germany", "Remote".

## `departmentKeywords` (type: `array`):

Only output jobs whose department or team contains one of these, e.g. "Engineering".

## `workplaceTypes` (type: `array`):

Only output these workplace types. Leave empty for all.

## `includeUnknownWorkplace` (type: `boolean`):

When filtering by workplace type, keep jobs whose board doesn't say (common on Greenhouse).

## `descriptionFormat` (type: `string`):

Include the job description as plain text, HTML, both, or not at all (smaller output). E-mail addresses, phone numbers and named recruiter/contact lines are removed.

## `statusRowsInDataset` (type: `boolean`):

Adds one row with recordType "companyStatus" per board (ok / not_found / error, counts). The same list is always saved as COMPANY_STATUS in the run's key-value store.

## `maxJobRowsPerRun` (type: `integer`):

Stop outputting job rows after this many (0 = no limit). Useful to cap cost on the first run.

## `greenhousePayRanges` (type: `boolean`):

Makes one extra request per Greenhouse job to read its published pay range. Slower on big boards.

## `smartRecruitersDescriptions` (type: `boolean`):

SmartRecruiters' list endpoint has no description. This makes one extra request per job.

## `maxDetailRequestsPerCompany` (type: `integer`):

Upper limit for the per-job requests above.

## `enableRecruitee` (type: `boolean`):

Recruitee's Careers Site API needs a company token from 10 February 2027. Until then public boards work without one. Off by default.

## `recruiteeTokens` (type: `string`):

Optional. Either one token, or one "company=token" per line. WARNING: Recruitee says a Careers Site token has the same permissions as the user who created it. Only use a token from your own Recruitee account, created by a user with minimal permissions.

## `maxConcurrency` (type: `integer`):

How many companies are processed at the same time. Requests to each ATS host are still spaced out.

## `requestIntervalMs` (type: `integer`):

Requests to one ATS host (e.g. boards-api.greenhouse.io) are spaced at least this far apart.

## `workableRequestIntervalMs` (type: `integer`):

Workable rate-limits its public API more strictly.

## `maxRetries` (type: `integer`):

Retries on 429, 5xx and network errors, with exponential backoff and Retry-After.

## Actor input object example

```json
{
  "companies": [
    "https://boards.greenhouse.io/discord",
    "https://jobs.lever.co/palantir",
    "https://jobs.ashbyhq.com/linear",
    "https://apply.workable.com/huggingface/",
    "smartrecruiters:Ubisoft2"
  ],
  "mode": "diff",
  "emitOnFirstRun": true,
  "watchlistId": "default",
  "stateStoreName": "steadfeed-ats-monitor-state",
  "includeUnknownWorkplace": true,
  "descriptionFormat": "text",
  "statusRowsInDataset": true,
  "maxJobRowsPerRun": 0,
  "greenhousePayRanges": false,
  "smartRecruitersDescriptions": false,
  "maxDetailRequestsPerCompany": 200,
  "enableRecruitee": false,
  "maxConcurrency": 4,
  "requestIntervalMs": 500,
  "workableRequestIntervalMs": 1500,
  "maxRetries": 4
}
```

# Actor output Schema

## `jobChanges` (type: `string`):

All dataset rows with the 'Job changes' view selected: new, changed and closed jobs (or all open jobs in snapshot mode), normalized across ATS platforms.

## `companyStatus` (type: `string`):

All dataset rows with the 'Company status' view selected: one row per board checked, with ATS, status, job counts and free/charged row counts.

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

JSON run summary (totals, mode, free-tier usage) stored as the SUMMARY record in the default key-value store.

# 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": [
        "https://boards.greenhouse.io/discord",
        "https://jobs.lever.co/palantir",
        "https://jobs.ashbyhq.com/linear",
        "https://apply.workable.com/huggingface/",
        "smartrecruiters:Ubisoft2"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("steadfeed/ats-job-board-monitor").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": [
        "https://boards.greenhouse.io/discord",
        "https://jobs.lever.co/palantir",
        "https://jobs.ashbyhq.com/linear",
        "https://apply.workable.com/huggingface/",
        "smartrecruiters:Ubisoft2",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("steadfeed/ats-job-board-monitor").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": [
    "https://boards.greenhouse.io/discord",
    "https://jobs.lever.co/palantir",
    "https://jobs.ashbyhq.com/linear",
    "https://apply.workable.com/huggingface/",
    "smartrecruiters:Ubisoft2"
  ]
}' |
apify call steadfeed/ats-job-board-monitor --silent --output-dataset

```

## MCP server setup

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

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/5vwfGGfRsubtGdSAx/builds/GhGWkoatFx43goKJy/openapi.json
