# Job Change Tracker: Greenhouse, Lever, Ashby & more (`democratic_lazurite/ats-job-change-tracker`) Actor

Track open roles at any company on 6 ATS platforms in one clean schema. Each run flags jobs as new, updated or removed since last time, with changed fields listed. Paste slugs or careers URLs. No login, no proxies. $1 per 1,000 current jobs; removed rows are free.

- **URL**: https://apify.com/democratic_lazurite/ats-job-change-tracker.md
- **Developed by:** [Ressa](https://apify.com/democratic_lazurite) (community)
- **Stats:** 2 total users, 1 monthly users, 66.7% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 results

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

> **Changed in 0.1.5:** output is now metadata-only — description and salary fields were removed, and the default output mode is now changes-only (max 100 items). Pin build 0.1.4 if you need the old output.

## Job posting change detection for AI agents: Greenhouse, Lever, Ashby, SmartRecruiters, Recruitee & Workable

**Job posting change detection for AI agents.** Give the Actor a list of companies and it tells you which job postings **appeared, changed or disappeared** since the previous run. The input is careers-page URLs or board slugs. The output is one small metadata row per job: id, title, URL, company, locations, dates and change type. Job descriptions and salaries are never returned. Defaults are small and safe for tool calls (`outputMode: "changes"`, `maxItems: 100`), so it works well as an MCP tool, in scheduled automations, and in plain API calls.

> **Not affiliated with Greenhouse, Lever, Ashby, SmartRecruiters, Recruitee, or Workable.** This Actor reads only the public, unauthenticated job-board endpoints those platforms provide for employers' career sites. Job content belongs to the employers who posted it.
> **Removal / takedown requests:** contact `ressatools@mail.grokbot.com`. We act on verified requests from employers or platforms within 5 business days.

### Quick start (agents and API)

```json
{ "companies": ["https://jobs.lever.co/palantir", "https://jobs.ashbyhq.com/ramp", "stripe"] }
```

- **First call** for a given `stateStoreName`: every open job is reported as `changeType: "new"`. The response is capped by `maxItems`, which defaults to 100.
- **Jobs beyond `maxItems` on the first call** are still recorded as seen. They are not reported as `new` later, so the first call works as a baseline. To get the complete current list once, use `outputMode: "all"` with a high `maxItems`.
- **Later calls**: only the differences are returned. A call where nothing changed returns 0 rows.
- **Separate watchlists**: give each one its own `stateStoreName`, for example `"competitors"` or `"portfolio"`.
- **Run summary**: the per-company result (jobs found, new/updated/removed counts, errors) is in the `OUTPUT` key-value record.

### Input

| Field | Default | Meaning |
|---|---|---|
| `companies` | demo list | Careers URLs, `platform:slug` (`greenhouse:`, `lever:`, `lever-eu:`, `ashby:`, `smartrecruiters:`, `recruitee:`, `workable:`), or bare slugs. Bare slugs are probed on all six platforms. |
| `outputMode` | `changes` | `changes` = new + updated + removed. `new` = only new jobs. `all` = every open job plus removed ones. |
| `maxItems` | 100 | Maximum rows per run. Raise it for full exports. |
| `stateStoreName` | `ats-jobs-state` | Name of the "memory" used to compare runs. Use one per watchlist. |
| `keywords` | – | Keeps jobs whose title contains any of these words (case-insensitive). Descriptions are also matched while processing if `searchDescription` is on. |
| `searchDescription` | `true` | Also match keywords against the description. The description is used only for matching and is never output or stored. |
| `locations` | – | Case-insensitive substring match on locations. `Remote` also matches jobs flagged as remote. |
| `remoteOnly` | `false` | Only jobs flagged as remote. |
| `companyNameOverrides` | `{}` | `{ "slug" or "platform:slug": "Display Name" }` |
| `resolveCompanyNames` | `true` | Read the Lever/Ashby board page title once to get the real company name. |
| `smartRecruitersDetails`, `smartRecruitersMaxDetails` | `false`, 500 | Optional extra request per SmartRecruiters posting. It checks that the posting is active and detects description edits. |
| `maxConcurrency`, `minRequestIntervalMs`, `cacheTtlMinutes` | 5, 300, 0 | Throughput and politeness controls. |

### Output fields (metadata only)

| Field | Type | Meaning |
|---|---|---|
| `id` | string | Stable key `platform:companySlug:sourceId`. Use it to deduplicate. |
| `changeType` | `new` / `updated` / `unchanged` / `removed` | Change since the previous run with the same `stateStoreName`. `unchanged` appears only in `outputMode: "all"`. |
| `changedFields` | string\[] | On `updated` rows only. Any of `title`, `department`, `team`, `locations`, `workplaceType`, `employmentType`, `salary`, `description`. For salary and description, only the fact that they changed is reported, never the content. |
| `source` | string | `greenhouse`, `lever`, `lever-eu`, `ashby`, `smartrecruiters`, `recruitee` or `workable`. |
| `sourceId` | string | Posting ID on the platform. |
| `companySlug`, `company` | string | Board slug and display name. |
| `title` | string | Job title. On removed rows, the last title seen. |
| `department`, `team` | string | null | When provided by the platform. |
| `locations` | string\[] | Locations as published. |
| `isRemote` | boolean | null | Flagged remote by the platform, or a location says remote. |
| `workplaceType` | string | null | `remote`, `hybrid` or `onsite`, when known. |
| `employmentType` | string | null | Full-time, contract, intern, etc. |
| `postedAt`, `updatedAt` | ISO 8601 | null | Publication and last-edit time on the platform. `updatedAt` comes from Greenhouse and Recruitee only. |
| `url` | string | Public posting URL. Open it to read the full posting. |
| `applyUrl` | string | null | Application page link. The Actor never applies. |
| `firstSeenAt`, `lastSeenAt` | ISO 8601 | First time this watchlist saw the job, and the current run time. |
| `removedAt` | ISO 8601 | On `removed` rows only. |
| `isFirstRunForBoard` | boolean | `true` on a board's first run, when all jobs are `new`. |

Removed rows contain `id, source, company, companySlug, title, locations, isRemote, url, changeType, firstSeenAt, removedAt`.

```json
{
  "id": "lever:palantir:6ed76ce8-4156-4b60-b120-403538bd66cd", "changeType": "updated", "changedFields": ["title"],
  "source": "lever", "sourceId": "6ed76ce8-4156-4b60-b120-403538bd66cd", "companySlug": "palantir", "company": "Palantir Technologies",
  "title": "Senior Administrative Business Partner", "department": null, "team": "Administrative",
  "locations": ["Singapore, Singapore"], "isRemote": false, "workplaceType": "hybrid", "employmentType": "Full-time",
  "postedAt": "2026-08-11T10:58:11.368Z", "updatedAt": null,
  "url": "https://jobs.lever.co/palantir/6ed76ce8-…", "applyUrl": "https://jobs.lever.co/palantir/6ed76ce8-…/apply",
  "firstSeenAt": "2026-10-01T00:00:00.000Z", "lastSeenAt": "2026-10-02T00:00:00.000Z", "isFirstRunForBoard": false
}
```

### How it behaves

- **Metadata only.** The output never includes job descriptions or salary. The saved state holds metadata plus *hashes* of salary and description, which is enough to detect edits without storing any text. State from version 0.1.4 and earlier is migrated automatically and produces no false "updated" rows.
- **Safe state handling.** If a board fails in one run, its previous snapshot is kept, so you never get a burst of false "removed" rows. One bad company never fails the run.
- **Polite and read-only.** The Actor spaces requests per host, can cache responses, and retries with backoff on 429/5xx. It uses GET requests only and never submits applications. Unlisted postings are excluded: Ashby `isListed:false`, non-public or inactive SmartRecruiters postings, and unpublished Recruitee offers.

### Limitations

- Only these six platforms. Workday, iCIMS, Taleo and Personio are not supported.
- SmartRecruiters company IDs are case-sensitive, and an unknown ID returns an empty list. With `smartRecruitersDetails` off (the default), description-only edits on SmartRecruiters are not detected.
- Workable's public widget endpoint is undocumented and may change.
- The data is whatever the employer published. It is not verified.

# Actor input Schema

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

Required in practice. List of companies whose public job boards to check. Each item can be: a careers-page URL (e.g. "https://jobs.lever.co/palantir", "https://boards.greenhouse.io/airbnb", "https://jobs.ashbyhq.com/ramp", "https://jobs.smartrecruiters.com/Ubisoft2", "https://bunq.recruitee.com", "https://apply.workable.com/huggingface/"), an explicit "platform:slug" (greenhouse:, lever:, lever-eu:, ashby:, smartrecruiters:, recruitee:, workable:), or a bare slug like "stripe" (all six platforms are probed). If empty, a two-company demo list is used.

## `outputMode` (type: `string`):

Which rows to return. "changes" (default) returns only jobs that are new, updated or removed since the previous run with the same stateStoreName; on the very first run every job counts as new. "new" returns only newly appeared jobs. "all" returns every currently open job (changeType "unchanged" included) plus removed ones.

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

Optional. Only return jobs whose title contains at least one of these words (case-insensitive). Example: \["engineer", "data"]. Leave empty for no keyword filter.

## `searchDescription` (type: `boolean`):

When true (default), keywords also match the job description text while the run is processing. The description itself is never returned or stored. Set false to match titles only.

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

Optional. Only return jobs whose location contains one of these strings (case-insensitive substring), e.g. \["London", "Remote"]. "Remote" also matches jobs flagged as remote.

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

When true, only return jobs the platform marks as remote. Default false.

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

Upper limit on returned rows for this run (default 100, which keeps agent calls small and cheap). Raise it for full exports, e.g. 5000. Removed-job rows also count toward the limit. Note: on a first run, jobs beyond the limit are still recorded as seen (baseline) and will not be reported as new later.

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

How many company boards are fetched at the same time (1–20, default 5). The default is fine for most uses.

## `minRequestIntervalMs` (type: `integer`):

Politeness delay between two requests to the same ATS host, in milliseconds (default 300). Lower values are faster but less polite; keep the default unless you have a reason.

## `cacheTtlMinutes` (type: `integer`):

Reuse a board response fetched by an earlier run within this many minutes instead of downloading it again (0 = off, default). Useful when an agent calls the Actor repeatedly in a short time.

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

Name of the key-value store that remembers what was seen last run; change detection compares against it. Use one name per watchlist (e.g. "competitors-q4"); a new name starts fresh, so its first run reports every job as new. Default "ats-jobs-state".

## `companyNameOverrides` (type: `object`):

Optional JSON object mapping a slug or "platform:slug" to the company name to show, e.g. {"ramp": "Ramp", "ashby:open-ai": "OpenAI"}. Leave empty to use names from the job boards.

## `resolveCompanyNames` (type: `boolean`):

Lever and Ashby APIs only expose the board slug. When true (default), the Actor reads the public board page title once per board to get the company name (e.g. "palantir" becomes "Palantir Technologies").

## `smartRecruitersDetails` (type: `boolean`):

Off by default. When true, one extra request per SmartRecruiters posting checks that it is still active and lets description edits count as "updated" and keyword matching use the description. Descriptions are still never returned.

## `smartRecruitersMaxDetails` (type: `integer`):

Only used when "SmartRecruiters: extra detail requests" is on. Caps those extra requests per company (default 500); postings beyond the cap are tracked from the listing data only.

## Actor input object example

```json
{
  "companies": [
    "stripe",
    "https://jobs.lever.co/palantir",
    "https://jobs.ashbyhq.com/ramp",
    "smartrecruiters:LVMH",
    "https://bunq.recruitee.com",
    "https://apply.workable.com/huggingface/"
  ],
  "outputMode": "changes",
  "searchDescription": true,
  "remoteOnly": false,
  "maxItems": 100,
  "maxConcurrency": 5,
  "minRequestIntervalMs": 300,
  "cacheTtlMinutes": 0,
  "stateStoreName": "ats-jobs-state",
  "companyNameOverrides": {},
  "resolveCompanyNames": true,
  "smartRecruitersDetails": false,
  "smartRecruitersMaxDetails": 500
}
```

# Actor output Schema

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

All rows in the default dataset (Overview view).

# 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": [
        "stripe",
        "https://jobs.lever.co/palantir",
        "https://jobs.ashbyhq.com/ramp",
        "smartrecruiters:LVMH",
        "https://bunq.recruitee.com",
        "https://apply.workable.com/huggingface/"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("democratic_lazurite/ats-job-change-tracker").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": [
        "stripe",
        "https://jobs.lever.co/palantir",
        "https://jobs.ashbyhq.com/ramp",
        "smartrecruiters:LVMH",
        "https://bunq.recruitee.com",
        "https://apply.workable.com/huggingface/",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("democratic_lazurite/ats-job-change-tracker").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": [
    "stripe",
    "https://jobs.lever.co/palantir",
    "https://jobs.ashbyhq.com/ramp",
    "smartrecruiters:LVMH",
    "https://bunq.recruitee.com",
    "https://apply.workable.com/huggingface/"
  ]
}' |
apify call democratic_lazurite/ats-job-change-tracker --silent --output-dataset

```

## MCP server setup

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

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/ZqcE02nlA8Zh8gS39/builds/bXv2LaFnpoGh9Bfu2/openapi.json
