# Multi-ATS Jobs Feed + Change Alerts (`tallowlanelabs/multi-ats-jobs-feed`) Actor

Job postings from the official public job-board APIs of Greenhouse, Lever, Ashby, SmartRecruiters, Workable and Recruitee in one normalized schema. Scheduled runs return only new, updated and removed jobs.

- **URL**: https://apify.com/tallowlanelabs/multi-ats-jobs-feed.md
- **Developed by:** [Tallow Lane Labs](https://apify.com/tallowlanelabs) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.50 / 1,000 job items

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Multi-ATS Jobs Feed + Change Alerts

**One job feed for every company you follow, from Greenhouse, Lever, Ashby, SmartRecruiters, Workable and Recruitee, in one clean schema. Schedule it, and each run returns only what changed: new jobs, updated jobs and closed jobs.**

Built and maintained by Tallow Lane Labs.

### What it does

- **Reads the official public job-board APIs** that each ATS publishes for career sites. There is no browser, no HTML scraping of job pages and no proxies, so runs are fast, cheap and stable.
- **Normalizes every posting** into one schema: title, department, team, employment type, remote/hybrid/onsite, locations, salary range (when the company publishes it), posted date, job URL, apply URL, and the description as plain text, Markdown and/or HTML.
- **Change alerts.** In `changes` mode the Actor remembers every board between runs, in a named key-value store, and outputs only:
  - `new`: postings that weren't there last run
  - `updated`: postings whose title, location, salary, team, description, URLs and so on changed. The record lists the changed fields and the previous title, department, location and URL.
  - `removed`: postings that disappeared, meaning the job was closed or filled.
- **Snapshot mode** returns the full current list every run.
- **Accepts whatever you paste:** a job-board URL (`https://jobs.lever.co/palantir`), an `ats:slug` shortcut (`greenhouse:stripe`), a company careers page that embeds one of these boards (the Actor detects the ATS), or a bare slug (the Actor tries each ATS).
- **Filters:** keywords, exclude keywords, locations, departments, workplace type, and posted within N days.

Typical uses include recruiting and sourcing alerts, sales signals ("company X just opened 12 data roles"), competitive hiring intel, job-board aggregation, and labor-market research.

### Input

| Field | Default | What it does |
|---|---|---|
| `boards` (required) | – | List of boards. Examples: `https://boards.greenhouse.io/stripe`, `lever:palantir`, `lever:eu:somecompany` (Lever EU), `https://jobs.ashbyhq.com/ramp`, `smartrecruiters:Ubisoft2` (case-sensitive), `https://apply.workable.com/huggingface/`, `bunq.recruitee.com`, `https://www.example.com/careers` |
| `mode` | `changes` | `changes` = only new/updated/removed since the last run. `snapshot` = every live posting. |
| `changeTypes` | all three | Which change types to output. |
| `emitBaseline` | `true` | On the first run of a board in `changes` mode, output all current jobs (marked `baseline`). Turn it off to start silently and alert only on later changes. |
| `keywords` / `excludeKeywords` | – | Case-insensitive substring match on the job title. |
| `locations`, `departments` | – | Case-insensitive substring match on any location (city, region, country) or on department and team, e.g. `Germany`, `Remote`, `Engineering`. |
| `workplaceTypes` | – | `remote`, `hybrid`, `onsite`, `unknown`. |
| `postedWithinDays` | – | Only jobs posted in the last N days. |
| `maxJobsPerBoard` | – | Caps output per board per run (a cost control). |
| `includeDescriptions` | `true` | Include job descriptions. Turning it off makes runs smaller and faster. |
| `descriptionFormats` | `text`, `markdown` | Any of `text`, `markdown`, `html`. |
| `redactContactInfo` | `false` | Masks e-mail addresses and phone numbers inside descriptions. |
| `includeUnlisted` | `false` | Ashby only: also return postings the company marked unlisted. |
| `stateStoreName` / `stateKeyPrefix` | `multi-ats-jobs-feed-state` / – | Where change-tracking state lives. Use a different name or prefix for each independent watch list. |
| `resetState` | `false` | Forget the stored state and start a new baseline. |
| `detectFromCareerPages`, `probeBareSlugs` | `true` | Turn off automatic ATS detection. |
| `boardConcurrency`, `maxConcurrency` | 3 / 4 | Politeness limits. Per-ATS request spacing is built in and can't be lowered. `boardConcurrency` also sets memory use; see [Memory](#memory). |
| `removalSafetyMinJobs` | 5 | If a board that had at least this many jobs suddenly returns 0, the Actor treats it as an outage and does **not** report mass removals. |

#### Example input

```json
{
  "boards": [
    "https://boards.greenhouse.io/stripe",
    "https://jobs.lever.co/palantir",
    "ashby:ramp",
    "smartrecruiters:Ubisoft2",
    "https://apply.workable.com/huggingface/",
    "bunq.recruitee.com"
  ],
  "mode": "changes",
  "keywords": ["engineer", "data"],
  "workplaceTypes": ["remote", "hybrid"]
}
```

Then put it on a schedule (daily is the recommended default; see [Choosing a schedule](#choosing-a-schedule)) and connect the dataset to a webhook, Slack, Google Sheets or Zapier.

### Output

One dataset record per job (snapshot or baseline) or per change. Errors for individual boards are written as `recordType: "error"` records and aren't charged. The other boards keep running.

```json
{
  "recordType": "change",
  "changeType": "new",
  "changedFields": [],
  "id": "ashby:ramp:34413f8d-26bf-4bbc-8ade-eb309a0e2245",
  "ats": "ashby",
  "board": "ramp",
  "companyName": "ramp",
  "jobId": "34413f8d-26bf-4bbc-8ade-eb309a0e2245",
  "requisitionId": null,
  "title": "Security Engineer, Cloud",
  "department": "Engineering",
  "team": "Backend",
  "employmentType": "full_time",
  "employmentTypeRaw": "FullTime",
  "workplaceType": "hybrid",
  "isRemote": false,
  "location": "New York, NY (HQ)",
  "locations": ["New York, NY (HQ)", "Remote (Canada)", "Remote (US)", "Miami, FL"],
  "city": "New York City",
  "region": "NY",
  "country": "USA",
  "salary": { "min": 211400, "max": 290600, "currency": "USD", "interval": "year", "text": "$211.4K - $290.6K" },
  "postedAt": "2026-04-07T17:12:35.753Z",
  "firstSeenAt": "2026-10-27T13:33:02.101Z",
  "detectedAt": "2026-10-27T13:33:02.101Z",
  "url": "https://jobs.ashbyhq.com/ramp/34413f8d-26bf-4bbc-8ade-eb309a0e2245",
  "applyUrl": "https://jobs.ashbyhq.com/ramp/34413f8d-26bf-4bbc-8ade-eb309a0e2245/application",
  "descriptionText": "ABOUT RAMP\n\nRamp is building the smart infrastructure for finance teams ...",
  "descriptionMarkdown": "# **About Ramp**\n\nRamp is building the smart infrastructure for finance teams ..."
}
```

An `updated` record has the same shape, plus `changedFields` (for example `["title", "salary"]`) and `previous`, which holds the last known title, department, location and URL. Other fields are tracked by hash, so the record tells you which ones changed but not their old values.

A `removed` record carries the last known title, company, department, location and URLs, plus `firstSeenAt` and `lastSeenAt`. A `new` record can carry `lateDiscovery: true` when the posting's `postedAt` is older than the previous run, meaning the ATS only started listing it now.

Each run also saves a `RUN_SUMMARY` record in the run's default key-value store. It holds per-board job counts, change counts, pages fetched, warnings and HTTP stats.

The dataset has three views: **Overview**, **Changes** and **Errors**.

### Pricing (pay per event)

| Event | Price | When it's charged |
|---|---|---|
| Job item | **$0.0015** | Each job returned in `snapshot` mode, and each `baseline` job on the first `changes` run of a board |
| Job change | **$0.002** | Each `new`, `updated` or `removed` record on later `changes` runs |
| Board check | $0.001 | Each board checked in a run, changed or not. A board with more than 1,000 live jobs counts once per started 1,000 jobs (a 2,400-job board = 3 checks). |
| Actor start | $0.005 | Once per run (Apify's standard start event; the same price at 256, 512 or 1024 MB) |

You don't pay for error records, boards that fail (not found, rate-limited), or jobs your filters exclude, and memory doesn't change the price. The start fee and board checks cover the cost of fetching and comparing your boards on runs that find nothing new, so frequent schedules stay cheap for small watch lists and fair for big ones. `RUN_SUMMARY` lists the board checks charged for each board.

**Examples**

- Watching 6 companies (about 1,500 jobs): a run with no changes costs **$0.011** ($0.005 + 6 × $0.001), about $0.33 a month on a daily schedule.
- Watching 50 companies with about 7,500 open jobs: the first run (baseline) costs about **$11.31** ($11.25 for 7,500 jobs + $0.05 for 50 board checks + $0.005). After that, a daily run that finds 150 changes costs about **$0.36** ($0.30 + $0.05 + $0.005), roughly $11 a month. A day with no changes costs $0.055.
- Turning off `emitBaseline` means the first run costs only the start fee and board checks, and you pay only for changes after that.
- A full snapshot of 1,000 jobs from 5 boards costs about $1.51.
- Set **Maximum cost per run** in Apify to put a hard cap on any run. The Actor stops cleanly at the cap and saves its state, so jobs it didn't deliver arrive on the next run instead of being lost.

#### Choosing a schedule

Every run pays the base fees (the $0.005 start plus the board checks), even when nothing changed. At 50 boards that is $0.055 a run ($0.05 for 50 board checks + $0.005 start). Over 30 days, including the start fee:

| Schedule | Runs a month | Base cost a month at 50 boards, no changes |
|---|---|---|
| **Daily (recommended default)** | 30 | **about $1.65** |
| Every 6 hours (fresher change alerts) | 120 | **about $6.60** |
| Hourly | 720 | about $39.60 |

Daily suits most watch lists. Use every 6 hours if you need change alerts sooner. Hourly also works, but at 50 boards it costs about $39.60 a month in base fees alone. Changes are billed on top of these base fees at $0.002 per new, updated or removed job.

### Memory

The default **512 MB** is enough for almost every watch list. What matters is not how many jobs you watch in total but how many are being processed at the same time: the Actor works on `boardConcurrency` boards at once (3 by default), so memory depends on your **3 largest boards**. Measured peaks:

| Memory | Use it when | Measured peak |
|---|---|---|
| 256 MB | Your 3 largest boards together have **at most 1,500** live jobs | ~190 MB at 1,500 jobs in flight |
| **512 MB (default)** | Your 3 largest boards together have **at most 7,500** live jobs and no single board has more than **5,000** | ~240 MB for a 29-board, 7,700-job no-change run; ~305 MB for its baseline; ~370 MB for 30,000 jobs across 20 boards; ~400 MB at 7,500 jobs in flight |
| **1024 MB** | **Any board has more than 5,000 live jobs, or your 3 largest boards together have more than 7,500** | ~630 MB for three 5,000-job boards; ~700 MB for one 15,000-job board |

Above about 15,000 jobs in flight, set `boardConcurrency` to 1 (memory then depends on your single largest board) and put very large boards in their own task. Lowering `boardConcurrency` also lets a 512 MB run handle a board of up to ~7,500 jobs. Each run's `RUN_SUMMARY.memory` shows the jobs in flight and the recommended memory, and the run log warns if you should move up.

### Limits and good to know

- **Public postings only.** Internal, unlisted (except Ashby with `includeUnlisted`) and draft postings aren't available through these APIs.
- **Field coverage varies by ATS.** Greenhouse doesn't publish employment type, and its remote/hybrid value is inferred from the location text. Salary appears only when the company publishes it (pay transparency). For Lever and Ashby, `companyName` is the board slug because those APIs don't return a display name.
- **SmartRecruiters** pagination isn't fully stable: the same posting can show up twice or be missing from one pass. The Actor makes up to three passes and checks every apparent removal, and every "new" posting, against the posting's own page. A job is only reported removed when SmartRecruiters confirms it's gone or inactive. Descriptions need one extra request per posting, but only for postings that are output.
- **Workable** rate-limits its public widget API heavily, so the Actor spaces Workable requests about 5.5 s apart. Large watch lists of Workable boards take longer.
- **Identifiers:** SmartRecruiters company IDs are case-sensitive. An unknown SmartRecruiters ID returns an empty list rather than an error, so the Actor reports it as "no postings found / check the identifier".
- **Change tracking is per board and per state store.** Running the same board from two different tasks? Give each task its own `stateStoreName` or `stateKeyPrefix`.
- **Normalizer updates** (for example, better remote/hybrid detection) are applied silently and don't trigger "updated" alerts.

### Data sources, terms and privacy

All data comes from the job boards' own public, unauthenticated endpoints, which exist for building career sites:

| ATS | Endpoint | Docs |
|---|---|---|
| Greenhouse | `boards-api.greenhouse.io/v1/boards/{token}/jobs` | https://developers.greenhouse.io/job-board.html |
| Lever | `api.lever.co/v0/postings/{site}` (EU: `api.eu.lever.co`) | https://github.com/lever/postings-api |
| Ashby | `api.ashbyhq.com/posting-api/job-board/{name}` | https://developers.ashbyhq.com/docs/public-job-posting-api |
| SmartRecruiters | `api.smartrecruiters.com/v1/companies/{id}/postings` | https://developers.smartrecruiters.com/docs/posting-api |
| Workable | `apply.workable.com/api/v1/widget/accounts/{subdomain}` | https://help.workable.com/hc/en-us/articles/115012771647 |
| Recruitee | `{company}.recruitee.com/api/offers/` | https://docs.recruitee.com/reference/intro-to-careers-site-api |

The Actor identifies itself with a descriptive User-Agent. It spaces requests to each ATS, backs off on HTTP 429 and 5xx (honoring `Retry-After`), and never submits applications.

**Personal data:** the Actor collects only what's in the public job posting. Recruiter and creator names, avatars, internal mailbox addresses, application questions and internal IDs that some APIs return are **dropped**. Specifically, it drops SmartRecruiters `creator`, Recruitee `mailbox_email`, and Greenhouse internal metadata and compliance fields. A posting's description can contain a contact address the company chose to publish, such as an accommodations or recruiting mailbox. That text is kept as published, the run summary counts how many postings contain one, and `redactContactInfo` masks them. You're responsible for using the data in line with the job boards' terms and the laws that apply to you, such as GDPR and CCPA.

### Changelog

- 0.1: First version. Six ATSs, changes and snapshot modes, PPE pricing (job item, job change, board check, start).
  SmartRecruiters team, department and employment type are compared by their stable IDs, because the API sometimes returns a page with localized labels (for example Chinese) and that must not show up as an "updated" job.

### Support

Questions or issues: open an issue on this Actor's Issues tab, or email Tallow Lane Labs at wdeornellas+tallowlanelabs@gmail.com.

# Actor input Schema

## `boards` (type: `array`):

One entry per company. Accepts a job-board URL (boards.greenhouse.io/stripe, jobs.lever.co/palantir, jobs.ashbyhq.com/ramp, jobs.smartrecruiters.com/Ubisoft2, apply.workable.com/huggingface, bunq.recruitee.com), an "ats:slug" pair (greenhouse:stripe, lever:palantir, lever:eu:company, ashby:ramp, smartrecruiters:Ubisoft2, workable:huggingface, recruitee:bunq), a company careers-page URL (the Actor looks for an embedded ATS), or a bare slug (the Actor probes each ATS).

## `mode` (type: `string`):

"changes" (default) returns only jobs that are new, updated or removed since the last run for each board; the first run for a board returns the full baseline. "snapshot" returns every current job on every run (each record still says whether it is new, updated or unchanged).

## `changeTypes` (type: `array`):

Which changes to output in "changes" mode.

## `emitBaseline` (type: `boolean`):

In "changes" mode, the first run for a board has nothing to compare against. If on, all current jobs are output (charged as job items) with changeType "baseline". If off, the first run only stores the baseline silently (nothing output, nothing charged) and alerts start on the next run.

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

Only output jobs whose title contains at least one of these (case-insensitive). Leave empty for all.

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

Skip jobs whose title contains any of these.

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

Only output jobs whose location, city, region or country contains one of these strings, e.g. "New York", "Germany", "remote".

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

Only output jobs whose department or team contains one of these strings, e.g. "Engineering".

## `workplaceTypes` (type: `array`):

Only output these workplace types. Empty = all. "unknown" covers postings that do not say.

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

Only output jobs first published within this many days (where the ATS gives a date).

## `maxJobsPerBoard` (type: `integer`):

Cap the number of records output per board per run (after filters). Change detection still sees the whole board.

## `includeDescriptions` (type: `boolean`):

Include the full description. Turning this off makes runs faster and smaller. For SmartRecruiters, descriptions need one extra request per output posting.

## `descriptionFormats` (type: `array`):

Formats to include when descriptions are on.

## `redactContactInfo` (type: `boolean`):

Some employers put a recruiter email or phone in the public job text. Turn on to replace them with \[email removed] / \[phone removed].

## `includeUnlisted` (type: `boolean`):

Ashby marks some postings isListed=false (meant to be reachable only by direct link). They are skipped by default.

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

Named key-value store that keeps each board's last-seen jobs between runs. Use a different name (or a key prefix) to run independent monitors.

## `stateKeyPrefix` (type: `string`):

Optional prefix so several saved tasks can monitor the same board independently.

## `resetState` (type: `boolean`):

Ignore stored state for the boards in this run and treat it as a first run.

## `detectFromCareerPages` (type: `boolean`):

For URLs that are not an ATS domain, load the page once and look for an embedded Greenhouse/Lever/Ashby/SmartRecruiters/Workable/Recruitee board.

## `probeBareSlugs` (type: `boolean`):

For entries like "stripe" with no ATS, try each ATS API until one answers (up to 6 requests).

## `boardConcurrency` (type: `integer`):

How many boards to process at once. Per-ATS rate limits still apply. Memory depends on the jobs on these boards: 512 MB (default) fits up to 7,500 live jobs across the boards processed at once (no single board over 5,000); use 1024 MB above that, or set this to 1 for very large boards.

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

Global cap on in-flight requests across all ATS APIs.

## `removalSafetyMinJobs` (type: `integer`):

If a board that had at least this many jobs suddenly returns zero, removals are not reported that run (it is usually an API hiccup).

## Actor input object example

```json
{
  "boards": [
    "ashby:ramp"
  ],
  "mode": "snapshot",
  "changeTypes": [
    "new",
    "updated",
    "removed"
  ],
  "emitBaseline": true,
  "keywords": [],
  "excludeKeywords": [],
  "locations": [],
  "departments": [],
  "workplaceTypes": [],
  "maxJobsPerBoard": 5,
  "includeDescriptions": true,
  "descriptionFormats": [
    "text",
    "markdown"
  ],
  "redactContactInfo": false,
  "includeUnlisted": false,
  "stateStoreName": "multi-ats-jobs-feed-state",
  "stateKeyPrefix": "",
  "resetState": false,
  "detectFromCareerPages": true,
  "probeBareSlugs": true,
  "boardConcurrency": 3,
  "maxConcurrency": 4,
  "removalSafetyMinJobs": 5
}
```

# Actor output Schema

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

All job and change records from this run in the default dataset.

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

JSON summary of the run: boards checked, job counts, change counts and errors.

# 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 = {
    "boards": [
        "ashby:ramp"
    ],
    "mode": "snapshot",
    "maxJobsPerBoard": 5
};

// Run the Actor and wait for it to finish
const run = await client.actor("tallowlanelabs/multi-ats-jobs-feed").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 = {
    "boards": ["ashby:ramp"],
    "mode": "snapshot",
    "maxJobsPerBoard": 5,
}

# Run the Actor and wait for it to finish
run = client.actor("tallowlanelabs/multi-ats-jobs-feed").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 '{
  "boards": [
    "ashby:ramp"
  ],
  "mode": "snapshot",
  "maxJobsPerBoard": 5
}' |
apify call tallowlanelabs/multi-ats-jobs-feed --silent --output-dataset

```

## MCP server setup

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

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/pqDj51Qy41UQxGRGI/builds/NbefSI6iTyUoWVBYA/openapi.json
