# ATS Jobs Tracker: Greenhouse, Lever, Ashby & Workable Jobs (`classier_linear/ats-jobs-tracker`) Actor

Track open jobs at any company on Greenhouse, Lever, Ashby or Workable. Paste board slugs, careers URLs or names. Only-new mode for daily monitoring, repost detection, salary, remote and department fields, plus a free per-company hiring summary. No login, no proxies. $1.75 per 1,000 jobs.

- **URL**: https://apify.com/classier_linear/ats-jobs-tracker.md
- **Developed by:** [Destiny Lazcano](https://apify.com/classier_linear) (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.25 / 1,000 job exporteds

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/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 Jobs Tracker: Greenhouse, Lever, Ashby & Workable Jobs

Export every open job at the companies you care about, straight from their public **Greenhouse, Lever, Ashby and Workable** job boards, and track hiring over time. Paste board slugs, careers URLs or company names. You get one clean row per job with title, department, location, remote flag, employment type, posting date, salary and links. You also get a free per-company **hiring summary** that shows how many roles are open, in which teams and where.

Built for recruiters and sourcing teams, sales teams who treat hiring as a buying signal, investors and analysts watching headcount growth, and job seekers and job boards that need fresh postings from specific employers.

### Why this tracker

- **Four ATSs, one schema.** Greenhouse, Lever (global and EU), Ashby and Workable are normalized into the same columns, so a mixed list of companies becomes one table.
- **Auto-detection.** Give a slug or a name (`stripe`, `Hugging Face`, `stripe.com`) and the Actor tries each ATS in turn until it finds a public board.
- **Only-new monitoring mode.** Schedule it daily: the first run exports everything up to your **Maximum jobs to export**, later runs export only postings you have not received before. Jobs you already have are never charged again.
- **Repost detection.** When a role disappears and comes back under a new job ID with the same title and location, it is flagged `isRepost: true` with `repostOf` pointing at the old ID.
- **Salary everywhere it exists.** Structured pay from Lever and Ashby, plus pay ranges parsed from the description text (for example `$150,000—$180,000 USD` on Greenhouse pay-transparency postings). `compensationSource` tells you which.
- **Free hiring summary** per company in the key-value store: open jobs by department, location, workplace type and employment type, jobs posted in the last 7 and 30 days, median days open, median USD salary, and in monitoring mode new, closed and reposted counts.

### Supported job boards

| ATS | Paste any of these | Notes |
|---|---|---|
| Greenhouse | `https://job-boards.greenhouse.io/stripe`, `https://boards.greenhouse.io/airbnb/jobs/123`, embed links with `?for=figma`, or `greenhouse:stripe` | Salary comes from the description text when the posting shows a range. |
| Lever | `https://jobs.lever.co/palantir`, `https://jobs.eu.lever.co/<site>`, or `lever:spotify` | Global and EU instances are both checked. |
| Ashby | `https://jobs.ashbyhq.com/openai`, or `ashby:ramp` | Structured compensation included. Unlisted (direct-link only) jobs are skipped. |
| Workable | `https://apply.workable.com/huggingface/`, `https://acme.workable.com`, or `workable:huggingface` | A `/j/<code>` job link does not name the company, so use the company page. |

Custom careers domains (for example `careers.airbnb.com`) are reduced to a name guess (`airbnb`) and auto-detected. Careers pages hosted on a shared platform or another ATS (Notion, GitHub Pages, Workday, SmartRecruiters, BambooHR, Gusto, Wellfound, Y Combinator and similar) are rejected with a clear error instead, because their domain names the vendor, not the employer. Paste the company's board link or name for those.

### Input examples

Three companies on three different ATSs (this is the prefilled example input in Console). `companies` is required and has no default. Without `maxJobs` an API or scheduled run uses 1,000.

```json
{ "companies": ["discord", "https://jobs.ashbyhq.com/linear", "https://apply.workable.com/huggingface/"], "maxJobs": 100 }
```

Remote engineering roles posted in the last 14 days, with descriptions:

```json
{
  "companies": ["stripe", "figma", "https://jobs.lever.co/palantir", "ashby:ramp"],
  "keywords": ["engineer", "developer"],
  "excludeKeywords": ["intern"],
  "remoteOnly": true,
  "postedWithinDays": 14,
  "includeDescription": true
}
```

Daily monitoring of a watch list (pair it with an Apify Schedule):

```json
{ "companies": ["stripe", "cloudflare", "lever:spotify"], "onlyNew": true, "stateKey": "competitors" }
```

Filters: **keywords** and **excludeKeywords** match whole words or phrases in the job title, ignoring case. Plural, -ing, -er and -ship forms also match, so `engineer` matches "Engineering Manager" and `intern` matches "Internship" but not "Internal Audit" or "International Tax". **locations** match whole words in the location text. A country name or abbreviation (`United States`, `USA`, `US`, `United Kingdom`, `UK`, `Germany`, ...) also matches the job's country code, whatever form the ATS uses. `Remote` matches remote-flagged jobs. Greenhouse publishes no country, so there a country filter matches only when the location text names the country. **departments** match department or team. **postedWithinDays** uses Greenhouse `first_published`, Lever `createdAt`, Ashby `publishedAt` and Workable `published_on`. Jobs with no date are kept.

### Output

One row per job, de-duplicated by ATS + company + job ID:

| Column | Example |
|---|---|
| `ats`, `company`, `companyName` | `greenhouse`, `discord`, `Discord` |
| `jobId`, `jobKey`, `title` | `8917177002`, `greenhouse:discord:8917177002`, `Associate Commercial Counsel` |
| `department`, `team` | `Legal` |
| `location`, `allLocations`, `country` | `San Francisco Bay Area or Remote (U.S.)`, ..., `US` (ISO code when the ATS gives a country) |
| `isRemote`, `workplaceType` | `true`, `remote` / `hybrid` / `onsite` / empty. Hybrid jobs are `isRemote: false` unless one of their locations is remote. |
| `employmentType` | `Full-time`, `Part-time`, `Contract`, `Internship`, ... |
| `postedAt`, `updatedAt`, `daysOpen` | `2026-11-03T16:27:16Z`, `2` |
| `compensation`, `salaryMin`, `salaryMax`, `salaryCurrency`, `salaryInterval`, `compensationSource` | `$200,000—$225,000 USD`, `200000`, `225000`, `USD`, `year`, `description` |
| `jobUrl`, `applyUrl`, `boardUrl` | links to the employer's own posting |
| `description` | plain text, only with **Include job description** |
| `isNew`, `isRepost`, `repostOf`, `firstSeenAt` | only-new mode only |
| `scrapedAt` | when the board was read |

Dates are ISO 8601 in UTC. Workable publishes dates without a time, so its `postedAt` is a plain date. Lever and Ashby publish no update time, and Lever's date is when the posting was created. `companyName` is filled where the ATS publishes it (Greenhouse and Workable).

The key-value store also holds **HIRING_SUMMARY** (free, one entry per company, computed over the whole board before your filters) and **RUN_REPORT** (what was read, what was found, what failed and why).

### Pricing

Pay per job delivered to your dataset. The summary, the report, closed-job notices and skipped already-seen jobs are free.

| Apify plan | Price per job | Per 1,000 jobs |
|---|---|---|
| Free and Bronze | $0.00175 | $1.75 |
| Silver | $0.00150 | $1.50 |
| Gold and above | $0.00125 | $1.25 |

Apify's standard run-start fee ($0.00005) applies. Your run's **Maximum cost per run** is respected: at $1.75 per 1,000 jobs a $1.00 cap allows 571 jobs, and the run stops cleanly with a status message saying so. **Maximum jobs to export** caps a run too.

### Monitoring with Apify Schedules

1. Turn on **Only new jobs** and give your list a **Monitoring list name**.
2. Raise **Maximum jobs to export** for the first run (the Console prefill is 100) so it covers every open job on your list. Run once. Everything currently open is exported and remembered, up to that maximum.
3. In Console, create a Schedule (for example daily at 07:00) that runs this Actor with the same input.
4. Each later run exports only new postings and updates HIRING_SUMMARY with new, closed and reposted counts. Connect a webhook, Slack or Google Sheets integration to get the new rows automatically.

If a run stops at the maximum, the jobs it did not reach come in later runs as `isNew: false` rows, and companies after the cut are not checked (no HIRING_SUMMARY entry) in that run.

The memory lives in your own account, in the named key-value store `ats-jobs-tracker-state-<name>`. Delete that store to start over. A job that was filtered out earlier is exported the first time it matches your filters.

### Limits and good citizenship

- Reads only the public, unauthenticated job-board endpoints each ATS documents for careers pages and widgets. It never logs in, never submits applications and never touches candidate data.
- One honest User-Agent, no proxies, no cookies, at most one request per second per host. When an ATS answers 401, 403, 429, 451, 503 or a bot check, that answer is recorded as **refused** and the Actor sends no further requests to that ATS for the rest of the run. Every later company on it is reported as refused too. A name being auto-detected is then also reported as refused instead of being matched on another ATS, so it never resolves to the wrong company's board. Network errors and other 5xx are retried once.
- One company failing (wrong slug, 404, timeout) never stops the run. The status message names the failures and RUN_REPORT gives the details.
- Up to 500 companies and 100,000 jobs per run. Large boards (several MB) take a few seconds each.
- Job-board content belongs to each employer. Link back with `jobUrl` and respect each employer's terms when you republish. Workable marks its pages as not for AI training.

### FAQ

**A company was "not found".** It may use another ATS, a different slug, or no public board at all. Open its careers page, copy the link to its job board and paste that instead of the name.

**Why is the salary empty?** The employer did not publish one. Ranges are read from structured fields first and from the description text second. Single numbers are ignored on purpose to avoid false matches.

**Do I get duplicate rows across runs?** Not in only-new mode. Without it, each run is a full snapshot.

**Why does Greenhouse not show `workplaceType`?** Greenhouse has no standard field. It is read from a custom "Workplace Type" field when the company has one. Some boards (Cloudflare, for example) put "Hybrid", "In-Office" or "Distributed" where the location should be. That value becomes `workplaceType`, and the location is taken from a location custom field or the offices. Otherwise `isRemote` falls back to "remote" in the location or title.

**Something looks wrong.** Open an issue on the Issues tab with your input JSON. Issues are answered within two business days.

# Actor input Schema

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

One company per line. Accepted: a careers or job-board URL (https://job-boards.greenhouse.io/stripe, https://jobs.lever.co/palantir, https://jobs.ashbyhq.com/openai, https://apply.workable.com/huggingface/), a board slug (stripe), a slug with the ATS forced (lever:palantir), a company domain (stripe.com) or a plain name (Hugging Face). Slugs, names and domains without a known ATS are auto-detected by trying Greenhouse, Lever, Ashby and Workable in turn. Up to 500 companies per run.

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

Which ATS bare slugs, names and domains belong to. Auto-detect tries each ATS until one has a public board (a few extra requests per company). URLs always use the ATS they point to.

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

Keep only jobs whose title contains at least one of these whole words or phrases (case-insensitive; plural, -ing, -er and -ship forms also match, so engineer matches Engineering Manager), for example engineer, sales, product manager. Leave empty for all jobs.

## `excludeKeywords` (type: `array`):

Drop jobs whose title contains any of these whole words or phrases (case-insensitive; plural, -ing, -er and -ship forms also match, so intern drops Intern and Internship but keeps Internal Audit), for example intern, senior, director.

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

Keep only jobs whose location matches one of these as whole words (case-insensitive; primary location and all listed locations), for example London, United States, Remote. A country name or abbreviation (United States, USA, US, UK) also matches the job's country code on Lever, Ashby and Workable. 'Remote' also matches jobs flagged remote.

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

Keep only jobs whose department or team contains one of these (case-insensitive), for example Engineering, Sales.

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

Keep only jobs marked as remote: Lever and Ashby workplaceType Remote (Ashby's isRemote flag only when it has no workplace type), a location that says remote, Workable telecommuting, or a Greenhouse 'Workplace Type' field, 'Remote'/'Distributed' location or 'remote' in the location or title. Hybrid jobs count only when one of their locations is remote.

## `postedWithinDays` (type: `integer`):

Keep only jobs posted in the last N days. Dates used: Greenhouse first_published, Lever createdAt, Ashby publishedAt, Workable published_on. Jobs with no date are kept.

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

Adds a plain-text 'description' column (HTML stripped, up to 50,000 characters). Off keeps the dataset small. Salary parsing from description text happens either way.

## `onlyNew` (type: `boolean`):

Remember which jobs were exported and export only jobs not exported before. The first run exports every matching job up to 'Maximum jobs to export' (raise it for the first run; the prefill is 100) and saves the state. Later runs (for example a daily Apify Schedule) return new postings plus any jobs an earlier capped run did not reach (those have isNew=false); already-exported jobs are never charged again. Also adds isNew, isRepost, repostOf and firstSeenAt, and closed/new/reposted counts in HIRING_SUMMARY.

## `stateKey` (type: `string`):

Name of the memory used by 'Only new jobs'. Runs with the same name share what has been exported; use a different name for each separate watch list. Stored in your account as the named key-value store ats-jobs-tracker-state-<name>; delete that store to start over.

## `maxJobs` (type: `integer`):

Stop after this many jobs in total across all companies; companies after that point are not checked (and get no HIRING_SUMMARY entry). Default 1000 when omitted (the Console prefill is 100). In 'Only new jobs' mode, set it high enough for the first run to cover all open jobs. Your run's 'Maximum cost per run' also caps the export: at $1.75 per 1,000 jobs a $1.00 cap allows 571 jobs.

## Actor input object example

```json
{
  "companies": [
    "discord",
    "https://jobs.ashbyhq.com/linear",
    "https://apply.workable.com/huggingface/"
  ],
  "ats": "auto",
  "remoteOnly": false,
  "includeDescription": false,
  "onlyNew": false,
  "stateKey": "default",
  "maxJobs": 100
}
```

# Actor output Schema

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

No description

## `hiringSummary` (type: `string`):

No description

## `runReport` (type: `string`):

No description

# 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": [
        "discord",
        "https://jobs.ashbyhq.com/linear",
        "https://apply.workable.com/huggingface/"
    ],
    "maxJobs": 100
};

// Run the Actor and wait for it to finish
const run = await client.actor("classier_linear/ats-jobs-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": [
        "discord",
        "https://jobs.ashbyhq.com/linear",
        "https://apply.workable.com/huggingface/",
    ],
    "maxJobs": 100,
}

# Run the Actor and wait for it to finish
run = client.actor("classier_linear/ats-jobs-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": [
    "discord",
    "https://jobs.ashbyhq.com/linear",
    "https://apply.workable.com/huggingface/"
  ],
  "maxJobs": 100
}' |
apify call classier_linear/ats-jobs-tracker --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,classier_linear/ats-jobs-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/9JSx5rqDf1Fxt78Vd/builds/PC5spNaUKQX7cHPg7/openapi.json
