# ATS Job Change Feed - Greenhouse & Lever (`dstyx/ats-job-feed-actor`) Actor

Monitor Greenhouse and Lever job boards and get only what changed since your last run: new, updated, and removed postings, each corroborated by a content hash. You pay per change, and unchanged rows are free.

- **URL**: https://apify.com/dstyx/ats-job-feed-actor.md
- **Developed by:** [Dakota Styck](https://apify.com/dstyx) (community)
- **Categories:** Jobs, Automation, Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.70 / 1,000 new job detecteds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

### What does ATS Job Change Feed do?

**ATS Job Change Feed tracks job postings across many company career pages and tells you what changed since your last run**: which jobs are **new**, which were **updated**, and which were **removed**. Snapshot APIs leave the diffing to you, and the incremental job feeds with any real adoption report only new and closed postings. This one reports the full diff on the boards you choose and corroborates every change with a content hash. It never bills you for a posting that didn't change.

It reads the public, no-authentication JSON APIs that [Greenhouse](https://boards-api.greenhouse.io) and [Lever](https://api.lever.co) publish so companies can embed job boards on their own websites. No browser is involved, which is why runs are fast and cheap.

Point it at 50 company boards, run it daily, and each morning you get the delta.

### Why use ATS Job Change Feed?

- **Stop diffing dumps yourself.** You get `change_type` per record: `new`, `updated`, `removed`.
- **Catch edits too.** A retitled role, a location change, or a new department shows up as `updated`.
- **Know when a role closes.** `removed` tells you a posting is gone, which a snapshot API can't tell you at all.
- **Cheap to run.** HTTP-only, no browser. A run over dozens of boards takes seconds.
- **Honest about outages.** If a board fails to fetch, its jobs are *not* reported as removed. See [Reliability](#reliability).

Recruiters use it to track competitor hiring, job boards use it to ingest fresh listings, and market researchers watch it for headcount signals. It also works as a plain alert that a specific company opened a specific kind of role.

### How to use ATS Job Change Feed

1. Type company names into **Companies**. Each one is looked up on Greenhouse first, then Lever, automatically, and a pasted careers-page URL works too. Names that match neither platform are reported in the run summary under `unresolved_companies`.
2. If a company's board token differs from its name (the lookup normalizes "Epic Games" to `epicgames`, but some tokens are abbreviations), put the token directly in **Companies on Greenhouse** or **Companies on Lever** under *Boards by platform*.
3. Optionally add filters: title keywords, locations, departments, remote-only.
4. Run it once. The first run has no history, so by default every open job comes back as `new`. That's your baseline.
5. Schedule it (daily is typical). From the second run on, you get only what changed.

#### First run behavior

There's no prior state on the first run, so "what changed" is undefined. You choose:

| `first_run_mode` | Behavior |
| --- | --- |
| `emit_all_as_new` (default) | Returns every open job tagged `new`. You get a full snapshot plus a baseline. |
| `baseline_only` | Returns nothing, silently records the baseline. The next run reports changes only. |

Pick `baseline_only` if you only care about deltas and don't want to pay for an initial full dump.

### Input

All fields are optional except that you must supply at least one company or board.

```json
{
  "companies": ["airbnb", "anthropic", "spotify"],
  "greenhouse_boards": ["stripe", "databricks"],
  "title_keywords": ["engineer", "data"],
  "locations": ["remote", "new york"],
  "remote_only": false,
  "first_run_mode": "emit_all_as_new",
  "emit_unchanged": false,
  "include_description": false,
  "hash_description": false,
  "state_store_name": "ats-job-feed-state"
}
```

Filters are applied *before* change detection, so a job drifting out of your filter is never mislabeled as `removed`.

### Output

Each dataset item is one changed job.

```json
{
  "change_type": "updated",
  "change_reason": "content",
  "job_key": "greenhouse:stripe:5416444",
  "source": "greenhouse",
  "board": "stripe",
  "external_id": "5416444",
  "title": "Data Analyst",
  "company": "Stripe",
  "locations": ["San Francisco, CA", "US"],
  "department": "Data Science",
  "team": null,
  "employment_type": null,
  "is_remote": null,
  "url": "https://stripe.com/jobs/search?gh_jid=5416444",
  "posted_at": "2026-06-02T12:58:57Z",
  "updated_at": "2026-07-27T15:17:30Z",
  "first_seen_at": "2026-08-01T09:00:00Z",
  "last_seen_at": "2026-08-04T01:04:17Z",
  "content_hash": "3f2a91c04be7d158"
}
```

The dataset downloads as JSON, CSV, Excel, or HTML.

A `removed` item has no current record - the posting is gone - so it carries `source`, `board`, `external_id`, `job_key`, and the timestamps, with `title` absent.

#### Data fields

| Field | Description |
| --- | --- |
| `change_type` | `new`, `updated`, `removed`, or `unchanged` |
| `change_reason` | For `updated`: `content`, `timestamp`, or `content+timestamp` |
| `job_key` | Stable identity, `source:board:external_id` |
| `source` | `greenhouse` or `lever` |
| `board` | The board token the job came from |
| `title` | Job title |
| `company` | Company name (Greenhouse only; Lever does not publish one) |
| `locations` | All locations attached to the posting |
| `department` / `team` | Org placement, where the source provides it |
| `employment_type` | e.g. "Regular Full Time" (Lever only) |
| `is_remote` | `true`, `false`, or `null` when the source gives no signal |
| `url` | Public posting URL |
| `posted_at` | When the posting went live, ISO-8601 UTC |
| `updated_at` | Vendor's last-modified claim. **Always `null` for Lever** - Lever publishes none |
| `first_seen_at` | When *this actor* first saw the job; survives updates |
| `last_seen_at` | This run's timestamp |
| `content_hash` | Hash of the tracked fields, for your own downstream checks |

A run summary is written to the key-value store under `RUN_SUMMARY`, with per-board counts, which boards failed, and whether any safety guard tripped.

### How change detection works

Most of the difficulty is in deciding what counts as a change.

**Every job carries a content hash.** It covers the tracked fields: title, locations, department, team, employment type, remote flag, URL, posted date. A job is `updated` when the hash moves. Greenhouse also publishes an `updated_at`, and when that moves too you get `change_reason: "content+timestamp"`, but on its own it never counts as a change unless you opt in via `detect_timestamp_only`.

That default comes from a measurement, not from caution. Greenhouse occasionally re-stamps nearly a whole board inside one minute with no tracked field changing at all. Under the opt-in, each of those events would bill you for hundreds of updates that never happened.

**Lever publishes no `updated_at` at all.** Verified against the live API on 2026-08-03: the payload carries `createdAt` and nothing else temporal. So for Lever the content hash is the only change signal there is, and any implementation leaning on `updated_at` would silently never report a Lever update.

**Only fingerprints are stored.** A job id, a 16-character hash, two timestamps. Tracking 10,000 jobs costs well under a megabyte of state.

**State lives in a named key-value store** (`state_store_name`). Apify gives every run a *fresh* default key-value store, so a baseline written there would vanish and every run would look like a first run.

**Adding a board doesn't reset the others.** Baselines are tracked per board, so a newly added board is a first run for that board alone while every existing board keeps diffing normally. You aren't re-billed for your whole corpus because you added one company.

**Changing what is hashed does start a clean baseline.** Flipping `hash_description` changes what the hash covers, so old and new hashes can't be compared. The state key accounts for that, so you don't get every job reported as `updated`. Pin `state_key` if you want one continuous feed regardless.

### Reliability

Change feeds have a specific failure mode: an outage that looks like news. Two guards address it.

**A failed fetch is not an empty board.** If a board returns an error, times out, or serves a non-JSON body, that board is skipped entirely: no records emitted, baseline preserved untouched. Without this, one transient 503 would report every job on that board as `removed`. Failed boards are listed in `RUN_SUMMARY.failed_boards`.

**Mass removals are withheld pending corroboration.** If more than `removal_alarm_ratio` (default 50%) of a board's tracked jobs disappear in a single run, the removals are held back and the baseline is kept, because a truncated-but-successful response looks exactly like a genuine mass close-out. Boards with fewer than 10 tracked jobs are exempt, where the ratio would be meaningless. Set `suppress_mass_removals: false` to let them through immediately.

Retries use exponential backoff with jitter and honor `Retry-After`. A `404` isn't retried: it means the board token is wrong, and hammering a free public endpoint over a typo is rude.

### How much does it cost?

Pay-per-event, two prices:

| Event | Price |
| --- | --- |
| Run start | $0.002 (charged once per run up to 1 GB of memory; this actor runs at 512 MB by default, so once) |
| Each `new`, `updated`, or `removed` record | $0.001 |

`unchanged` records, when you enable them, are never charged.

You pay per change, so a large board that sits still costs you almost nothing. Measured on live Greenhouse boards of ~550 and ~800 jobs: 22-35 billable changes on a weekday, with weekends much quieter. That's a few cents either way. A daily poll over 10 boards with ~1,000 tracked jobs comes to roughly $1-2/month, and a quiet day costs $0.002, the run fee and nothing else. A snapshot API bills you for the same 500 rows every day whether or not anything moved.

The first run is the exception: with no baseline yet, `emit_all_as_new` bills every open job once as `new`. Choose `first_run_mode: "baseline_only"` to skip that and pay only for changes from run two onward.

Runs are HTTP-only with no browser, so compute is a small fraction of a browser-based scraper's.

### Tips

- **Filter early.** Title and location filters run before change detection, so narrow feeds mean cheaper runs.
- **Leave `include_description` off** unless you need the body text; it makes Greenhouse responses much larger.
- **Turn on `hash_description`** only if you genuinely need description edits flagged, and know what it costs. Measured on Databricks' board, 2026-08-13: an employer added one legal boilerplate link to every posting at once, so **769 of 779 jobs** would have billed as `updated` for a change that says nothing about the job. Ten were genuine edits, and six of those were already caught without it. Board-wide edits like that are normal, not rare: of sixteen Greenhouse boards scanned that day, ten showed one, and eight of those had fired within the previous ten days.
- **Use `baseline_only`** on the first run if you only want deltas.
- **Keep `max_concurrency` modest.** These are free public endpoints offered as a courtesy.

### FAQ

**Does this need an API key for Greenhouse or Lever?**
No. Both publish these endpoints without authentication so their customers can embed job boards on their own sites. Greenhouse's developer documentation states that job board data is publicly available and authentication is not required for GET endpoints.

**Is this scraping?**
Not in the usual sense. It reads documented public JSON APIs; there's no browser or HTML parsing involved, and nothing is bypassed to get the data.

**Why is `updated_at` null on all my Lever jobs?**
Because Lever doesn't publish it. Change detection for Lever works entirely off the content hash. This is a property of Lever's API, not a limitation of this actor.

**Why did a job show as `updated` with no visible difference?**
Only possible if you enabled `detect_timestamp_only`: `change_reason: "timestamp"` means the vendor's `updated_at` advanced but nothing in the content hash moved. It's off by default because Greenhouse re-stamps nearly a whole board at once whenever the employer edits every posting - often just to add a legal link - and on the event measured 2026-08-13 that touched 769 of 779 jobs. If you do want description edits, enable `hash_description`; those arrive as `change_reason: "content"`, and you should expect board-wide bursts along with the edits you actually care about.

**Why did no removals appear when a board clearly emptied out?**
The mass-removal guard held them. Check `RUN_SUMMARY.mass_removal_flagged`. Set `suppress_mass_removals: false` if you'd rather have them immediately.

**Which ATS platforms are supported?**
Greenhouse and Lever today. Ashby's public endpoint has been verified working and is the next candidate; Workable, SmartRecruiters, and Workday are further out.

### Support

Found a bug or want another ATS supported? Open an issue on the Issues tab.

# Actor input Schema

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

Company names. Each is looked up on Greenhouse first, then Lever. A pasted careers-page URL also works. Names found on neither platform are listed in the run summary, never billed. If a board token differs from the company name, use 'Boards by platform' below.

## `title_keywords` (type: `array`):

Case-insensitive substrings. A job is kept if its title contains at least one. Leave empty for no title filter.

## `first_run_mode` (type: `string`):

What to do when there is no stored baseline yet. 'emit\_all\_as\_new' returns every open job tagged 'new' (and bills for them). 'baseline\_only' returns nothing, records the baseline, and starts reporting real changes on the next run.

## `greenhouse_boards` (type: `array`):

Company board tokens from Greenhouse job board URLs. For https://boards.greenhouse.io/airbnb the token is 'airbnb'.

## `lever_boards` (type: `array`):

Company slugs from Lever job board URLs. For https://jobs.lever.co/spotify the slug is 'spotify'.

## `exclude_title_keywords` (type: `array`):

Case-insensitive substrings. A job is dropped if its title contains any of these. Applied before the include filter.

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

Case-insensitive substrings matched against the job's locations.

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

Case-insensitive substrings. Note: enabling this makes Greenhouse requests heavier, because Greenhouse only returns department data on its detailed payload.

## `remote_only` (type: `boolean`):

Keep only postings the source explicitly marks remote. Jobs with no remote signal are excluded rather than guessed at.

## `emit_unchanged` (type: `boolean`):

Include jobs that did not change. Never billed as a change event. Turns this into a full dump plus change labels.

## `include_description` (type: `boolean`):

Add the plain-text description to each output record. Makes Greenhouse responses substantially larger.

## `hash_description` (type: `boolean`):

Fold the description into the change hash so description edits count as updates. Increases sensitivity and request size. Changing this resets your baseline.

## `detect_timestamp_only` (type: `boolean`):

Report a job as 'updated' when the vendor's updated\_at advanced but nothing in the content hash changed. Off by default: measured on real Greenhouse boards, this signal is dominated by occasional bulk re-stamps that touch nearly every job at once with no visible change - each would be billed as an update. To catch description edits, enable 'Detect description changes' instead. Greenhouse only; Lever publishes no updated\_at.

## `state_store_name` (type: `string`):

Named key-value store holding your baseline between runs. Must be a named store: Apify gives every run a fresh default store, so a baseline there would never survive.

## `state_key` (type: `string`):

Pin the baseline to a fixed key. By default the key reflects your hashing options only, so adding a board keeps every other board's history while changing what is hashed starts a clean baseline.

## `reset_state` (type: `boolean`):

Discard the stored baseline and treat this run as a first run.

## `removal_alarm_ratio` (type: `number`):

Fraction of a board's tracked jobs that vanishing in one run is treated as suspicious rather than real. Guards against a truncated but successful response wiping your feed.

## `suppress_mass_removals` (type: `boolean`):

When the alarm trips, keep the baseline and emit no removals for that board, so a genuine mass close-out is reported only once a second run corroborates it.

## `max_concurrency` (type: `integer`):

These are free public endpoints; keep this polite.

## `request_timeout` (type: `integer`):

Per-request timeout for a single board fetch.

## `max_retries` (type: `integer`):

Retries use exponential backoff with jitter and honor Retry-After.

## Actor input object example

```json
{
  "companies": [
    "airbnb",
    "anthropic",
    "spotify"
  ],
  "title_keywords": [
    "engineer",
    "data"
  ],
  "first_run_mode": "emit_all_as_new",
  "greenhouse_boards": [],
  "lever_boards": [],
  "exclude_title_keywords": [],
  "locations": [],
  "departments": [],
  "remote_only": false,
  "emit_unchanged": false,
  "include_description": false,
  "hash_description": false,
  "detect_timestamp_only": false,
  "state_store_name": "ats-job-feed-state",
  "reset_state": false,
  "removal_alarm_ratio": 0.5,
  "suppress_mass_removals": true,
  "max_concurrency": 5,
  "request_timeout": 30,
  "max_retries": 4
}
```

# Actor output Schema

## `changes` (type: `string`):

One record per posting that is new, updated, or removed relative to the caller's stored baseline. Linked without a `view` parameter on purpose: Console renders the `changes` view from the dataset schema anyway, while API and MCP consumers get every field, including `content_hash`.

## `runSummary` (type: `string`):

Per-board counts and tracked-job totals, boards that failed to fetch, and boards where the mass-removal guard fired. Read this before trusting `removed`: a board that could not be reached is carried forward, not reported as closed.

# 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": [
        "airbnb",
        "anthropic",
        "spotify"
    ],
    "title_keywords": [
        "engineer",
        "data"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("dstyx/ats-job-feed-actor").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": [
        "airbnb",
        "anthropic",
        "spotify",
    ],
    "title_keywords": [
        "engineer",
        "data",
    ],
}

# Run the Actor and wait for it to finish
run = client.actor("dstyx/ats-job-feed-actor").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": [
    "airbnb",
    "anthropic",
    "spotify"
  ],
  "title_keywords": [
    "engineer",
    "data"
  ]
}' |
apify call dstyx/ats-job-feed-actor --silent --output-dataset

```

## MCP server setup

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

```

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/0fCv02qhN1LsT6luj/builds/i6Xm7jzhHb09Hs3jX/openapi.json
