# Greenhouse, Lever, Ashby & Workable Jobs API (`sunburst077/multi-ats-jobs-feed`) Actor

Pull public job postings from Greenhouse, Lever, Ashby, and Workable boards. No logins, proxies, or paid APIs.

- **URL**: https://apify.com/sunburst077/multi-ats-jobs-feed.md
- **Developed by:** [Sunburst](https://apify.com/sunburst077) (community)
- **Categories:** Jobs, Lead generation, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.50 / 1,000 jobs

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 API

Pull every public job posting from companies' **Greenhouse**, **Lever**, **Ashby** and **Workable** job boards into one normalized JSON feed, with one dataset item per job.

The Actor calls only the unauthenticated job-board JSON APIs those products already publish for careers sites. It does not log in, does not use proxies, and does not call paid job APIs.

### Why use it

- One input list of boards, one output schema
- Board URLs are enough — the Actor detects the ATS from the host
- Bad slugs become error rows; the rest of the run continues
- Optional keyword, location, department, remote, and recency filters
- Pay-per-event: a small start fee plus a fee per job item

### How to get a board slug

Open the company's public careers page and copy the URL.

| ATS | Careers URL | Slug |
| --- | --- | --- |
| Greenhouse | `https://boards.greenhouse.io/gitlab` or `https://job-boards.greenhouse.io/gitlab` | `gitlab` |
| Lever | `https://jobs.lever.co/spotify` (EU boards use `jobs.eu.lever.co`) | `spotify` |
| Ashby | `https://jobs.ashbyhq.com/ramp` | `ramp` |
| Workable | `https://apply.workable.com/soundcloud` | `soundcloud` |

You can paste that URL, or pass `{"ats": "greenhouse", "slug": "gitlab"}`.

### Input

| Field | Type | Description |
| --- | --- | --- |
| `companies` | array | **Required.** Board URLs and/or `{ats, slug}` objects. |
| `keywords` | string\[] | Keep jobs that contain at least one keyword (case-insensitive substring). Searches the title only unless `keywordsMatch` says otherwise. |
| `keywordsMatch` | `title` | `title_and_description` | Where `keywords` are searched. Default `title`. See [Keyword matching](#keyword-matching). |
| `locations` | string\[] | Keep jobs whose location text contains at least one value. |
| `remoteOnly` | boolean | Keep only remote roles. |
| `departments` | string\[] | Match department or team text. |
| `postedSince` | integer | Keep jobs posted or updated in the last N days. Jobs with no date are kept. |
| `maxJobsPerCompany` | integer | Cap matching jobs per board after filters. Default `5`. |
| `includeDescription` | boolean | Write cleaned Markdown (or plain text) on `descriptionMarkdown`. Default `false`. |
| `descriptionFormat` | `markdown` | `text` | Used only when `includeDescription` is true. |
| `maxConcurrency` | integer | Boards fetched at once. Default `3`. |

The default input is three well-known public boards with `maxJobsPerCompany: 5` and descriptions off, so a first run stays small.

#### Example

```json
{
    "companies": [
        "https://job-boards.greenhouse.io/gitlab",
        "https://jobs.lever.co/spotify",
        { "ats": "ashby", "slug": "ramp" },
        { "ats": "workable", "slug": "soundcloud" }
    ],
    "keywords": ["engineer", "designer"],
    "locations": ["Remote", "New York"],
    "remoteOnly": false,
    "departments": ["Engineering"],
    "postedSince": 30,
    "maxJobsPerCompany": 25,
    "includeDescription": false
}
```

#### Keyword matching

`keywordsMatch` controls where `keywords` are searched:

- `title` (default): the job title only. `engineer` returns "Backend Engineer" and "Engineering Manager", but not an "Account Executive" whose description mentions "solutions engineers".
- `title_and_description`: the title plus the full job description. This is how `keywords` worked before 1.0.8. It finds more jobs but also more off-target ones, and it is slower because descriptions have to be downloaded.

Inputs from before 1.0.8 still validate and run. To get the old matching back, add `"keywordsMatch": "title_and_description"`.

### Output

Each successful item looks like this:

```json
{
    "status": "ok",
    "id": "greenhouse:gitlab:1234567",
    "company": "GitLab",
    "ats": "greenhouse",
    "slug": "gitlab",
    "title": "Senior Backend Engineer",
    "department": "Engineering",
    "team": null,
    "location": "Remote",
    "remote": true,
    "employmentType": "Full-time",
    "compensation": {
        "min": 150000,
        "max": 200000,
        "currency": "USD",
        "interval": "year"
    },
    "postedAt": "2026-10-01T20:00:00+00:00",
    "updatedAt": "2026-10-14T15:55:28+00:00",
    "applyUrl": "https://boards.greenhouse.io/gitlab/jobs/1234567",
    "jobUrl": "https://boards.greenhouse.io/gitlab/jobs/1234567",
    "descriptionMarkdown": null,
    "sourceId": "1234567"
}
```

`compensation` is `null` when the board does not publish a range. `descriptionMarkdown` is filled only when `includeDescription` is enabled.

If a company cannot be read, the Actor writes an error row and continues:

```json
{
    "status": "error",
    "reason": "Board not found (HTTP 404) at https://boards-api.greenhouse.io/v1/boards/not-a-real-board/jobs?content=true&pay_transparency=true.",
    "company": "not-a-real-board",
    "ats": "greenhouse",
    "slug": "not-a-real-board",
    "sourceId": null
}
```

The dataset **Overview** view shows status, title, company, ATS, location, remote, dates, and links.

### Pricing

Pay-per-event. See the pricing table in the Actor listing for the live figures. The published events are:

| Event | Price | Charged |
| --- | --- | --- |
| `run-start` | $0.002 | Once per run |
| `job` | $0.0015 | Once per job item |

Error rows are not billed as `job` events. If you set a max total charge on the run, the Actor stops when the next event would exceed it and keeps the items already written.

### Public APIs used

| ATS | Endpoint |
| --- | --- |
| Greenhouse | `GET https://boards-api.greenhouse.io/v1/boards/{token}/jobs?content=true` |
| Lever | `GET https://api.lever.co/v0/postings/{company}?mode=json` (EU: `api.eu.lever.co`) |
| Ashby | `GET https://api.ashbyhq.com/posting-api/job-board/{name}?includeCompensation=true` |
| Workable | `GET https://apply.workable.com/api/v1/widget/accounts/{subdomain}` (`?details=true` when descriptions are needed) |

These endpoints are public, unauthenticated, and intended for careers-site widgets. Identify your traffic and do not poll a board more often than you need.

### Reliability

- Retries with exponential backoff on HTTP 429, 5xx, timeouts, and network errors
- Invalid or unknown slugs become error rows; the run does not fail
- Modest default concurrency (3 boards at a time)
- Charge-limit stop is clean: no partial charges beyond the cap the platform allows

### Limitations

- Only the four ATS hosts above. Workday, SmartRecruiters, Lever-hosted custom domains, and Greenhouse embed-only sites without a public board token are out of scope.
- A board must be public. Private Greenhouse boards, Lever sites that are not published, and Workable accounts that disabled the widget return 404 and an error row.
- Greenhouse has no official remote flag. Remote is inferred from location and office text.
- Compensation is included only when the ATS publishes a structured range (most common on Ashby; occasional on Lever and Greenhouse). Workable's widget feed does not include pay.
- Lever `createdAt` is used as `postedAt`. Greenhouse uses `first_published` / `updated_at`. Workable often has a date without a time.
- Descriptions are HTML from the ATS, converted to Markdown or plain text. Conversion is best-effort; unusual markup can look messy.
- Large boards are downloaded in one or a few HTTP responses. `maxJobsPerCompany` limits how many items you **write**, not how much JSON the ATS returns. Leave `includeDescription` off if you only need titles.
- This Actor does not deduplicate across runs.
- Results only go as far as each employer published on that board. The Actor does not search the open web.

### FAQ

**Do I need API keys?**\
No. These job-board endpoints are public.

**Can I pass a Greenhouse job URL, not the board URL?**\
Yes. `https://boards.greenhouse.io/gitlab/jobs/123` is treated as the `gitlab` board. The same applies to a single Lever, Ashby, or Workable posting URL.

**What happens if one company is wrong?**\
That company becomes an error row. Other companies still run.

**Why is compensation null?**\
Most employers do not publish a structured range on the public board API. The Actor does not guess salary from description text.

**Why is a role not marked remote?**\
Only an ATS remote flag, a `workplaceType` of remote, or location text such as "Remote" / "Work from home" counts. "Hybrid" is not remote.

**Will the default input stay fast?**\
The prefills are public boards with `maxJobsPerCompany: 5` and descriptions off. Run time still depends on how large those boards are on the day you run.

**How do I keep costs down?**\
Set `maxJobsPerCompany`, add filters, leave descriptions off, and set a max total charge on the run.

### More actors from this developer

- [PDF to Markdown: DOCX, PPTX, XLSX, OCR & RAG Chunks](https://apify.com/sunburst077/document-to-markdown-rag): PDF, DOCX, PPTX, XLSX and HTML to clean Markdown and RAG-ready chunks.
- [Google Trends Scraper & API: Interest, Related, Trending](https://apify.com/sunburst077/google-trends-reliable): interest over time, by region, and related queries from Google Trends.
- [Google News Scraper: Full-Text Articles to Markdown](https://apify.com/sunburst077/google-news-to-markdown): Google News articles as Markdown and RAG chunks for any topic or company.
- [Website Screenshot & URL to PDF API (Bulk, Full Page)](https://apify.com/sunburst077/website-screenshot-pdf): full-page and mobile screenshots (PNG, JPEG, WebP) and PDFs of any URL.
- [Wayback Machine API: Snapshots & Archived Pages](https://apify.com/sunburst077/wayback-machine-snapshots): every archived capture of a URL or domain, the snapshot closest to a date, and archived pages as Markdown.

### Local development

```bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements-dev.txt
pytest
```

On the Apify platform the Actor starts with `python -m src`. Publish pay-per-event events `run-start` ($0.002) and `job` ($0.0015) to match this code. There is no deploy step in this repository.

# Changelog

This Actor's version history is a separate document: https://apify.com/sunburst077/multi-ats-jobs-feed/changelog.md

# Actor input Schema

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

Public job boards to read. Each item is either a board URL (for example https://boards.greenhouse.io/gitlab) or an object {"ats": "greenhouse"|"lever"|"ashby"|"workable", "slug": "board-token"}. ATS is inferred from well-known hosts: boards.greenhouse.io, job-boards.greenhouse.io, jobs.lever.co, jobs.eu.lever.co, jobs.ashbyhq.com, apply.workable.com.

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

Keep jobs that contain at least one of these strings (case-insensitive). By default only the job title is searched; use Keyword match to also search descriptions. Leave empty to keep all jobs.

## `keywordsMatch` (type: `string`):

Where keywords are searched. title (default): job title only, so 'engineer' does not return sales roles that mention engineers in the description. title_and_description: title plus full description text (the behaviour before version 1.0.8; broader, noisier, and slower because descriptions are downloaded).

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

Keep jobs whose location text contains at least one of these strings (case-insensitive). Matches city, country, or the ATS location label.

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

When enabled, keep only jobs marked remote by the ATS, or whose location text clearly says remote / work from home.

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

Keep jobs whose department or team contains at least one of these strings (case-insensitive).

## `postedSince` (type: `integer`):

Keep jobs whose posted or updated timestamp falls within this many days. Uses postedAt, then updatedAt. Jobs with no date are kept. Leave empty for no date filter.

## `maxJobsPerCompany` (type: `integer`):

Cap how many matching jobs are written per board, after filters. Use a small number for a fast test run.

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

When enabled, convert the posting HTML to cleaned Markdown and write it on descriptionMarkdown. Leave off for a faster, smaller dataset.

## `descriptionFormat` (type: `string`):

How to convert HTML when includeDescription is enabled. Markdown is the default. Plain text strips tags.

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

How many company boards to fetch at once. Keep this modest to respect public rate limits.

## Actor input object example

```json
{
  "companies": [
    "https://boards.greenhouse.io/stripe",
    {
      "ats": "lever",
      "slug": "spotify"
    },
    {
      "ats": "workable",
      "slug": "soundcloud"
    }
  ],
  "keywordsMatch": "title",
  "remoteOnly": false,
  "maxJobsPerCompany": 5,
  "includeDescription": false,
  "descriptionFormat": "markdown",
  "maxConcurrency": 3
}
```

# Actor output Schema

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

One dataset item per job, plus an error row for any board that could not be read.

# 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": [
        "https://job-boards.greenhouse.io/gitlab",
        "https://jobs.lever.co/spotify",
        {
            "ats": "ashby",
            "slug": "ramp"
        }
    ],
    "maxJobsPerCompany": 5
};

// Run the Actor and wait for it to finish
const run = await client.actor("sunburst077/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 = {
    "companies": [
        "https://job-boards.greenhouse.io/gitlab",
        "https://jobs.lever.co/spotify",
        {
            "ats": "ashby",
            "slug": "ramp",
        },
    ],
    "maxJobsPerCompany": 5,
}

# Run the Actor and wait for it to finish
run = client.actor("sunburst077/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 '{
  "companies": [
    "https://job-boards.greenhouse.io/gitlab",
    "https://jobs.lever.co/spotify",
    {
      "ats": "ashby",
      "slug": "ramp"
    }
  ],
  "maxJobsPerCompany": 5
}' |
apify call sunburst077/multi-ats-jobs-feed --silent --output-dataset

```

## MCP server setup

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