# Job Postings Tracker: Greenhouse, Lever, Ashby & more (`descriptive_orbit/ats-job-tracker`) Actor

Track open jobs at any list of companies on Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee and Personio. One clean row per job (department, location, remote, salary, dates) plus what changed since the last run: new, closed and edited postings. Public job-board APIs only, no login.

- **URL**: https://apify.com/descriptive_orbit/ats-job-tracker.md
- **Developed by:** [Orbit Data Tools](https://apify.com/descriptive_orbit) (community)
- **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

## Company Job Postings Tracker: Greenhouse, Lever, Ashby, Workable & more

Give this Actor a list of companies. It returns every open job at those companies as one clean row each: title, department, team, location, remote or not, employment type, salary range (where the company publishes one), posted date and links. Run it again (or schedule it) and every row tells you **what changed since the last run**: new postings, closed postings, and postings whose title, location, workplace type or salary changed.

It reads the public job-board APIs that applicant tracking systems (ATS) provide so companies can show jobs on their careers pages. There is no login, no browser and no API key. A 150-job company takes about one second.

### Supported applicant tracking systems

| ATS | Example board URL | Also accepted | Salary in the data |
|---|---|---|---|
| Greenhouse | `https://job-boards.greenhouse.io/figma` | `boards.greenhouse.io/figma`, embed links, `greenhouse:figma` | When the company shows a pay range |
| Lever | `https://jobs.lever.co/spotify` | `jobs.eu.lever.co/...` (EU accounts), `lever:spotify` | When the company sets one |
| Ashby | `https://jobs.ashbyhq.com/ramp` | `ashby:ramp` | Yes, when the company shows compensation |
| Workable | `https://apply.workable.com/huggingface` | `huggingface.workable.com`, `workable:huggingface` | No (not in the public API) |
| SmartRecruiters | `https://jobs.smartrecruiters.com/BoschGroup` | `smartrecruiters:BoschGroup` | No |
| Recruitee | `https://bunq.recruitee.com` | `recruitee:bunq` | When the company sets one |
| Personio | `https://ottonova.jobs.personio.de` | `.jobs.personio.com`, `personio:ottonova` | No |

You can also enter:

- **A plain company name**, like `Stripe` or `Hugging Face`. The Actor tries Greenhouse, Lever, Ashby, Workable, Recruitee, SmartRecruiters and Personio in that order and uses the first board with open jobs. The run log and the `OUTPUT` record show what was tried.
- **The company's own careers page**, like `https://linear.app/careers`. The Actor opens it once and looks for a link to one of the boards above. Pages that build their job list with JavaScript may not show the link; in that case use the board URL or `ats:name`.

### What you can use it for

- **Recruiters and sourcers.** Watch the companies you hire from or compete with, and see each new role the day it is posted.
- **Job seekers.** Follow 50 target companies with a keyword filter ("product manager", "data") and a location filter, and get only the new matching jobs each morning.
- **Sales and business development.** Hiring is a buying signal: a company posting its first "Security Engineer" or five new sales roles in Germany is probably about to buy tools for that team. Filter by keyword and send the new rows to your CRM.
- **Market and competitor analysis.** Count open roles by department and location across a peer group, and track how fast postings open and close. `firstSeenAt` and `removed` rows give you posting lifetimes.
- **Salary research.** Greenhouse, Ashby, Lever and Recruitee boards often publish pay ranges; you get them as numbers with currency and period.
- **AI agents and automations.** Every ATS comes out in the same schema with one `changeType` to filter on, so an agent can call the Actor through the Apify API or MCP server and work with clean JSON.

### How to use it

1. Add your companies to **Companies**, one per line, in any of the forms above. You can mix ATS freely.
2. Optionally set **Keywords**, **Locations** and **Remote jobs only**.
3. Start the run. The first run marks every job as `new`, because there is nothing to compare with yet.
4. Run it again later, or create a schedule. From then on each row says what changed. Turn on **Output only changes** to get (and pay for) only the changes.

### How change tracking works

After each run the Actor saves each company's list of open jobs (title, department, location, workplace type, employment type, salary and links) in a named key-value store in your Apify account, `ats-job-tracker-state` by default. The next run compares the live list with that snapshot and sets `changeType` on each row:

| changeType | Meaning |
|---|---|
| `new` | The posting was not there last time (every posting on the first run) |
| `updated` | Title, department, team, location, workplace type, remote flag, employment type or salary changed. `changedFields` lists what changed and `previousValues` holds the old values |
| `removed` | The posting was there last time and is gone now: it was closed, filled or unpublished |
| `unchanged` | Same as last time |
| `error` | The company could not be read. The `error` field explains why. Error rows are free |

Details worth knowing:

- **Filters never cause fake changes.** The snapshot always holds the company's full, unfiltered list. Keywords, locations and remote-only only decide which rows are saved, so you can change them between runs without getting false `new` or `removed` rows.
- **`removed` is only reported when the whole list was read.** Greenhouse, Lever, Ashby, Workable, Recruitee and Personio always return the whole list. SmartRecruiters pages through 100 jobs at a time; the Actor reads up to 10,000 postings per company.
- **Nothing is lost when a run is cut short.** If **Max jobs per company** or your maximum charge stops a run before every change row is saved, the unsaved changes stay out of the snapshot and are reported on the next run. Rows that were saved are not reported (or charged) again.
- **A board that suddenly shows zero jobs** (maintenance, a temporary block, a board being moved) does not produce a wave of `removed` rows. The first empty run keeps the snapshot and adds a free warning row; if the next run is also empty, the removals are reported.
- **Description edits and the ATS's own "updated at" stamp are ignored**, because they change often for reasons that do not matter (typo fixes, re-saves).
- To start a fresh baseline, change **Snapshot store name**, or delete the company's record (for example `greenhouse--figma`) from the `ats-job-tracker-state` key-value store in Apify Console.

### Input example

```json
{
    "companies": [
        "https://job-boards.greenhouse.io/figma",
        "https://jobs.lever.co/spotify",
        "ashby:ramp",
        "https://apply.workable.com/huggingface",
        "Stripe"
    ],
    "keywords": ["engineer", "data"],
    "locations": ["New York", "Remote"],
    "remoteOnly": false,
    "includeDescription": false,
    "maxJobsPerCompany": 500,
    "trackChanges": true,
    "onlyChanges": true
}
```

| Field | Default | Description |
|---|---|---|
| `companies` | (required) | Board URLs, `ats:board-name` entries, plain company names or careers page URLs |
| `ats` | `auto` | Which ATS to use for plain company names; `auto` tries each one |
| `keywords` | all jobs | Keep jobs whose title, department or team contains one of these words. A keyword matches the start of a word: `engineer` finds "Engineering", and `product` also finds "Production"; write `product manager` to be stricter |
| `locations` | all locations | Keep jobs whose location contains one of these texts (case does not matter) |
| `remoteOnly` | false | Keep only jobs marked remote, or whose location says "Remote" |
| `includeDescription` | false | Add the description as plain text, up to 5,000 characters |
| `maxJobsPerCompany` | 500 | Rows saved per company per run, after filters. Changes come first |
| `trackChanges` | true | Compare with the previous run |
| `onlyChanges` | false | Save only `new`, `updated` and `removed` rows |
| `stateStoreName` | `ats-job-tracker-state` | Key-value store that holds the snapshots |
| `proxyConfiguration` | no proxy | Only needed if a board blocks requests |

### Output example

Real rows from test runs. The first is a new posting on an Ashby board. The second came from a later run after a salary change: to test the tracking, the saved snapshot was edited to a lower range before that run.

```json
[
  {
    "ats": "ashby",
    "company": "ramp",
    "companySlug": "ramp",
    "companyBoardUrl": "https://jobs.ashbyhq.com/ramp",
    "jobId": "34413f8d-26bf-4bbc-8ade-eb309a0e2245",
    "title": "Security Engineer, Cloud",
    "department": "Engineering",
    "team": "Backend",
    "location": "New York, NY (HQ)",
    "locations": ["New York, NY (HQ)", "Remote (Canada)", "Remote (US)", "Miami, FL"],
    "isRemote": true,
    "workplaceType": "hybrid",
    "employmentType": "Full-time",
    "salaryMin": 211400,
    "salaryMax": 290600,
    "salaryCurrency": "USD",
    "salaryPeriod": "year",
    "postedAt": "2026-04-07T17:12:35Z",
    "updatedAt": null,
    "url": "https://jobs.ashbyhq.com/ramp/34413f8d-26bf-4bbc-8ade-eb309a0e2245",
    "applyUrl": "https://jobs.ashbyhq.com/ramp/34413f8d-26bf-4bbc-8ade-eb309a0e2245/application",
    "description": null,
    "changeType": "new",
    "changedFields": [],
    "previousValues": null,
    "firstSeenAt": "2026-10-21T15:12:24Z",
    "scrapedAt": "2026-10-21T15:12:24Z",
    "sourceInput": "https://jobs.ashbyhq.com/ramp",
    "error": null
  },
  {
    "ats": "greenhouse",
    "company": "Figma",
    "companySlug": "figma",
    "companyBoardUrl": "https://job-boards.greenhouse.io/figma",
    "jobId": "5426468004",
    "title": "Account Executive, Enterprise",
    "department": "Sales",
    "team": null,
    "location": "San Francisco, CA • New York, NY • United States",
    "locations": ["San Francisco, CA", "New York, NY", "United States"],
    "isRemote": false,
    "workplaceType": null,
    "employmentType": null,
    "salaryMin": 165000,
    "salaryMax": 190000,
    "salaryCurrency": "USD",
    "salaryPeriod": "year",
    "postedAt": "2025-01-28T23:57:29Z",
    "updatedAt": "2026-07-22T09:37:08Z",
    "url": "https://boards.greenhouse.io/figma/jobs/5426468004?gh_jid=5426468004",
    "applyUrl": "https://boards.greenhouse.io/figma/jobs/5426468004?gh_jid=5426468004",
    "description": null,
    "changeType": "updated",
    "changedFields": ["salaryMin", "salaryMax"],
    "previousValues": { "salaryMin": 150000, "salaryMax": 170000 },
    "firstSeenAt": "2026-10-21T15:08:46Z",
    "scrapedAt": "2026-10-21T15:12:28Z",
    "sourceInput": "https://job-boards.greenhouse.io/figma",
    "error": null
  }
]
```

A `removed` row has the same fields, filled from the snapshot (title, location, salary and links as they were when the posting was last seen), with `changeType: "removed"`. An error row has `changeType: "error"`, the `sourceInput` you entered and an `error` message such as `Greenhouse: board not found (HTTP 404). Check the board name: https://job-boards.greenhouse.io/... should list the jobs.`

#### Output fields

| Field | Description |
|---|---|
| `ats` | `greenhouse`, `lever`, `ashby`, `workable`, `smartrecruiters`, `recruitee` or `personio` |
| `company`, `companySlug`, `companyBoardUrl` | Company name (when the ATS provides one, otherwise the board name), board name and board link |
| `jobId` | The ATS's ID for the posting |
| `title`, `department`, `team` | As published. SmartRecruiters: job function when no department is set. Workable: job function as team. Personio: recruiting category as team |
| `location`, `locations` | Main location, and every location of the posting |
| `isRemote`, `workplaceType` | Remote flag, and `remote` / `hybrid` / `onsite` when the ATS says so. Ashby's remote flag means "can be done remotely", so a hybrid role with remote options counts as remote |
| `employmentType` | Full-time, Part-time, Contract, Temporary or Internship (other values as published) |
| `salaryMin`, `salaryMax`, `salaryCurrency`, `salaryPeriod` | Pay range when the company publishes one; period is `year`, `month`, `week`, `day` or `hour` |
| `postedAt`, `updatedAt` | When the posting was published, and last updated (only Greenhouse and Recruitee publish the latter). UTC |
| `url`, `applyUrl` | Posting page and application link |
| `description` | Plain-text description when **Include job descriptions** is on |
| `changeType`, `changedFields`, `previousValues` | What changed since the last run |
| `firstSeenAt`, `scrapedAt` | When the Actor first saw this posting, and when this row was read |
| `sourceInput`, `error` | The entry you typed, and the error message on error rows |

The run's default key-value store also gets an `OUTPUT` record with a per-company summary: which ATS and board were used (and what auto-detection tried), open jobs, jobs matching your filters, rows saved, change counts, whether the full list was read, requests and timing.

### Pricing

This Actor uses pay-per-event pricing: **you pay per job row saved to the dataset**, plus a small fee per run start. Error rows are free, and filtered-out jobs are never charged. Apify platform usage is included in the event price.

See the price per row on the Pricing tab. As a worked example, assuming $1.00 per 1,000 rows:

- **One-off export** of 20 companies with about 150 open jobs each: 3,000 rows, about $3.00.
- **Daily monitoring of 50 companies** with about 7,500 open jobs in total, with **Output only changes** on:
  - First run: 7,500 `new` rows, about $7.50.
  - Each day after that: if about 4% of postings open or close (300 rows), about $0.30 a day, or about $9 a month.
  - The same monitor without Output only changes would save all 7,500 rows every day, about $225 a month, so turn it on for schedules.
- Add a keyword filter and you pay only for matching jobs: tracking only engineering roles at the same 50 companies might be a third of that.

You can cap any run with Apify's "Maximum cost per run" setting. When the cap is reached, the Actor stops cleanly, keeps what it has saved, and reports the unsaved changes on the next run.

### Scheduling tip

Create a task with your company list and **Output only changes** on, then add a schedule (for example daily at 07:00). Add a webhook, or the Slack, email, Google Sheets, Zapier or Make integration, to receive the new and closed postings as soon as each run finishes. Keep the same task and the same **Snapshot store name** so each run compares with the one before it. To run two independent monitors of the same companies (for example one per client), give each its own snapshot store name.

### FAQ

**Can an AI agent use it?** Yes. Add this Actor to Apify's MCP server (mcp.apify.com) and it becomes a tool for Claude, Cursor and other MCP clients. An agent can then ask which jobs opened or closed at a list of companies and get one clean row per posting back. You can also call it from code with the Apify API or any Apify client.

**Do I need an account or API key for these ATS?**
No. The Actor reads the same public job data that the companies' careers pages show.

**How do I find a company's board name?**
Open the company's careers page and click a job. The address usually shows the ATS and the board name, for example `jobs.lever.co/spotify/...` or `job-boards.greenhouse.io/figma/jobs/...`. Paste that link as it is. Or just type the company name and let auto-detection find it.

**Why did a company return an error row?**
The message says what happened. Common causes: the board name is misspelled, the company uses an ATS this Actor does not cover (Workday, iCIMS, Taleo, SuccessFactors and others), the company moved to another ATS, or a plain company name matched nothing. SmartRecruiters returns an empty list for unknown company IDs instead of an error, so a SmartRecruiters company with no jobs gets a free warning row on its first run. The other companies in the run are not affected.

**Why is the salary empty for some jobs?**
Only some companies publish pay ranges, and Workable, SmartRecruiters and Personio do not expose them in their public APIs. For Greenhouse, the range comes from the pay-transparency block in the posting; Greenhouse does not label the period, so it is set to `year` for amounts of 10,000 and more and `hour` for amounts under 500.

**How fast is it?**
It sends at most about one request per second to each ATS host and reads up to 5 companies at a time. Most ATS return a whole board in one request: a 700-job Greenhouse board takes 2 to 5 seconds. Lever pages through 100 jobs per request and its API can be slow (a 300-job board took 45 to 80 seconds in tests). SmartRecruiters also returns 100 jobs per request (a 330-job company took about 4 seconds), and with descriptions on it needs one extra request per saved job.

**Why does auto-detection pick the wrong company sometimes?**
Two companies can use the same board name on different ATS, and a plain name is matched in a fixed order. If that happens, use the board URL or `ats:name` instead.

**Which languages are supported?**
Whatever the company publishes. Titles and descriptions come as written (for example German for many Personio boards).

### Public job postings only, no personal data

The Actor reads job postings that companies publish for anyone to see, through the public endpoints the ATS vendors provide for careers pages. It does not log in or apply to jobs. It does not collect personal data: recruiter and hiring-manager names, email addresses and application questions are skipped even when an API returns them. It sends at most about one request per second to each host. You are responsible for using the data in line with each website's terms and the laws that apply to you.

# Actor input Schema

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

One company per line. Accepted forms: a job board URL (https://job-boards.greenhouse.io/figma, https://boards.greenhouse.io/figma, https://jobs.lever.co/spotify, https://jobs.eu.lever.co/company, https://jobs.ashbyhq.com/ramp, https://apply.workable.com/huggingface, https://jobs.smartrecruiters.com/BoschGroup, https://bunq.recruitee.com, https://ottonova.jobs.personio.de); an ATS prefix plus board name (greenhouse:stripe, lever:palantir, ashby:openai, workable:blueground, smartrecruiters:Ubisoft2, recruitee:bunq, personio:ottonova); a plain company name (Stripe), which is looked up on each ATS in turn; or the company's own careers page URL, which is scanned once for a link to one of these job boards. An entry that cannot be read produces one free error row and the run carries on.

## `ats` (type: `string`):

Which applicant tracking system to use for entries that are just a name (like "stripe"). "Auto" tries Greenhouse, Lever, Ashby, Workable, Recruitee, SmartRecruiters and Personio in that order and uses the first one with open jobs (the run log and the OUTPUT record show what was tried). URLs and "ats:name" entries ignore this setting.

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

Only output jobs whose title, department or team contains one of these words (any one is enough, case does not matter). A keyword matches the start of a word, so "engineer" finds "Engineer" and "Engineering". Leave empty for all jobs. Filters only change which rows are saved (and charged); change tracking always compares the full list.

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

Only output jobs whose location contains one of these texts, for example "London", "Germany", "New York" or "Remote". Case does not matter. Leave empty for all locations.

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

Only output jobs the ATS marks as remote, or whose location says "Remote".

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

Add the job description as plain text (up to 5,000 characters). Off by default to keep the dataset small. For SmartRecruiters this needs one extra request per saved job, so runs with many SmartRecruiters jobs take longer.

## `maxJobsPerCompany` (type: `integer`):

Save at most this many rows per company per run (after filters). Change rows (new, updated, removed) come first. Change tracking still reads the company's whole list, so the limit never causes fake "removed" rows; changes beyond the limit are reported on the next run.

## `trackChanges` (type: `boolean`):

Saves each company's list of open jobs in a named key-value store after the run, so the next run can mark every row as new, updated (title, department, location, workplace type, employment type or salary changed), removed (the posting closed) or unchanged. The first run for a company marks everything as new.

## `onlyChanges` (type: `boolean`):

Skip unchanged jobs, so each run saves (and charges) only new, updated and removed postings. Ideal for daily schedules. Needs change tracking (it is switched on automatically).

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

Name of the key-value store that keeps the job lists between runs. Use a different name to run separate monitors of the same companies, or to start a fresh baseline.

## `proxyConfiguration` (type: `object`):

Usually not needed: these job-board APIs are public. If a board answers with HTTP 403 or 429 in the error row, try Apify Proxy.

## Actor input object example

```json
{
  "companies": [
    "https://job-boards.greenhouse.io/figma",
    "https://jobs.ashbyhq.com/linear",
    "workable:huggingface"
  ],
  "ats": "auto",
  "remoteOnly": false,
  "includeDescription": false,
  "maxJobsPerCompany": 100,
  "trackChanges": true,
  "onlyChanges": false,
  "stateStoreName": "ats-job-tracker-state",
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

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

One row per open (or just closed) job with what changed since the previous run.

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

Totals per company and per change type for this run.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "companies": [
        "https://job-boards.greenhouse.io/figma",
        "https://jobs.ashbyhq.com/linear",
        "workable:huggingface"
    ],
    "maxJobsPerCompany": 100,
    "proxyConfiguration": {
        "useApifyProxy": false
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("descriptive_orbit/ats-job-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": [
        "https://job-boards.greenhouse.io/figma",
        "https://jobs.ashbyhq.com/linear",
        "workable:huggingface",
    ],
    "maxJobsPerCompany": 100,
    "proxyConfiguration": { "useApifyProxy": False },
}

# Run the Actor and wait for it to finish
run = client.actor("descriptive_orbit/ats-job-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": [
    "https://job-boards.greenhouse.io/figma",
    "https://jobs.ashbyhq.com/linear",
    "workable:huggingface"
  ],
  "maxJobsPerCompany": 100,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}' |
apify call descriptive_orbit/ats-job-tracker --silent --output-dataset

```

## MCP server setup

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