# Greenhouse Jobs Feed + Lever, Ashby & Workable: New & Closed (`madrasco/ats-job-postings-feed`) Actor

Greenhouse jobs plus Lever, Ashby and Workable postings from public job boards in one flat schema with salary, location and remote fields. Change mode returns only new, changed or closed postings since your last run. No recruiter fields; emails and phone numbers removed from descriptions.

- **URL**: https://apify.com/madrasco/ats-job-postings-feed.md
- **Developed by:** [Jack Valmadre](https://apify.com/madrasco) (community)
- **Categories:** Jobs
- **Stats:** 2 total users, 1 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.50 / 1,000 job posting rows

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

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

## What's an Apify Actor?

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

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

## How to integrate an Actor?

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

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

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

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

# README

## Greenhouse, Lever, Ashby & Workable Jobs Feed — New & Closed

Get the open jobs of any company that publishes its job board on **Greenhouse**, **Lever**, **Ashby** or **Workable**,
in one flat table — and, on a schedule, only the postings that are **new, changed or closed** since your last run.

It reads the public job-board APIs these applicant-tracking systems provide for employers' careers pages (plain JSON,
no login, no browser, one request per board), so runs are quick and cheap.

| System | What to enter | Salary fields |
|---|---|---|
| Greenhouse | `greenhouse:stripe` or `https://boards.greenhouse.io/stripe` | when the employer publishes a pay range |
| Lever | `lever:spotify` or `https://jobs.lever.co/spotify` (EU: `https://jobs.eu.lever.co/...`) | when the employer publishes a pay range |
| Ashby | `ashby:linear` or `https://jobs.ashbyhq.com/linear` | when the employer publishes a pay range |
| Workable | `workable:skroutz` or `https://apply.workable.com/skroutz` | never (Workable's public job list has no salary field) |

### What you can use it for

- Keep a job board, newsletter or niche jobs site up to date with new postings and remove closed ones.
- Watch hiring at a list of companies (new teams, new locations, roles closing).
- Salary and remote-work research across employers that publish pay ranges.

### Input

| Field | What it does |
|---|---|
| `boards` | One board per line: `greenhouse:stripe`, `lever:spotify`, `ashby:linear`, `workable:skroutz`, or a board URL (`https://boards.greenhouse.io/figma`, `https://jobs.lever.co/spotify`, `https://jobs.ashbyhq.com/linear`, `https://apply.workable.com/skroutz`). EU-hosted Lever boards (`https://jobs.eu.lever.co/...`) work too; EU-hosted Greenhouse boards (`boards.eu.greenhouse.io`), single Workable job links (`https://apply.workable.com/j/...`) and Workable careers sites on a company's own domain are not supported and are reported as invalid. Up to 500 per run. Also accepted as `startUrls`, `urls` or `companies`. |
| `mode` | `full` (default): every open posting on every run, marked `new` / `changed` / `unchanged`, plus closed ones. `changes`: only new, changed and closed postings since the previous run with the same boards; the first run returns every open posting as `new`. **Use `changes` for scheduled runs.** |
| `maxRows` | Stop after this many rows (each row is one charged `job-row` event). The input form starts at 10 so a first try costs about US$0.015; clear it (or set 0) to get every posting. Leave it out when calling the API and there is no limit. Also accepted as `maxItems`. |
| `includeDescription` | Adds the plain-text job description (off by default; about 7 times larger output in our test). Email addresses and phone numbers are removed. |
| `stateStoreName` | Name of the key-value store in your account that remembers the previous run (default `ats-job-feed-state`). |
| `resetState` | Forget the history for these boards and start over. |

The board name is the part of the careers-page URL after the domain (for `https://jobs.lever.co/spotify` it is `spotify`;
for `https://apply.workable.com/skroutz` it is `skroutz`).

### How the change feed works

The actor keeps the postings it saw in a **named key-value store in your own Apify account** (default
`ats-job-feed-state`), one record per distinct board list (the record key is a hash of the sorted boards, so the order
and URL form don't matter). Schedule the actor with the same boards and `mode` set to `changes` — for example daily — and each run returns
only what changed. Changing `mode` or `includeDescription` keeps the history. Editing the board list starts a new history for
the new list. The record is small (about 24 KB for 272 postings in our test) and named stores are kept until you delete
them.

- A posting is **changed** when its title, team, location, workplace type, employment type, salary, links or
  description change. Greenhouse's `updated_at` alone does not count (Greenhouse updates it without visible edits).
- A **closed** posting appears once, on the first run after it disappeared, then never again.
- A board that fails to load (network error, 404) keeps its history and never produces `closed` rows; the run summary
  lists it.
- A run stopped early by your maximum cost per run or by `maxRows` saves history only for the postings it wrote:
  the rest come back on the next run, and boards it didn't reach never produce `closed` rows.
- Rows are written before the history is saved, so an interrupted run can repeat rows on the next run but never lose
  a change.

### Output

One dataset row per posting, same fields for all four systems (missing values are `null`):

`source`, `board`, `company`, `job_id`, `posting_key` (`source:board:job_id`, stable across runs), `title`,
`department`, `team`, `location`, `all_locations`, `country`, `workplace_type` (remote / hybrid / onsite), `remote`,
`employment_type`, `salary_min`, `salary_max`, `salary_currency`, `salary_interval`, `salary_text`, `url`,
`apply_url`, `published_at`, `updated_at`, `description`, `change_type` (new / changed / unchanged / closed),
`first_seen_at`, `closed_at`, `scraped_at`.

Example row (real output for Figma's Greenhouse board, 2026-09-25, description shortened):

```json
{
  "source": "greenhouse", "board": "figma", "company": "Figma", "job_id": "5426468004",
  "posting_key": "greenhouse:figma:5426468004", "title": "Account Executive, Enterprise",
  "department": "Sales", "location": "San Francisco, CA • New York, NY • United States",
  "salary_min": 165000.0, "salary_max": 190000.0, "salary_currency": "USD",
  "url": "https://boards.greenhouse.io/figma/jobs/5426468004?gh_jid=5426468004",
  "published_at": "2025-01-28T18:57:29-05:00", "change_type": "new",
  "first_seen_at": "2026-09-25T09:41:36Z", "closed_at": null
}
```

The run summary (key-value store record `OUTPUT`) lists each board's status (`ok`, `not_found`, `error`, `invalid`,
`duplicate`), counts of new / changed / unchanged / closed postings, and a warning when a board is empty (companies
that move to another applicant-tracking system often leave an empty board behind).

### What the data covers (measured)

Field coverage depends on what each employer fills in. On 21 real boards on 2026-09-25 (4,651 postings), a salary was
present on 33% of Greenhouse, 35% of Lever and 66% of Ashby postings, and a remote flag on 17%, 100% and 78%.
On Greenhouse, `salary_text` is the employer's label for the pay range (e.g. "Annual Base Salary Range:") and
`salary_interval` is usually empty.

Workable (3 real boards on 2026-09-27, 54 postings): the salary fields are always empty; `country`, `remote` and
`all_locations` were filled on every posting, `department` on 51 and `employment_type` on 41; `workplace_type` is set
only for remote jobs. Workable lists a job once per location; the actor returns it as one row with every location in
`all_locations`, so the row count matches the job total on the company's Workable careers page (33, 19 and 2 jobs on
these boards).

### Limitations

- Only Greenhouse, Lever, Ashby and Workable job boards. Companies using other systems (Workday, iCIMS, SmartRecruiters,
  Personio, Recruitee and others) are not covered; a company that moved away shows as `not_found` or empty.
- You supply the boards; the actor doesn't discover companies or search all jobs.
- Only postings the employer publishes on the public board (Ashby unlisted postings are skipped).
- A board whose address redirects to another site is reported as an error and keeps its history (no false `closed`
  rows).
- Personal data: recruiter and hiring-manager fields, Greenhouse custom fields and internal ids are never copied, and
  email addresses and phone numbers are removed from descriptions. Names of people that appear inside the free text
  of a description are **not** removed; switch descriptions off if you don't need them.
- Postings are the employers' own job ads. You are responsible for how you use and republish them.

### Politeness

One request per board per run (up to 2 retries on errors), at least 1 second between requests to the same host (Lever's robots.txt asks
for this), and a clear user agent. Only the public read-only endpoints are used; no applications are submitted.

### Use with AI agents and other actors

Input is plain JSON, e.g. `{"boards": ["greenhouse:stripe", "ashby:linear", "workable:skroutz"], "mode": "changes"}`; `startUrls` in the
usual `[{"url": "..."}]` form also works. Each row has a stable `posting_key` for de-duplication.

### Pricing

Pay per event: US$0.0015 per dataset row (`job-row`), plus a start fee of US$0.00005 per GB of run memory, charged once per run (at most US$0.00005 at the default 256 MB).
Every row counts: in `full` mode (the default) that is every open posting plus closed ones, in `changes` mode only
new, changed and closed postings. There is no extra charge for Apify platform usage. Apify shows the price before
you run, and you can set a maximum cost per run. When that budget (or `maxRows`) runs out, the run writes as many
rows as it covers — part of a board if need be — then stops, succeeds, and says so in its status message; the
`OUTPUT` record marks the cut board `partial` and the boards after it `not_read`. Nothing is lost in the change
feed: postings whose row wasn't written keep their saved history, so the next run with these boards still reports
them as new, changed or closed, and boards that weren't read never produce `closed` rows.

### Support

Please use the **Issues** tab of this actor. We aim to respond within 14 days. This actor is built and supported with
AI assistance; a human owner can be reached on request through the Issues tab.

Publisher: Madrasco. Not affiliated with Greenhouse, Lever, Ashby or Workable.

# Actor input Schema

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

One board per line: 'greenhouse:<board>', 'lever:<company>', 'ashby:<board>', 'workable:<account>', or a board URL such as https://boards.greenhouse.io/stripe, https://jobs.lever.co/spotify (EU-hosted Lever boards on jobs.eu.lever.co work too), https://jobs.ashbyhq.com/ramp or https://apply.workable.com/skroutz. Up to 500 boards per run. Also accepted as 'startUrls', 'urls' or 'companies'. Not supported: EU-hosted Greenhouse boards (boards.eu.greenhouse.io), single Workable job links (apply.workable.com/j/...) and Workable careers sites on a company's own domain.

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

'full' (default): every open posting on every run, each marked new/changed/unchanged, plus closed ones. 'changes': only postings that are new, changed or closed since the previous run with the same boards (the first run returns every open posting as new) — use this for scheduled runs.

## `maxRows` (type: `integer`):

Stop after this many dataset rows (each row is one charged job-row event). The form starts at 10 so a first try costs about US$0.015; clear it or set 0 for no limit. Also accepted as 'maxItems'. A run also stops cleanly when your maximum cost per run is reached. Either way the postings not yet written are kept in the history and reported by a later run.

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

Add the plain-text job description (email addresses and phone numbers removed). Off by default: descriptions made the output about 7 times larger in our test. Switching this on or off does not mark postings as changed.

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

Named key-value store in your account that remembers the previous run's postings (one record per distinct board list). Change it only to keep separate histories for the same boards.

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

Ignore the stored history for these boards: every open posting is returned as new and the history is rebuilt.

## Actor input object example

```json
{
  "boards": [
    "greenhouse:stripe",
    "https://jobs.lever.co/spotify",
    "https://jobs.ashbyhq.com/ramp"
  ],
  "mode": "full",
  "maxRows": 10,
  "includeDescription": false,
  "stateStoreName": "ats-job-feed-state",
  "resetState": false
}
```

# Actor output Schema

## `postings` (type: `string`):

One dataset row per posting: new, changed, closed (and unchanged in full mode).

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

Per-board status (ok, not found, error, empty), counts of new/changed/closed postings, and the state key used.

# 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": [
        "greenhouse:stripe",
        "https://jobs.lever.co/spotify",
        "https://jobs.ashbyhq.com/ramp"
    ],
    "maxRows": 10
};

// Run the Actor and wait for it to finish
const run = await client.actor("madrasco/ats-job-postings-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": [
        "greenhouse:stripe",
        "https://jobs.lever.co/spotify",
        "https://jobs.ashbyhq.com/ramp",
    ],
    "maxRows": 10,
}

# Run the Actor and wait for it to finish
run = client.actor("madrasco/ats-job-postings-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": [
    "greenhouse:stripe",
    "https://jobs.lever.co/spotify",
    "https://jobs.ashbyhq.com/ramp"
  ],
  "maxRows": 10
}' |
apify call madrasco/ats-job-postings-feed --silent --output-dataset

```

## MCP server setup

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