# Greenhouse, Lever, Ashby, Workable Jobs Scraper + Job Monitor (`wickfeed/ats-job-aggregator`) Actor

Greenhouse, Lever, Ashby, Workable & Recruitee jobs scraper + monitor — open roles from each company's live feed in one normalized schema. $1 per 1,000 jobs, $0 start. Optional diff mode, off by default: the first run bills the jobs delivered; later runs bill new + watched-field changes.

- **URL**: https://apify.com/wickfeed/ats-job-aggregator.md
- **Developed by:** [WickFeed](https://apify.com/wickfeed) (community)
- **Categories:** Jobs, Lead generation, MCP servers
- **Stats:** 2 total users, 0 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$1.00 / 1,000 job fetcheds

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/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are a software tools running on the Apify platform, for all kinds of web data extraction and automation use cases.
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.

In JavaScript/TypeScript projects, use official [JavaScript/TypeScript client](https://docs.apify.com/api/client/js.md):

```bash
npm install apify-client
```

In Python projects, use official [Python client library](https://docs.apify.com/api/client/python.md):

```bash
pip install apify-client
```

In shell scripts, use [Apify CLI](https://docs.apify.com/cli/docs.md):

````bash
# MacOS / Linux
curl -fsSL https://apify.com/install-cli.sh | bash
# Windows
irm https://apify.com/install-cli.ps1 | iex
```bash

In AI frameworks, you might use the [Apify MCP server](https://docs.apify.com/platform/integrations/mcp.md).

If your project is in a different language, use 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

## Greenhouse, Lever, Ashby, Workable Jobs Scraper + Job Monitor

**Know the moment a company you track posts or removes a job — and in diff mode, pay only for what's new.**

$1 per 1,000 jobs · no start fee · 1,925 boards verified 2026-07-15 · reads each company's own live job feed, not a copy of the web page, so a careers-page redesign never breaks it · name or URL in, jobs out.

**Live status:** https://api.apify.com/v2/key-value-stores/WickFeed~wickfeed-public-status/records/status.json — real self-test results for every wickfeed scraper (pass/fail plus 30-day success rate), updated hourly, no login needed. Opens as raw data (JSON).

**Choose the right WickFeed tool**

- Mixed ATS, or unsure which system a company uses → **this tool** (ATS Job Aggregator)
- Greenhouse only → [Greenhouse Jobs Scraper](https://apify.com/wickfeed/greenhouse-jobs-scraper)
- Ashby only, including published salary ranges → [Ashby Jobs Scraper](https://apify.com/wickfeed/ashby-jobs-scraper)
- Workable only → [Workable Jobs Scraper](https://apify.com/wickfeed/workable-jobs-scraper)
- Need companies and live job counts, not job rows → [ATS Company Discovery](https://apify.com/wickfeed/ats-company-discovery)

#### Your first run, start to finish

1. Click **Try for free** and sign in to Apify.
2. Leave the **5 demo boards** as they are and click **Start** — real jobs back in about a minute for about **$0.17** (at most **$0.25**). The free Apify plan's **$5 monthly credit** covers it many times over.
3. When the run finishes, open **Dataset** to see the job rows.
4. Click **Export** for CSV, Excel, or JSON.

That's the whole first run — no tokens to hunt, nothing to set up. Want more than the demo? [Quick start ↓](#quick-start)

Every source lands in one normalized schema. Turn on **diff mode** and each run returns **only the new and changed jobs** since your last run, **charged only for those** — so a daily monitor stays cheap. Prefer a one-shot pull? Leave diff mode off and get the whole board on demand.

**Supported ATS:** Greenhouse · Lever · Ashby · Workable · Recruitee

*Unofficial — not affiliated with or endorsed by Greenhouse, Lever, Ashby, Workable, or Recruitee. Job data is employer-published public information; descriptions may include recruiter contact details — use them for hiring research, not spam.*

### Just type the company — name or URL in, jobs out

Don't know a company's board token? You don't need it. Put a **company name** or a **careers URL** in the **"Company names or careers URLs"** box and the actor finds the hiring system and board for you. It merges with the preset and Sources lists (duplicates removed), so mix and match freely.

```json
{ "companies": ["Stripe", "https://boards.greenhouse.io/airbnb", "https://apply.workable.com/huggingface"] }
````

**How reliable each kind is (most reliable first):**

1. **A careers/board URL almost always works.** An ATS-hosted URL — `https://boards.greenhouse.io/stripe`, `https://jobs.lever.co/spotify`, `https://jobs.ashbyhq.com/ashby`, `https://apply.workable.com/huggingface`, `https://grid.recruitee.com` — carries the board token right in it, so we read it directly. This is the reliable path.
2. **A bare company name is best-effort.** We check a bundled index of named boards, then try a short list of live board-name guesses. It works for **most** names but **not all** — many companies don't name their board after themselves, and some systems (Ashby, Lever) don't expose a company name at all. That's expected, not a bug.

**If we can't find your company, you pay nothing and we tell you why.** Resolution is always **free** — you're only ever charged for jobs a matched board returns. Every input that can't be matched is reported in the **`RESOLUTION_REPORT`** key-value record with the reason and a prompt to paste the careers URL (or `{ ats, token }`) instead — at **$0**. Ambiguous names (a guess that matches more than one company) resolve to **nothing** on purpose, so you're never billed for the wrong company's jobs. Scanning your own company homepage (e.g. `https://acme.com/`) is a planned fast-follow, not live yet — paste the careers/board URL (the one with the board id) instead.

For a **stable daily monitor**, resolve once and pin the result: open `RESOLUTION_REPORT`, copy the resolved `{ ats, token }` boards into **`sources`** (or set a fixed **`diffStateKey`**). In diff mode the baseline is keyed on the boards watched, so pinning the tokens keeps the monitor drift-proof — at no cost.

### Quick start

Pressing **Start** with the prefills untouched runs the 5 demo boards (~**$0.17**, at most **$0.25**, roughly 165 jobs). Want more than the 5 demo boards?

1. **Pick a preset** from the dropdown — `starter-500` loads 500 verified boards in one click. This one isn't free: a first full run can reach **~$25** at the default cap (see the [first-run cost table](#bundled-company-lists-start-here)), so lower **Max results per source** to shrink it.
2. **Optionally** add keywords or locations to keep only the jobs you want.
3. **Press Start.** Each board delivers up to 50 **matching** jobs by default (`maxResultsPerSource`) — your keywords, locations, and other filters run over the **whole** board first, then the matches are capped, so a match never hides past the cap. Runs stay light: at $1 per 1,000 jobs, about 50 jobs costs roughly **$0.05**. Raise the cap once you like what comes back.
4. **Get the data out — no code.** Every run exports to **CSV, Excel, or Google Sheets** (plus JSON) through Apify's built-in export and integrations.

### Pricing

**Pay-per-event:** `job-fetched` — **$1.00 per 1,000 normalized jobs**. Starting a run costs nothing, and empty or failed runs are never charged. You pay only for jobs that pass validation and land in your dataset. In **diff mode** you pay only for the new and changed jobs delivered — on a day when nothing changed at your boards, the run costs **$0**, and closed-job detection (the `CLOSED_JOBS` list) is **bundled free**, not a paid add-on.

Charge-only-new billing isn't unique to us, but at **$1 per 1,000 with a $0 start fee** we sit at the cheap end of the store's incremental feeds — most we've checked run **$1.50–$12 per 1,000** (that range is for **multi-system** feeds that cover several hiring systems in one run, like this one; single-system scrapers quote a different, lower range), and at least one lists lower. **$1 per 1,000, $0 start — the cheapest feed in our price table below.**

**If a charge ever looks wrong,** open an issue on the Issues tab with your run link. If we over-billed you, we file a credit request with Apify to put it back on your account. (Apify pays seller compensation as account credit rather than cash, so that's what we can promise and what we'll do — not a guarantee about how Apify itself handles it.)

| Actor | Price per 1,000 jobs | Start fee (every run) |
|---|---|---|
| **This actor (wickfeed)** | **$1.00** | **$0** |
| `bovi/greenhouse-lever-ashby-job-scraper` | $1.50 | ~$0 (negligible) |
| `fantastic-jobs/career-site-job-listing-feed` | $2.50 | $0.10 |
| `jobo.world/ats-jobs-api` | $4.00 | ~$0 (negligible) |

*Competitor store prices as listed on 2026-07-15, at each actor's entry (free) subscription tier; some tier their per-1,000 price down on higher paid subscriptions. We charge one flat $1.00 per 1,000 with no start fee, and in diff mode you're billed only for the jobs that changed.*

### Run it daily — set up job alerts

Never used Apify before? This is the whole setup. Three steps, and each morning you get an email (or a Slack message) listing the new and changed roles at your boards since yesterday:

1. **Flip on "Only new & changed jobs (diff mode)"** in the input form above, then pick your boards or a preset. That switch is what makes each run deliver only what's new instead of the whole board every time.
2. **Put it on a daily timer.** Click **Actions → Schedule** at the top of this page — it saves your current settings as a task and schedules that task in one step. (By hand instead: **Actions (⋯) → Save as task**, then **Schedules → Create new → add that task**.) Either way, schedule the **task**, not the bare actor: a bare-actor schedule doesn't carry the diff-mode switch and board picks you just chose, so it would re-fetch and re-bill the whole board every day.
3. **Get the results sent to you.** On your saved task's **Integrations** tab, connect **Slack** (or **Email**) — Apify wires it to your run's Dataset for you. Prefer to check yourself? Every run's results wait on the run's **Storage** tab: job rows in **Dataset**, one click to CSV, Excel, or Google Sheets.

**What the alert can and can't include.** Each diff run's **Dataset** carries the **new and changed** jobs — that's what a Dataset integration sends to Slack or email. **Removed** jobs are recorded separately in **`CLOSED_JOBS`** (a Key-value store record), so a Dataset integration alone sends new and changed; **including removals needs a webhook or automation that also reads `CLOSED_JOBS`**.

**Watching your own hand-typed boards?** Give the monitor a fixed name — set **`diffStateKey`** to anything you like (say, `my-monitor`) so its memory of what it saw last time stays put between runs. Preset monitors are already steady (their memory is tied to the preset name), so skip this for those. (Running two separate monitors over the same hand-typed list? Give each its own name, or they'll share one memory.)

### Bundled company lists (start here)

Pick a preset and press Start — no token hunting. 1,925 boards, every one verified live against the vendor's own API on **2026-07-15**. A dead board never costs you anything — it's isolated and reported, not charged.

| Preset | Boards | First full run¹ | Use |
|---|---:|---:|---|
| `all-verified` | **1,925** | up to ~$96 | The full verified set — Greenhouse, Ashby, Workable, Recruitee. |
| `starter-500` | 500 | up to ~$25 | The 500 boards with the most active jobs — best first run. |
| `engineering` | 457 | up to ~$23 | Boards where most open roles are engineering. |
| `sales` | 132 | up to ~$6.60 | Boards where most open roles are sales. |
| `healthcare` | 53 | up to ~$2.65 | Boards where most open roles are healthcare. |
| `finance` | 37 | up to ~$1.85 | Boards where most open roles are finance. |
| Lever | **0 bundled** | — | Add boards by hand (see below) — EU list planned. |

¹ **Ceilings, not typical bills.** Each figure is the *most* a first full run could cost at the default cap: boards × 50 jobs × $1 per 1,000 (e.g. `starter-500` = 500 × 50 = 25,000 jobs = $25). Real runs are almost always well under, because many boards post fewer than 50 jobs, dead boards return nothing and are never charged, and any filter trims further. Lower **Max results per source** to shrink any preset proportionally. In diff mode the first run bills for the whole board like a normal run (these same ceilings apply); later runs charge only for what changed.

**Free Apify plan.** The **$5 monthly usage credit** comfortably covers the 5-board demo, `finance`, and `healthcare`. The larger presets exceed $5, so a free-plan run of `sales`, `engineering`, `starter-500`, or `all-verified` stops partway once the credit is used up.

A preset merges with anything you add in `sources` (duplicates removed by `ats:token`). Each preset entry carries `company`/`jobCount`/`verifiedAt` provenance; the actor reads only `ats`/`token`. **Where the boards came from:** tokens were discovered from the public **Common Crawl** URL index (crawls `CC-MAIN-2026-25` + `CC-MAIN-2026-21`), then each was independently confirmed live by us — no third-party curated dataset was copied. The full funnel and per-board provenance ship inside the actor's source, in `presets/NOTES.md` (next to the preset files). **Lever ships 0 boards** because its pages are robots-blocked in Common Crawl, so there was no license-clean way to source US Lever boards; add Lever boards by hand in `sources`. Region-aware coverage (incl. ~65 verified EU boards) is planned.

***

### Track only what's new — daily job alerts

Turn on **`diffMode`** and the actor remembers what it saw last time and returns **only the jobs that are new or changed** since your last run. Everything else is skipped, and **you're not charged for it**.

- **New** jobs (first time we've seen that id) and **changed** jobs (title, location, salary, or description edited) are delivered — each tagged `changeType` (`"new"`/`"changed"`) and `firstSeenAt`.
- **Unchanged** jobs are silently skipped and cost nothing.
- **Closed** jobs (there last run, gone now) are listed compactly under **`CLOSED_JOBS`** (`id`, `ats`, `companyToken`, `title`, `lastSeenAt`) on the run's **Storage** tab, in the **Key-value store** section — never mixed into your clean job Dataset. Bundled free.

**Your comparison history is private to you.** When you run a public actor on Apify, its storages are created **in your own account** — so the baseline (a key-value store named `ats-jobs-diff-state`) lives under your account; we don't have access to it. Different board lists keep separate histories automatically, or set `diffStateKey` to name your own.

**Honest note on the first run.** Your very first diff run has no history to compare against, so *every* job counts as new — it delivers and charges for the whole board, exactly like a normal run. From the second run on, you pay only for what actually changed — usually cents, and **$0 on a day nothing changed**.

#### Diff-mode rules (how it stays accurate and predictable)

1. **For accurate closed-job detection, keep the cap above each board's number of matching jobs.** Set **`maxResultsPerSource`** at or above how many jobs match your filters on each board (it defaults to 50 for cheap first runs). Filters run over the whole board first, so a board with more *matching* jobs than the cap is a partial view: the actor notices, **skips closed-job detection for that board that run** — carrying your baseline forward untouched and recording it (`diff.closedDetectionSkipped`, plus a `truncated` flag on that source's row) — rather than reporting false closures. A big board whose matches all fit under the cap gets full closed detection. A low cap never phantom-closes or re-bills jobs.
2. **Baselines and list changes.** **Presets** key the baseline to the **preset name**, so a re-verify/refresh does not reset it or re-bill you. **Your own boards** key the baseline to your board list **and your keyword/location filters**, so changing either starts a fresh baseline once (a one-time re-bill of the new set). Set a fixed **`diffStateKey`** to carry history across edits — but a fixed key pins one baseline, so narrowing filters under it will report the now-excluded jobs in `CLOSED_JOBS` and re-bill them if you widen again. **Change filters by starting a new `diffStateKey`, not by editing filters on the same one.**
3. **"Changed" watches four fields.** A job is re-delivered as `changed` only when its **title, location, salary, or description** is edited. Department, remote flag, and URL changes don't count.
4. **Reposts and a spend ceiling.** To cap what any single run can cost, set **`maxTotalChargeUsd`** in the Apify run options — the actor stops charging and delivering at the cap and notes it in the run summary.

### When something breaks, you'll know

The worst thing a job feed can do is quietly shrink — one board stops answering, your dataset gets smaller, and the run still shows green. This actor doesn't do that. When a run finishes but one or more boards couldn't be read:

- **The run is marked with a warning** you can see in the run list — *"2 of 12 sources failed this run — see DEGRADED\_SOURCES"* — instead of a silent green checkmark.
- **A `DEGRADED_SOURCES` record** is written to the run's **Storage** tab, listing every board that failed with, in plain English: what kind of failure it was, what it most likely means (a wrong token, the board went down, the source is throttling us, the company took the board offline…), and what to try next.
- **A failed board is never faked as empty.** Its jobs are *not* marked closed and *not* re-charged — they're held as-is until the board answers again. A dead token can't trigger a false wave of "job closed" alerts, and it can't bill you.

A normal quiet day is **not** a warning: if every board answered fine and there was simply nothing new, that's a healthy $0 run, not a degradation. **If you run this on a schedule, point your Apify alerts at any non-`SUCCEEDED` run status** (and watch for the warning message) so a degraded run reaches you the same way a full failure would. This is how we surface a problem — not a promise about how fast we answer support.

### Posting age, changes, and removals

This actor makes **no claim** about a company's intent or whether anyone was hired. It hands you observable, dated, source-linked facts, and you read them yourself:

- **How long a role has been up.** Every record carries `postedAt` (when the company first published it). In diff mode, `firstSeenAt` records when *you* first saw that id. Use these to see how long a posting has been visible.
- **The moment a role comes down.** In diff mode, the free `CLOSED_JOBS` list flags jobs that were there last run and are gone now, each with a `lastSeenAt` time. These are real removals, not guesses: a board that simply errored is never reported closed.
- **Reposts.** Ids are `<ats>:<companyToken>:<jobId>`. If the ATS assigns a new job id, the repost appears as a new record — compare title, source, and dates to spot it. (Not every repost gets a new id.)

Every result links to its public ATS source, so you can open the posting and decide for yourself. These are observable signals — not a compliance or accuracy guarantee, and not a claim about employer intent or hiring outcomes.

### Schema stability (the output contract)

- **We never rename or remove an existing output field within a major version.** The keys we emit — `id`, `ats`, `title`, `url`, and the rest — keep their names and meaning.
- **New fields are added, never forced.** Adding a field never touches the ones you already read, so existing code keeps working.
- **A breaking change is loud, not silent.** Renaming or removing a field is a **major version bump**, announced in the **Changelog** with a note in the **Issues** tab before it lands.
- **`schemaVersion` is a breaking-contract version.** Every record carries it (and it's in `RUN_SUMMARY`). It is **not** our release number and **not** an exact-shape fingerprint: **adding a new field does not bump it** — it changes only when an existing field is renamed, removed, or retyped (a breaking change, which also bumps our major version). So an unchanged `schemaVersion` means every field you already read is still present, with the same name, type, and meaning; new fields may have been added alongside. It's `1` today.

### Input

```json
{
  "companies": ["Stripe", "https://boards.greenhouse.io/airbnb"],
  "preset": "none",
  "sources": [
    { "ats": "greenhouse", "token": "stripe" },
    { "ats": "lever", "token": "spotify" },
    { "ats": "ashby", "token": "ashby" },
    { "ats": "workable", "token": "huggingface" },
    { "ats": "recruitee", "token": "grid" }
  ],
  "maxCompanies": 25,
  "keywords": ["engineer", "product"],
  "locations": ["remote", "berlin"],
  "remoteOnly": false,
  "postedAfter": "2026-01-01",
  "maxResultsPerSource": 50,
  "includeHtml": false,
  "diffMode": false,
  "diffStateKey": ""
}
```

An API call with **no** `companies`, `sources`, or `preset` runs the **5 demo boards** (up to $0.25) — the same as pressing Start in the Console. An explicit `sources: []` returns a clean error.

| Field | Type | Notes |
|---|---|---|
| `companies` | string\[] | **The easy path.** Company **names** or **careers URLs**, resolved to `{ ats, token }` for you. A URL is reliable; a bare name is best-effort. Resolution is **free**; misses are reported in `RESOLUTION_REPORT` and cost `$0`. Merges with `preset` + `sources` (deduped). |
| `maxCompanies` | integer | Caps how many `companies` entries are resolved per run. Default `25`, max `200`. Extras are reported `skipped`, never charged. |
| `preset` | string | Start from a bundled verified-live list. One of `none`|`starter-500`|`engineering`|`sales`|`healthcare`|`finance`|`all-verified`; default `none`. Merged with `companies` + `sources`. |
| `sources` | array | The explicit path — one `{ ats, token }` per board. `ats` ∈ `greenhouse`|`lever`|`ashby`|`workable`|`recruitee`. **Optional** when `companies` or a `preset` is set. Malformed/duplicate entries are **skipped**, never fatal; the run fails only with no usable board at all. |
| `keywords` | string\[] | Keep jobs whose **title, department or description** contains ANY keyword (case-insensitive). |
| `locations` | string\[] | Keep jobs whose `location` contains ANY substring (case-insensitive). |
| `remoteOnly` | boolean | Keep only jobs the ATS explicitly marks remote. Unknown-remote jobs are excluded when `true`. |
| `postedAfter` | ISO date | Keep jobs posted on/after this date. Unknown-date jobs are excluded when set. |
| `maxResultsPerSource` | integer | Cap on **matching** jobs delivered per board. Filters run over the whole board first, then the matches are capped — so a match never hides past the cap. Default `50` for a fast, low-cost first run. In diff mode, keep it at or above each board's number of matching jobs for accurate closed-job detection (see the [diff-mode rules](#diff-mode-rules-how-it-stays-accurate-and-predictable)). |
| `includeHtml` | boolean | Also emit `descriptionHtml` (raw HTML). Default `false`. |
| `diffMode` | boolean | Return only **new + changed** jobs since the last run, and charge only for those. Adds `changeType`/`firstSeenAt`; lists vanished jobs in `CLOSED_JOBS`. Default `false`. |
| `diffStateKey` | string | Optional. Names the saved diff baseline. Blank = derived from your board list. Same value on two runs shares one baseline. |

The `token` is the board id inside that ATS, read straight off the careers URL:

| ATS | Endpoint used | Example token |
|---|---|---|
| Greenhouse | `boards-api.greenhouse.io/v1/boards/{token}/jobs?content=true` | `stripe` |
| Lever | `api.lever.co/v0/postings/{token}?mode=json` | `spotify` |
| Ashby | `api.ashbyhq.com/posting-api/job-board/{token}?includeCompensation=true` | `ashby` |
| Workable | `apply.workable.com/api/v1/widget/accounts/{token}?details=true` | `huggingface` |
| Recruitee | `{token}.recruitee.com/api/offers/` | `grid` |

### Output

One record per job, pushed to the dataset. Every ATS produces the **same fields**, with honest `null`s where a source doesn't expose a value:

```json
{
  "id": "greenhouse:stripe:6415041",
  "ats": "greenhouse",
  "companyToken": "stripe",
  "company": "Stripe",
  "title": "Software Engineer, Payments",
  "department": "Engineering",
  "location": "San Francisco, CA",
  "remote": null,
  "url": "https://job-boards.greenhouse.io/stripe/jobs/6415041",
  "postedAt": "2026-07-02T09:15:00.000Z",
  "updatedAt": "2026-07-09T17:42:11.000Z",
  "salaryRaw": null,
  "descriptionText": "As a Payments engineer, you'll build the systems that move money …",
  "fetchedAt": "2026-07-15T18:00:00.000Z",
  "schemaVersion": 1
}
```

| Field | Type | Meaning |
|---|---|---|
| `id` | string | Stable id: `"<ats>:<companyToken>:<jobId>"`. Deduplicate on this. |
| `ats` | string | Source ATS. |
| `companyToken` | string | The board token you supplied. |
| `company` | string | null | Best-effort display name (see per-ATS notes). |
| `title` | string | Job title. |
| `department` | string | null | Department / team when the ATS provides it. |
| `location` | string | null | Location string as provided/assembled. |
| `remote` | boolean | null | `true`/`false` when the ATS tells us; `null` when unknown. |
| `url` | string | Public posting URL. |
| `postedAt` | ISO string | null | First published time (UTC). |
| `updatedAt` | ISO string | null | Last update time (UTC) when the ATS provides it. |
| `salaryRaw` | string | null | Raw salary text when exposed. Not parsed into numbers. |
| `descriptionText` | string | Plain text, HTML stripped, capped at ~20,000 chars. |
| `descriptionHtml` | string | null | Only present when `includeHtml: true`. |
| `fetchedAt` | ISO string | When this run fetched the record. |
| `changeType` | string | **Diff mode only.** `"new"` or `"changed"`. |
| `firstSeenAt` | ISO string | **Diff mode only.** When this job id was first seen across your runs. |
| `schemaVersion` | number | The output-record schema version (currently `1`). |

**Per-ATS notes.** **Greenhouse** — no remote flag (only `true` when the location says "remote") and no salary; `company` is usually the title-cased token. **Lever** — no update timestamp; `remote` from `workplaceType`; `company` is the title-cased slug. **Ashby** — `remote` is the explicit `isRemote`; `salaryRaw` from the compensation summary when present; `company` is the title-cased org slug. **Workable** — `company` is the account's real display name; `remote` is the `telecommuting` flag. **Recruitee** — explicit `remote`; `company` is `company_name`; `salaryRaw` from the structured `salary` object.

### Limitations

- **Public boards only.** It reads the job data a company chooses to publish — not private/authenticated ATS data, internal reqs, or candidate information.
- **Company discovery via bundled lists.** No ATS endpoint enumerates every company and the actor doesn't crawl. The bundled lists give 1,925 verified boards; for anything else, read the token off the careers URL.
- **No server-side search.** Endpoints return the whole board; `keywords`/`locations`/`remoteOnly`/`postedAfter` are applied over the whole board after fetching, and only then are the matches capped to `maxResultsPerSource` — so the cap never hides a match.
- **Field coverage varies by ATS.** `updatedAt` and `salaryRaw` are often `null` because several endpoints don't expose them; `remote` is `null` when the source doesn't say.
- **Rate & politeness.** Requests are capped at ~2/second with retries and backoff, so very large runs take proportionally longer.
- **Endpoint drift.** If a vendor changes a response shape, that ATS's normalizer may need an update. Failures are isolated per-source and listed in `DEGRADED_SOURCES` rather than crashing the run.

### FAQ

**How do I estimate what a run will cost?** Count the jobs and divide by 1,000: at $1 per 1,000, a run of *N* jobs runs about *N ÷ 1,000* dollars — ~50 jobs ≈ **$0.05**, 25,000 jobs ≈ **$25** at the ceiling. In diff mode only the first run bills the whole board; after that you pay for what changed.

**Do I need proxies?** No. These are public vendor APIs — the actor calls them directly, the same request a browser makes when it loads a careers page.

**Is this allowed?** It reads only the job data a company chooses to **publish** — the same public JSON feeds a careers page loads in your browser. No login, no authentication, no HTML scraping. How you use the results is on you (descriptions can include recruiter contact details — for hiring research, not spam). This is a factual description of what the actor does, not a legal or compliance guarantee.

**What if a board token is wrong or dead?** It's skipped and logged for that one source; the rest of your run finishes normally. You're never charged for a board that returned nothing.

**How fresh is the bundled company list?** Every board was verified live on **2026-07-15** (the date updates whenever the list is re-verified). A board that has since died returns zero jobs and is never charged — and you can re-verify any board yourself just by running it.

### Use with AI agents (MCP)

This actor is callable as a tool by AI agents through **Apify's hosted MCP server** at `mcp.apify.com` — nothing to install or host on our side. Point your MCP client at Apify's server:

```json
{ "mcpServers": { "apify": { "url": "https://mcp.apify.com" } } }
```

That form uses OAuth sign-in; to pass a token instead, add a header `"Authorization": "Bearer <YOUR_APIFY_TOKEN>"`. Then ask the agent to run `wickfeed/ats-job-aggregator` with your boards (or a preset), and set `diffMode: true` for a daily monitor. Output is flat, stable JSON with consistent field names and ISO-8601 dates. Agentic pay-per-event billing is enabled.

**Integrations.** Works with n8n, Make, and Zapier through Apify's native integrations — route the dataset into Slack, a webhook, a Google Sheet, or your own pipeline.

### For engineers

The dataset holds job records only. A **`RUN_SUMMARY`** is written to the **key-value store** (not a dataset item), next to **`REJECTS`** (records that failed the schema check, with reasons — never silently dropped; only pushed records are charged). `RUN_SUMMARY` carries `actorVersion`, `schemaVersion`, a `totals` object, and a `perSource` row (`pushed` + any `note`).

- **Degraded sources.** If the run succeeded but one or more boards errored, `RUN_SUMMARY` gains a `degraded` block, the run status is set to a `WARNING`, and **`DEGRADED_SOURCES`** lists each failed board (`ats`, `token`, `errorClass`, raw `error`, plain-English `meaning`, `retry` advice).
- **Resolution report.** When you use `companies`, a per-company **`RESOLUTION_REPORT`** records each input as `resolved` (with `matchedBy`/`ats`/`token`/`careersUrl`/`confidence`), `ambiguous`, `unresolved` (with a `reason` and paste `hint`), or `skipped` (over `maxCompanies`). Resolution issues no charge.
- **Diff-mode summary.** `totals` also carries `new`/`changed`/`unchanged`/`closed`, and `RUN_SUMMARY` gains a `diff` block. Removed jobs go to `CLOSED_JOBS` in the run's default key-value store; the baseline lives in the named `ats-jobs-diff-state` store under your account. The baseline stores only hashes and labels (never job bodies) and shards by ATS if it grows past ~9 MB, so even an 80k-job watchlist stays lean.
- **Response size.** Runs on a 512 MB floor. Greenhouse returns a whole board in one un-paginated response (biggest real boards ~35 MB), so its fetch is capped at 64 MB; other systems paginate and are capped at 30 MB (tunable via `ATS_MAX_RESPONSE_BYTES`). An over-size response is refused for that one source, not the whole run.

### Changelog

- **0.2.10 — 2026-07-16** — Added a public live-status link (real self-test results, no login).
- **0.2.9 — 2026-07-16** — **Filter before capping, plus honesty fixes.** **`maxResultsPerSource` now caps matching jobs, not raw rows:** filters run over the whole board first, then the matches are capped — so a job that matches your keywords/location/date is never hidden past the cap (it previously could be). Per-board cost ceilings are unchanged (still at most the cap × $0.001 per board). In diff mode, closed-job detection now skips a board only when its *matching* jobs exceed the cap, so big boards whose matches fit under the cap get full closed detection. **Invalid `postedAfter` dates are rejected** with a plain-English error instead of silently disabling the date filter. **Distinct `diffStateKey` values can no longer collide** onto one baseline (a key with special characters gets a short hash appended; plain keys are unchanged, so existing baselines keep resolving). Plus honest wording for `schemaVersion` (a breaking-contract version, not an exact-shape fingerprint) and provenance references. All backward-compatible for existing pipelines.
- **0.2.8 — 2026-07-16** — **Bare API `{}` now runs the demo.** An API call with no `sources`, `companies`, or `preset` runs the 5 prefilled demo boards (up to **$0.25** at the default 50-job cap), matching the Console's "press Start" demo, instead of failing with "no usable sources." An explicit `sources: []` still returns a clean error, and bad input still fails with the same messages. Billing note: a bare `{}` first run is now billable (worst case $0.25) rather than free.
- **0.2.2 — 2026-07-16** — Three additive features. **Company autodetect:** optional `companies` input resolves names/careers URLs to `{ ats, token }` sources (free; misses reported in `RESOLUTION_REPORT`; bounded by `maxCompanies`). **Schema-stability contract:** existing output fields are never renamed/removed within a major version; every record now carries `schemaVersion` (currently `1`), also in `RUN_SUMMARY`. **Degradation warning:** a run that succeeds with one or more board errors sets a `WARNING` status and writes `DEGRADED_SOURCES`; a failed board is never treated as closed or charged. All additive — existing pipelines unaffected.
- **Diff mode** — Optional `diffMode` returns only new + changed jobs and charges only for those; adds `changeType`/`firstSeenAt`, a `CLOSED_JOBS` summary, and a per-buyer private baseline in the named `ats-jobs-diff-state` key-value store. Off by default; non-diff runs are unchanged.
- **0.1.0** — Initial build. Five ATS normalizers, one normalized schema, per-source failure isolation, retries/backoff, rate limiting, validation with `REJECTS`, run summary, bundled verified-live company presets, unit tests + live smoke test.

***

Need to find *which* companies use these hiring systems first? [ATS Company Discovery](https://apify.com/wickfeed/ats-company-discovery) returns verified-live companies with current job counts ($2/1k). If this actor saved you time, an honest review helps others find it.

# Actor input Schema

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

Leave this blank to run the 5 prefilled demo boards below. Maximum first-run cost: $0.25. Or type a company NAME (e.g. "Stripe") or paste an ATS careers/board URL (e.g. "https://boards.greenhouse.io/stripe") — the actor finds the hiring system and board for you, no tokens to hunt. A careers/board URL is the most reliable; a bare name is best-effort (some companies can't be matched by name — those are reported, never charged, with the reason and a prompt to paste the careers URL or { ats, token } instead). Resolution is FREE; you pay only for the jobs the matched boards return. Merges with the preset and Sources lists below.

## `preset` (type: `string`):

Start from a bundled, verified-live company list — no need to hand-type board tokens. Leave this on "None" to use the Sources box below (prefilled with 5 demo boards for an instant, low-cost first run — up to about $0.25, usually ~$0.17). Switch to "Starter 500" for full coverage: the 500 boards with the most active jobs, all confirmed live on 2026-07-15 (a first full run costs up to about $25 at the default 50-jobs cap — lower "Max results per source" to reduce it). A preset is MERGED with anything you add in Sources (duplicates removed by ats:token), so you can layer your own boards on top. Board counts — Starter 500: 500 · Engineering: 457 · Sales: 132 · Healthcare: 53 · Finance: 37 · All verified: 1,925. FIRST-RUN COST CEILING at the default cap (boards × 50 × $1/1,000) — Starter 500 ≈ $25 · Engineering ≈ $23 · Sales ≈ $6.60 · Healthcare ≈ $2.65 · Finance ≈ $1.85 · All verified ≈ $96; these are ceilings and real runs are usually well under (many boards post fewer than 50 jobs, dead boards are never charged). The free plan's $5 monthly credit covers the demo, Finance and Healthcare; lower "Max results per source" to shrink any run. In diff mode the first run bills for the whole board like a normal run; later runs charge only for new and changed jobs. Presets currently cover Greenhouse, Ashby, Workable and Recruitee (Lever boards can still be added by hand in Sources).

## `sources` (type: `array`):

The explicit, power-user path: one entry per company job board as { "ats": "greenhouse|lever|ashby|workable|recruitee", "token": "<board-token>" }. Prefer the "Company names or careers URLs" box above if you don't already know the token — it resolves names/URLs into these entries for you. The token is the company's board identifier in that ATS (e.g. Greenhouse board "stripe", Lever site "spotify", Ashby org "ashby", Workable subdomain "huggingface", Recruitee subdomain "grid"). Optional — the companies, preset, and this list are all merged (duplicates removed). Malformed or duplicate entries are skipped, not fatal; the run fails only when there is no usable board at all.

## `maxCompanies` (type: `integer`):

Advanced. Caps how many entries in "Company names or careers URLs" the actor looks up per run (keeps runs fast and predictable). Default 25, max 200. Any extra entries are reported as skipped and never charged. Looking a company up is always free — this only limits how many lookups happen at once.

## `diffMode` (type: `boolean`):

Get only jobs that are NEW or CHANGED since the last run, not the whole board each time. Each delivered job is tagged changeType ("new" or "changed") plus a firstSeenAt date, and you pay only for those — unchanged jobs cost nothing. Your first run has no history yet, so it charges for everything; later runs stay small. Best paired with a daily Apify schedule. Jobs that disappeared are listed under CLOSED\_JOBS on the run's Storage tab. For a clean monitor of your own hand-entered boards, set a fixed "Monitoring memory name" below (preset monitors are already stable). Keep "Max results per source" at or above each board's job count for accurate closed-job detection — see the README's diff-mode rules.

## `diffStateKey` (type: `string`):

Advanced, optional — most people leave this blank. In diff mode the actor remembers what it saw last time so it can show you only what's new; this just gives that memory a name. Leave it blank and the actor names it for you. Set a fixed name only if you want one steady monitor that survives edits to your board list — and if you do, change your keyword/location filters by starting a NEW name rather than editing the old one (editing filters under a fixed name can re-bill jobs). Full rules are in the README under "Diff mode". Best to use letters, numbers, dot, dash, and underscore; other characters are made safe automatically, and each distinct name always keeps its own separate memory.

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

Keep only jobs whose title, department or description contains ANY of these (case-insensitive). Leave empty for no keyword filtering.

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

Keep only jobs whose location contains ANY of these substrings (case-insensitive). Leave empty for no location filtering.

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

Keep only jobs the hiring system explicitly marks as remote. Heads-up: jobs where the source didn't say either way are also dropped while this is on, so a board that doesn't tag remote can come back empty — leave this off and filter by Location if you're not sure.

## `postedAfter` (type: `string`):

Keep only jobs posted on/after this date (e.g. 2026-01-01). Jobs with an unknown post date are excluded when set. An unreadable date is rejected with a clear error — it never silently turns the filter off. Leave blank to not filter by date.

## `maxResultsPerSource` (type: `integer`):

Cap on the number of MATCHING jobs delivered per board. Your keyword/location/remote/date filters run over the whole board first, then the matches are capped — so a matching job is never hidden past the cap. Defaults to 50 so a first run stays fast and light; raise it once you're happy with the output. In diff mode, keep this at or above each board's number of matching jobs for accurate closed-job detection — if a board has more matching jobs than the cap, the actor skips closed detection for it that run rather than reporting false closures.

## `includeHtml` (type: `boolean`):

Also include the original HTML job description as descriptionHtml. Off by default to keep records lean.

## Actor input object example

```json
{
  "companies": [
    "Stripe",
    "https://boards.greenhouse.io/airbnb",
    "https://apply.workable.com/huggingface"
  ],
  "preset": "none",
  "sources": [
    {
      "ats": "greenhouse",
      "token": "stripe"
    },
    {
      "ats": "lever",
      "token": "spotify"
    },
    {
      "ats": "ashby",
      "token": "ashby"
    },
    {
      "ats": "workable",
      "token": "huggingface"
    },
    {
      "ats": "recruitee",
      "token": "grid"
    }
  ],
  "maxCompanies": 25,
  "diffMode": false,
  "keywords": [
    "engineer",
    "product"
  ],
  "locations": [
    "remote",
    "berlin"
  ],
  "remoteOnly": false,
  "maxResultsPerSource": 50,
  "includeHtml": false
}
```

# 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": [],
    "preset": "none",
    "sources": [
        {
            "ats": "greenhouse",
            "token": "stripe"
        },
        {
            "ats": "lever",
            "token": "spotify"
        },
        {
            "ats": "ashby",
            "token": "ashby"
        },
        {
            "ats": "workable",
            "token": "huggingface"
        },
        {
            "ats": "recruitee",
            "token": "grid"
        }
    ],
    "maxCompanies": 25,
    "maxResultsPerSource": 50
};

// Run the Actor and wait for it to finish
const run = await client.actor("wickfeed/ats-job-aggregator").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": [],
    "preset": "none",
    "sources": [
        {
            "ats": "greenhouse",
            "token": "stripe",
        },
        {
            "ats": "lever",
            "token": "spotify",
        },
        {
            "ats": "ashby",
            "token": "ashby",
        },
        {
            "ats": "workable",
            "token": "huggingface",
        },
        {
            "ats": "recruitee",
            "token": "grid",
        },
    ],
    "maxCompanies": 25,
    "maxResultsPerSource": 50,
}

# Run the Actor and wait for it to finish
run = client.actor("wickfeed/ats-job-aggregator").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print("💾 Check your data here: https://console.apify.com/storage/datasets/" + run["defaultDatasetId"])
for item in client.dataset(run["defaultDatasetId"]).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": [],
  "preset": "none",
  "sources": [
    {
      "ats": "greenhouse",
      "token": "stripe"
    },
    {
      "ats": "lever",
      "token": "spotify"
    },
    {
      "ats": "ashby",
      "token": "ashby"
    },
    {
      "ats": "workable",
      "token": "huggingface"
    },
    {
      "ats": "recruitee",
      "token": "grid"
    }
  ],
  "maxCompanies": 25,
  "maxResultsPerSource": 50
}' |
apify call wickfeed/ats-job-aggregator --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=wickfeed/ats-job-aggregator",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

```json
{
    "openapi": "3.0.1",
    "info": {
        "title": "Greenhouse, Lever, Ashby, Workable Jobs Scraper + Job Monitor",
        "description": "Greenhouse, Lever, Ashby, Workable & Recruitee jobs scraper + monitor — open roles from each company's live feed in one normalized schema. $1 per 1,000 jobs, $0 start. Optional diff mode, off by default: the first run bills the jobs delivered; later runs bill new + watched-field changes.",
        "version": "0.2",
        "x-build-id": "2LGK9Gyskbh7RImYU"
    },
    "servers": [
        {
            "url": "https://api.apify.com/v2"
        }
    ],
    "paths": {
        "/acts/wickfeed~ats-job-aggregator/run-sync-get-dataset-items": {
            "post": {
                "operationId": "run-sync-get-dataset-items-wickfeed-ats-job-aggregator",
                "x-openai-isConsequential": false,
                "summary": "Executes an Actor, waits for its completion, and returns Actor's dataset items in response.",
                "tags": [
                    "Run Actor"
                ],
                "requestBody": {
                    "required": true,
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/inputSchema"
                            }
                        }
                    }
                },
                "parameters": [
                    {
                        "name": "token",
                        "in": "query",
                        "required": true,
                        "schema": {
                            "type": "string"
                        },
                        "description": "Enter your Apify token here"
                    }
                ],
                "responses": {
                    "200": {
                        "description": "OK"
                    }
                }
            }
        },
        "/acts/wickfeed~ats-job-aggregator/runs": {
            "post": {
                "operationId": "runs-sync-wickfeed-ats-job-aggregator",
                "x-openai-isConsequential": false,
                "summary": "Executes an Actor and returns information about the initiated run in response.",
                "tags": [
                    "Run Actor"
                ],
                "requestBody": {
                    "required": true,
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/inputSchema"
                            }
                        }
                    }
                },
                "parameters": [
                    {
                        "name": "token",
                        "in": "query",
                        "required": true,
                        "schema": {
                            "type": "string"
                        },
                        "description": "Enter your Apify token here"
                    }
                ],
                "responses": {
                    "200": {
                        "description": "OK",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/runsResponseSchema"
                                }
                            }
                        }
                    }
                }
            }
        },
        "/acts/wickfeed~ats-job-aggregator/run-sync": {
            "post": {
                "operationId": "run-sync-wickfeed-ats-job-aggregator",
                "x-openai-isConsequential": false,
                "summary": "Executes an Actor, waits for completion, and returns the OUTPUT from Key-value store in response.",
                "tags": [
                    "Run Actor"
                ],
                "requestBody": {
                    "required": true,
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/inputSchema"
                            }
                        }
                    }
                },
                "parameters": [
                    {
                        "name": "token",
                        "in": "query",
                        "required": true,
                        "schema": {
                            "type": "string"
                        },
                        "description": "Enter your Apify token here"
                    }
                ],
                "responses": {
                    "200": {
                        "description": "OK"
                    }
                }
            }
        }
    },
    "components": {
        "schemas": {
            "inputSchema": {
                "type": "object",
                "properties": {
                    "companies": {
                        "title": "Company names or careers URLs",
                        "type": "array",
                        "description": "Leave this blank to run the 5 prefilled demo boards below. Maximum first-run cost: $0.25. Or type a company NAME (e.g. \"Stripe\") or paste an ATS careers/board URL (e.g. \"https://boards.greenhouse.io/stripe\") — the actor finds the hiring system and board for you, no tokens to hunt. A careers/board URL is the most reliable; a bare name is best-effort (some companies can't be matched by name — those are reported, never charged, with the reason and a prompt to paste the careers URL or { ats, token } instead). Resolution is FREE; you pay only for the jobs the matched boards return. Merges with the preset and Sources lists below.",
                        "items": {
                            "type": "string"
                        }
                    },
                    "preset": {
                        "title": "Company list preset",
                        "enum": [
                            "none",
                            "starter-500",
                            "engineering",
                            "sales",
                            "healthcare",
                            "finance",
                            "all-verified"
                        ],
                        "type": "string",
                        "description": "Start from a bundled, verified-live company list — no need to hand-type board tokens. Leave this on \"None\" to use the Sources box below (prefilled with 5 demo boards for an instant, low-cost first run — up to about $0.25, usually ~$0.17). Switch to \"Starter 500\" for full coverage: the 500 boards with the most active jobs, all confirmed live on 2026-07-15 (a first full run costs up to about $25 at the default 50-jobs cap — lower \"Max results per source\" to reduce it). A preset is MERGED with anything you add in Sources (duplicates removed by ats:token), so you can layer your own boards on top. Board counts — Starter 500: 500 · Engineering: 457 · Sales: 132 · Healthcare: 53 · Finance: 37 · All verified: 1,925. FIRST-RUN COST CEILING at the default cap (boards × 50 × $1/1,000) — Starter 500 ≈ $25 · Engineering ≈ $23 · Sales ≈ $6.60 · Healthcare ≈ $2.65 · Finance ≈ $1.85 · All verified ≈ $96; these are ceilings and real runs are usually well under (many boards post fewer than 50 jobs, dead boards are never charged). The free plan's $5 monthly credit covers the demo, Finance and Healthcare; lower \"Max results per source\" to shrink any run. In diff mode the first run bills for the whole board like a normal run; later runs charge only for new and changed jobs. Presets currently cover Greenhouse, Ashby, Workable and Recruitee (Lever boards can still be added by hand in Sources).",
                        "default": "none"
                    },
                    "sources": {
                        "title": "Sources (advanced — exact boards)",
                        "type": "array",
                        "description": "The explicit, power-user path: one entry per company job board as { \"ats\": \"greenhouse|lever|ashby|workable|recruitee\", \"token\": \"<board-token>\" }. Prefer the \"Company names or careers URLs\" box above if you don't already know the token — it resolves names/URLs into these entries for you. The token is the company's board identifier in that ATS (e.g. Greenhouse board \"stripe\", Lever site \"spotify\", Ashby org \"ashby\", Workable subdomain \"huggingface\", Recruitee subdomain \"grid\"). Optional — the companies, preset, and this list are all merged (duplicates removed). Malformed or duplicate entries are skipped, not fatal; the run fails only when there is no usable board at all.",
                        "items": {
                            "type": "object",
                            "properties": {
                                "ats": {
                                    "type": "string",
                                    "title": "ATS",
                                    "description": "Which applicant-tracking system this board lives on.",
                                    "enum": [
                                        "greenhouse",
                                        "lever",
                                        "ashby",
                                        "workable",
                                        "recruitee"
                                    ]
                                },
                                "token": {
                                    "type": "string",
                                    "title": "Board token",
                                    "description": "The company's board identifier in that ATS, read off its careers URL (e.g. \"stripe\")."
                                }
                            },
                            "required": [
                                "ats",
                                "token"
                            ]
                        }
                    },
                    "maxCompanies": {
                        "title": "Max company names to look up per run",
                        "minimum": 1,
                        "maximum": 200,
                        "type": "integer",
                        "description": "Advanced. Caps how many entries in \"Company names or careers URLs\" the actor looks up per run (keeps runs fast and predictable). Default 25, max 200. Any extra entries are reported as skipped and never charged. Looking a company up is always free — this only limits how many lookups happen at once.",
                        "default": 25
                    },
                    "diffMode": {
                        "title": "Only new & changed jobs (diff mode)",
                        "type": "boolean",
                        "description": "Get only jobs that are NEW or CHANGED since the last run, not the whole board each time. Each delivered job is tagged changeType (\"new\" or \"changed\") plus a firstSeenAt date, and you pay only for those — unchanged jobs cost nothing. Your first run has no history yet, so it charges for everything; later runs stay small. Best paired with a daily Apify schedule. Jobs that disappeared are listed under CLOSED_JOBS on the run's Storage tab. For a clean monitor of your own hand-entered boards, set a fixed \"Monitoring memory name\" below (preset monitors are already stable). Keep \"Max results per source\" at or above each board's job count for accurate closed-job detection — see the README's diff-mode rules.",
                        "default": false
                    },
                    "diffStateKey": {
                        "title": "Monitoring memory name (optional, advanced)",
                        "type": "string",
                        "description": "Advanced, optional — most people leave this blank. In diff mode the actor remembers what it saw last time so it can show you only what's new; this just gives that memory a name. Leave it blank and the actor names it for you. Set a fixed name only if you want one steady monitor that survives edits to your board list — and if you do, change your keyword/location filters by starting a NEW name rather than editing the old one (editing filters under a fixed name can re-bill jobs). Full rules are in the README under \"Diff mode\". Best to use letters, numbers, dot, dash, and underscore; other characters are made safe automatically, and each distinct name always keeps its own separate memory."
                    },
                    "keywords": {
                        "title": "Keywords",
                        "type": "array",
                        "description": "Keep only jobs whose title, department or description contains ANY of these (case-insensitive). Leave empty for no keyword filtering.",
                        "items": {
                            "type": "string"
                        }
                    },
                    "locations": {
                        "title": "Locations",
                        "type": "array",
                        "description": "Keep only jobs whose location contains ANY of these substrings (case-insensitive). Leave empty for no location filtering.",
                        "items": {
                            "type": "string"
                        }
                    },
                    "remoteOnly": {
                        "title": "Remote only",
                        "type": "boolean",
                        "description": "Keep only jobs the hiring system explicitly marks as remote. Heads-up: jobs where the source didn't say either way are also dropped while this is on, so a board that doesn't tag remote can come back empty — leave this off and filter by Location if you're not sure.",
                        "default": false
                    },
                    "postedAfter": {
                        "title": "Posted after",
                        "type": "string",
                        "description": "Keep only jobs posted on/after this date (e.g. 2026-01-01). Jobs with an unknown post date are excluded when set. An unreadable date is rejected with a clear error — it never silently turns the filter off. Leave blank to not filter by date."
                    },
                    "maxResultsPerSource": {
                        "title": "Max results per source",
                        "minimum": 1,
                        "maximum": 100000,
                        "type": "integer",
                        "description": "Cap on the number of MATCHING jobs delivered per board. Your keyword/location/remote/date filters run over the whole board first, then the matches are capped — so a matching job is never hidden past the cap. Defaults to 50 so a first run stays fast and light; raise it once you're happy with the output. In diff mode, keep this at or above each board's number of matching jobs for accurate closed-job detection — if a board has more matching jobs than the cap, the actor skips closed detection for it that run rather than reporting false closures.",
                        "default": 50
                    },
                    "includeHtml": {
                        "title": "Include raw HTML description",
                        "type": "boolean",
                        "description": "Also include the original HTML job description as descriptionHtml. Off by default to keep records lean.",
                        "default": false
                    }
                }
            },
            "runsResponseSchema": {
                "type": "object",
                "properties": {
                    "data": {
                        "type": "object",
                        "properties": {
                            "id": {
                                "type": "string"
                            },
                            "actId": {
                                "type": "string"
                            },
                            "userId": {
                                "type": "string"
                            },
                            "startedAt": {
                                "type": "string",
                                "format": "date-time",
                                "example": "2025-01-08T00:00:00.000Z"
                            },
                            "finishedAt": {
                                "type": "string",
                                "format": "date-time",
                                "example": "2025-01-08T00:00:00.000Z"
                            },
                            "status": {
                                "type": "string",
                                "example": "READY"
                            },
                            "meta": {
                                "type": "object",
                                "properties": {
                                    "origin": {
                                        "type": "string",
                                        "example": "API"
                                    },
                                    "userAgent": {
                                        "type": "string"
                                    }
                                }
                            },
                            "stats": {
                                "type": "object",
                                "properties": {
                                    "inputBodyLen": {
                                        "type": "integer",
                                        "example": 2000
                                    },
                                    "rebootCount": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "restartCount": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "resurrectCount": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "computeUnits": {
                                        "type": "integer",
                                        "example": 0
                                    }
                                }
                            },
                            "options": {
                                "type": "object",
                                "properties": {
                                    "build": {
                                        "type": "string",
                                        "example": "latest"
                                    },
                                    "timeoutSecs": {
                                        "type": "integer",
                                        "example": 300
                                    },
                                    "memoryMbytes": {
                                        "type": "integer",
                                        "example": 1024
                                    },
                                    "diskMbytes": {
                                        "type": "integer",
                                        "example": 2048
                                    }
                                }
                            },
                            "buildId": {
                                "type": "string"
                            },
                            "defaultKeyValueStoreId": {
                                "type": "string"
                            },
                            "defaultDatasetId": {
                                "type": "string"
                            },
                            "defaultRequestQueueId": {
                                "type": "string"
                            },
                            "buildNumber": {
                                "type": "string",
                                "example": "1.0.0"
                            },
                            "containerUrl": {
                                "type": "string"
                            },
                            "usage": {
                                "type": "object",
                                "properties": {
                                    "ACTOR_COMPUTE_UNITS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATASET_READS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATASET_WRITES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "KEY_VALUE_STORE_READS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "KEY_VALUE_STORE_WRITES": {
                                        "type": "integer",
                                        "example": 1
                                    },
                                    "KEY_VALUE_STORE_LISTS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "REQUEST_QUEUE_READS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "REQUEST_QUEUE_WRITES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATA_TRANSFER_INTERNAL_GBYTES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATA_TRANSFER_EXTERNAL_GBYTES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "PROXY_RESIDENTIAL_TRANSFER_GBYTES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "PROXY_SERPS": {
                                        "type": "integer",
                                        "example": 0
                                    }
                                }
                            },
                            "usageTotalUsd": {
                                "type": "number",
                                "example": 0.00005
                            },
                            "usageUsd": {
                                "type": "object",
                                "properties": {
                                    "ACTOR_COMPUTE_UNITS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATASET_READS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATASET_WRITES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "KEY_VALUE_STORE_READS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "KEY_VALUE_STORE_WRITES": {
                                        "type": "number",
                                        "example": 0.00005
                                    },
                                    "KEY_VALUE_STORE_LISTS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "REQUEST_QUEUE_READS": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "REQUEST_QUEUE_WRITES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATA_TRANSFER_INTERNAL_GBYTES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "DATA_TRANSFER_EXTERNAL_GBYTES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "PROXY_RESIDENTIAL_TRANSFER_GBYTES": {
                                        "type": "integer",
                                        "example": 0
                                    },
                                    "PROXY_SERPS": {
                                        "type": "integer",
                                        "example": 0
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}
```
