# Greenhouse + Lever Job Boards Feed (Unofficial) (`graylabs/job-boards-feed`) Actor

Jobs from public Greenhouse and Lever job boards in one schema, with change tracking: each run flags new, changed and closed jobs. A failing board never fails the run. Company and job fields only; optional descriptions have emails, phones and names removed. Not affiliated with Greenhouse or Lever.

- **URL**: https://apify.com/graylabs/job-boards-feed.md
- **Developed by:** [Gray Labs](https://apify.com/graylabs) (community)
- **Categories:** Jobs, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 job saveds

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 Job Boards Feed (Unofficial)

### What does Greenhouse + Lever Job Boards Feed (Unofficial) do?

This Actor turns **public company job boards hosted on Greenhouse and Lever into one clean, flat job feed**, and tells you **what changed since your last run**: every row is marked `new`, `changed` or `unchanged`, and jobs that were taken down are reported once as `closed`.

Give it a list of companies (a board token or just the job board URL) and schedule it. Each run reads every board, normalises the jobs into one schema, checks each row against that schema, and saves it with a link to the original job page.

- **Change tracking built in.** No diffing on your side: use `onlyChanges` to get just the new, changed and closed jobs each day.
- **A bad board never fails the run.** A company that moved off Greenhouse, a typo, a 500 error or a slow API only affects that one board. It is listed with a plain-language reason in the run summary, and every other board is still saved.
- **Retries and polite pacing.** Temporary errors are retried with backoff, rate-limit responses are respected, and requests are spaced out per source.
- **No account or API key needed.** Both sources are public, read-only job APIs.

> This Actor is unofficial. It is not affiliated with, endorsed by, or sponsored by Greenhouse Software, Inc., Lever, or Employ Inc. It reads the job postings that companies publish on their public job boards.

### What data can you extract?

Company and job fields only. There are no recruiter, hiring-manager or other person fields.

| Field                                                        | What it is                                                                                                                                                     |
| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `jobKey`                                                     | Stable unique key: `greenhouse:<board>:<id>` or `lever:<company>:<id>`                                                                                         |
| `source`, `sourceName`, `board`                              | `greenhouse` or `lever`, the source's name, and the board token or company name                                                                                |
| `companyName`                                                | Company name as shown on the Greenhouse board (Lever's API does not provide one)                                                                               |
| `jobId`, `requisitionId`                                     | The posting's id and, where published, the company's requisition id                                                                                            |
| `title`, `departments`, `team`                               | Job title, department(s) and team                                                                                                                              |
| `location`, `allLocations`, `country`                        | Location text, all listed locations, country code (Lever)                                                                                                      |
| `workplaceType`, `isRemote`                                  | `remote`, `hybrid` or `on-site` (Lever); `isRemote` also from Greenhouse locations                                                                             |
| `employmentType`                                             | e.g. Full-time (Lever)                                                                                                                                         |
| `salaryMin`, `salaryMax`, `salaryCurrency`, `salaryInterval` | Pay range when the company publishes one in the structured field (Lever)                                                                                       |
| `postedAt`, `updatedAt`, `applicationDeadline`               | Dates as published by the source                                                                                                                               |
| `jobUrl`, `applyUrl`                                         | Link to the original job page and the application page                                                                                                         |
| `description`, `descriptionRedactions`                       | Full description, only when **Include full job descriptions** is on (otherwise `null`), with personal details removed (see Privacy), and how many were removed |
| `changeStatus`, `firstSeenAt`, `lastSeenAt`, `closedAt`      | Change tracking (see below)                                                                                                                                    |
| `retrievedAt`                                                | When this run read the job                                                                                                                                     |

### Change tracking

With `trackChanges` on (default), the Actor remembers each board's jobs in a named key-value store in your account (`job-boards-feed-state`). History is kept **per feed**, so separate feeds never interfere:

- Run from a saved **task**: the task is the feed. Two tasks that watch the same board each get every job as `new` the first time, and each keeps its own history.
- Run directly (API or Console, no task): the feed is the combination of boards and filters (keywords, locations, departments, workplace types; order and letter case don't matter). Changing the boards or filters starts a new feed, whose first run reports every job as `new`. Changing `onlyChanges`, `includeClosed`, `includeDescriptions`, `maxJobs` or `maxRequests` keeps the history. Two direct runs with exactly the same boards and filters share one history; use separate tasks if you need them apart.

`RUN_SUMMARY.stateFeedId` shows which feed a run used. On the next run of the same feed:

- `new`: the job was not on the board last time.
- `changed`: title, location, department, pay, description or another job field changed.
- `unchanged`: same as last time (skip these with `onlyChanges`; skipped rows are not charged).
- `closed`: the job was on the board last time and is gone now. It is reported once with its last known details and `closedAt`, then forgotten.

Safety rules:

- A board that fails in a run never marks its jobs as closed.
- A board whose rows were not all saved (for example because `maxJobs` was reached) keeps its previous state, so nothing is missed next time.
- A posting that is still listed but can't be read this run (for example a missing title) is not reported as closed; it keeps its history and is reported normally once it's readable again. If a posting has no readable id at all, no job on that board is marked closed in that run.
- A posting that fails the output check is kept in the history, so it is not re-reported as `new` later. If it was never delivered, it is reported as `new` once, when it first passes; it is never reported as `closed`.
- `firstSeenAt` is the first run of the feed that saw the job.

### Reliability

- Each board is fetched and processed in isolation. Errors are recorded per board in `RUN_SUMMARY` with a reason such as "says this board does not exist (HTTP 404)".
- The run only fails if **every** board fails. If some fail, the run succeeds with status `partial`.
- Temporary errors (timeouts, 5xx) are retried with exponential backoff. HTTP 429 waits for the time the API asks for.
- Requests are paced per source (Lever: at least 1 second apart, as its robots.txt asks).
- `maxRequests` caps HTTP calls per run, retries included; `maxJobs` caps saved rows.
- Every row is validated against the output schema; postings without a title or link are skipped and counted.

### Use cases

- Daily feed of new openings at a list of companies, for a job board, newsletter or community.
- Labour-market and hiring-trend research: how many roles a set of companies opens and closes per week, by department or location.
- Monitoring remote or hybrid roles in a field across many company boards.
- Keeping your own careers aggregator in sync, including removing closed roles.

### How to use it

1. Add companies under **Greenhouse boards** (e.g. `acme` or `job-boards.greenhouse.io/acme`) and/or **Lever companies** (e.g. `acme` or `jobs.lever.co/acme`).
2. Optionally filter by title keywords, locations, departments or workplace type.
3. Run it. Schedule it daily and turn on **Only output changes** for a change feed.
4. Read the jobs from the dataset and the per-board results from `RUN_SUMMARY`.

Finding the board name: open the company's careers page. If job links go to `boards.greenhouse.io/<name>`, `job-boards.greenhouse.io/<name>` or `jobs.lever.co/<name>`, that `<name>` is what you enter. You can also paste the URL.

### Input examples

These are also in the `examples/` folder.

Daily change feed for a few companies (schedule it daily; only new, changed and closed jobs are saved) (`examples/daily-change-feed.json`):

```json
{
    "greenhouseBoards": ["gitlab", "figma", "job-boards.greenhouse.io/airbnb"],
    "leverCompanies": ["spotify", "jobs.lever.co/palantir"],
    "onlyChanges": true,
    "maxJobs": 2000
}
```

Remote engineering roles at a few companies (`examples/remote-engineering.json`):

```json
{
    "greenhouseBoards": ["gitlab", "figma"],
    "leverCompanies": ["spotify"],
    "keywords": ["engineer", "developer", "sre"],
    "workplaceTypes": ["remote"],
    "maxJobs": 500
}
```

One company's remote engineering roles, with full descriptions, without change tracking (`examples/one-company-with-descriptions.json`):

```json
{
    "greenhouseBoards": ["gitlab"],
    "departments": ["Engineering"],
    "locations": ["Remote"],
    "includeDescriptions": true,
    "trackChanges": false,
    "maxJobs": 100
}
```

### Output example

Real sample: 3 rows from a test run on October 17, 2026, with the default input (links shown without \`\`; the dataset has full links). With **Include full job descriptions** on, `description` holds the cleaned text, for example `"This posting was created by [name removed] for the demo. Call [phone removed]. ..."`, and `descriptionRedactions` counts the removals (here 3).

```json
[
    {
        "jobKey": "greenhouse:greenhouse:8126988",
        "source": "greenhouse",
        "sourceName": "Greenhouse public job board",
        "board": "greenhouse",
        "companyName": "Greenhouse",
        "jobId": "8126988",
        "title": "Commercial Counsel",
        "departments": ["Legal Team"],
        "team": null,
        "location": "Eastern Timezone",
        "allLocations": ["Eastern Timezone", "United States of America"],
        "country": null,
        "workplaceType": null,
        "isRemote": null,
        "employmentType": null,
        "salaryMin": null,
        "salaryMax": null,
        "salaryCurrency": null,
        "salaryInterval": null,
        "requisitionId": "2026 - P3178",
        "language": "en",
        "postedAt": "2026-08-24T13:52:04-04:00",
        "updatedAt": "2026-10-09T18:28:27-04:00",
        "applicationDeadline": "2026-10-26T19:00:00-04:00",
        "jobUrl": "job-boards.greenhouse.io/greenhouse/jobs/8126988?gh_jid=8126988",
        "applyUrl": null,
        "description": null,
        "descriptionRedactions": null,
        "changeStatus": "unchanged",
        "firstSeenAt": "2026-10-17T23:56:54.029Z",
        "lastSeenAt": "2026-10-18T00:04:40.285Z",
        "closedAt": null,
        "retrievedAt": "2026-10-18T00:04:40.285Z"
    },
    {
        "jobKey": "lever:leverdemo:ad208490-4052-4f91-a57e-433f2d1e484b",
        "source": "lever",
        "sourceName": "Lever public job postings",
        "board": "leverdemo",
        "companyName": null,
        "jobId": "ad208490-4052-4f91-a57e-433f2d1e484b",
        "title": "Customer Success Manager",
        "departments": ["Customer Success"],
        "team": "Customer Success Team",
        "location": "Atlanta, Georgia",
        "allLocations": ["Atlanta, Georgia"],
        "country": "US",
        "workplaceType": "hybrid",
        "isRemote": false,
        "employmentType": "Regular Full Time (Salary)",
        "salaryMin": 85000,
        "salaryMax": 175000,
        "salaryCurrency": "USD",
        "salaryInterval": "per-year-salary",
        "requisitionId": null,
        "language": null,
        "postedAt": "2026-08-31T18:12:19.703Z",
        "updatedAt": null,
        "applicationDeadline": null,
        "jobUrl": "jobs.lever.co/leverdemo/ad208490-4052-4f91-a57e-433f2d1e484b",
        "applyUrl": "jobs.lever.co/leverdemo/ad208490-4052-4f91-a57e-433f2d1e484b/apply",
        "description": null,
        "descriptionRedactions": null,
        "changeStatus": "new",
        "firstSeenAt": "2026-10-18T00:04:40.787Z",
        "lastSeenAt": "2026-10-18T00:04:40.787Z",
        "closedAt": null,
        "retrievedAt": "2026-10-18T00:04:40.787Z"
    },
    {
        "jobKey": "greenhouse:greenhouse:8279752",
        "source": "greenhouse",
        "sourceName": "Greenhouse public job board",
        "board": "greenhouse",
        "companyName": "Greenhouse",
        "jobId": "8279752",
        "title": "Enterprise Account Executive",
        "departments": ["Account Executive"],
        "team": null,
        "location": "London, United Kingdom",
        "allLocations": ["London, United Kingdom", "United Kingdom"],
        "country": null,
        "workplaceType": null,
        "isRemote": null,
        "employmentType": null,
        "salaryMin": null,
        "salaryMax": null,
        "salaryCurrency": null,
        "salaryInterval": null,
        "requisitionId": "2026 - Generating6",
        "language": "en",
        "postedAt": "2026-10-14T11:52:46-04:00",
        "updatedAt": "2026-10-14T11:52:46-04:00",
        "applicationDeadline": null,
        "jobUrl": "job-boards.greenhouse.io/greenhouse/jobs/8279752?gh_jid=8279752",
        "applyUrl": null,
        "description": null,
        "descriptionRedactions": null,
        "changeStatus": "unchanged",
        "firstSeenAt": "2026-10-17T23:56:54.029Z",
        "lastSeenAt": "2026-10-18T00:04:40.285Z",
        "closedAt": null,
        "retrievedAt": "2026-10-18T00:04:40.285Z"
    }
]
```

### How much does it cost?

Pay per event, with no subscription:

- **$0.01** per run start,
- **$0.002** per board checked successfully (failed boards are not charged),
- **$2.00 per 1,000 jobs** saved ($0.002 per row, `closed` rows included; unchanged rows skipped by `onlyChanges` are free).

Example: 20 companies with about 1,000 jobs in total cost about $0.01 + $0.04 + $2.00 = $2.05 per full run. A daily `onlyChanges` run usually costs much less than a full run, because only changes are saved. Set a maximum cost per run in the run options and the Actor stops cleanly when it is reached.

### Privacy: company and job data only

- The output has no person fields. Greenhouse custom fields (which companies sometimes use for recruiter or hiring-manager names) are dropped entirely.
- Full job descriptions are **off by default**. Rows still have the title, company, location, department, links and dates. Descriptions are each employer's own text; turn them on only if you need them.
- With **Include full job descriptions** on, descriptions are plain text with **email addresses, phone numbers, profile links (LinkedIn, Calendly, X/Twitter) and names after cues** such as "contact", "recruiter", "hiring manager", "reports to", "created by" or "my name is" replaced with placeholders like `[name removed]`.
- Limits: a name written with no such cue (for example a bare "Alex will onboard you") can't be told apart from other capitalised words and may remain. Leave **Include full job descriptions** off (the default) if you need zero free text.
- Use the data for job-related purposes. Don't use it to contact people.

### Limits

- Greenhouse boards hosted in the EU region (`job-boards.eu.greenhouse.io`) are not supported yet and are rejected with a clear message.
- Lever's postings API does not include the company name, so `companyName` is empty for Lever rows (`board` holds the company's Lever name).
- Neither API supports server-side filtering, so filters are applied after download: each board is still read in full.
- Workplace type comes from Lever's structured field. Greenhouse boards only reveal "remote" when the location says so.
- Description text is the employer's own, and only lightly cleaned. Change detection still notices description edits when descriptions are off (rows are marked `changed`, without the text).
- The sources do not publish rate limits. The Actor paces itself and stops a board after repeated rate-limit responses.
- Change tracking remembers up to 100,000 jobs per board per feed (stored in chunks of 5,000). A board larger than that would have its oldest-listed jobs reported as `new` again; no public board we know of comes close. Very large boards also need more memory than the 256 MB default.
- The October 2026 update (changelog 0.1.2) changed how history is stored (per feed instead of per board). The first run after the update reports every job as `new` once; old records named `BOARD-…` in `job-boards-feed-state` are no longer used and can be deleted.

### Integrations and API

Use the Apify API, webhooks, or integrations (Zapier, Make, Google Sheets, Slack and others) to send each run's new and changed jobs wherever you need them. The per-board results are in the `RUN_SUMMARY` record of the run's default key-value store.

### FAQ

**Does it need a Greenhouse or Lever account?** No. It reads public job board data that needs no login or key.

**Why did a board fail?** See `RUN_SUMMARY.boards`. The most common reason is a wrong board name or a company that no longer uses that job board (HTTP 404).

**Will I be charged for failed boards?** No. The board event is only charged for boards that were read successfully.

**How do I reset change tracking?** Delete the `job-boards-feed-state` key-value store in your account (this resets every feed), or start a new task.

**Is it official?** No. It is an independent tool, not affiliated with Greenhouse, Lever or Employ Inc.

# Changelog

This Actor's version history is a separate document: https://apify.com/graylabs/job-boards-feed/changelog.md

# Actor input Schema

## `greenhouseBoards` (type: `array`):

Board tokens or board URLs, e.g. "acme", "https://boards.greenhouse.io/acme" or "https://job-boards.greenhouse.io/acme". The token is the last part of the company's Greenhouse job board address.

## `leverCompanies` (type: `array`):

Lever company slugs or job page URLs, e.g. "acme" or "https://jobs.lever.co/acme". EU-hosted pages (jobs.eu.lever.co) are supported.

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

Keep only jobs whose title contains any of these words (case-insensitive). Empty keeps all.

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

Keep only jobs whose location contains any of these (case-insensitive), e.g. "London", "Germany", "Remote".

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

Keep only jobs whose department or team contains any of these (case-insensitive), e.g. "Engineering".

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

Keep only these workplace types. Lever reports remote, hybrid or on-site; Greenhouse boards only reveal remote when the location says so.

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

Off by default: rows have the title, company, location, department, links and dates, and `description` is null. Turn on to add each job's full description as plain text. Email addresses, phone numbers, profile links and person names after cues like "contact" or "recruiter" are removed from the text. Descriptions are written by each employer.

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

Remembers each board's jobs in a named key-value store in your account so every row says whether it is new, changed or unchanged since your last run, and jobs that disappeared are reported as closed.

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

Output only new, changed and closed jobs (needs change tracking). Unchanged jobs are skipped and not charged.

## `includeClosed` (type: `boolean`):

Add a row with changeStatus "closed" for each job that was on a board last run but is gone now.

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

Stop after saving this many rows (closed rows included).

## `maxRequests` (type: `integer`):

Safety cap on HTTP requests per run, retries included. Greenhouse uses one request per board; Lever one per 100 jobs.

## Actor input object example

```json
{
  "greenhouseBoards": [
    "greenhouse"
  ],
  "leverCompanies": [
    "leverdemo"
  ],
  "includeDescriptions": false,
  "trackChanges": true,
  "onlyChanges": false,
  "includeClosed": true,
  "maxJobs": 20,
  "maxRequests": 300
}
```

# Actor output Schema

## `jobs` (type: `string`):

No description

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

No description

# API

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

## JavaScript example

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

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

// Prepare Actor input
const input = {
    "greenhouseBoards": [
        "greenhouse"
    ],
    "leverCompanies": [
        "leverdemo"
    ],
    "maxJobs": 20
};

// Run the Actor and wait for it to finish
const run = await client.actor("graylabs/job-boards-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 = {
    "greenhouseBoards": ["greenhouse"],
    "leverCompanies": ["leverdemo"],
    "maxJobs": 20,
}

# Run the Actor and wait for it to finish
run = client.actor("graylabs/job-boards-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 '{
  "greenhouseBoards": [
    "greenhouse"
  ],
  "leverCompanies": [
    "leverdemo"
  ],
  "maxJobs": 20
}' |
apify call graylabs/job-boards-feed --silent --output-dataset

```

## MCP server setup

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