# Public ATS Job Board Scraper (Greenhouse, Ashby, Lever) (`vocal_pedestal/ats-job-board-scraper`) Actor

Full job descriptions from companies' public ATS APIs. No proxy, no browser - pay per job result, not per page load.

- **URL**: https://apify.com/vocal_pedestal/ats-job-board-scraper.md
- **Developed by:** [Hjerau Lee](https://apify.com/vocal_pedestal) (community)
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 job results

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

## ATS Jobs API Scraper — Greenhouse, Ashby, Lever Job Postings

Read a company's live job openings — **including the full job description** — straight from
the applicant-tracking APIs those companies already publish. No headless browser, no proxy
rotation, no login. One HTTP call usually returns an entire board, so you pay per job result
rather than per page load.

Use it for sales intelligence (who is hiring, for what, where), recruiting-market research,
candidate matching, internal mobility dashboards, or as the source of a job-alert product.

### Highlights

- **Cheap per result.** A board with 700 openings costs one request, not 700 page renders.
  The measured transfer is ~4 MB of gzipped JSON per 1,000 jobs, no proxy involved — see
  *How it works* for why that matters to your bill.
- **Full descriptions, structured.** Every result carries the job text as both `contentHtml`
  and tag-stripped `contentText`, so keyword filters and embeddings work on the same record.
- **Nothing invented.** A field the company does not publish stays `null`. No guessed
  salaries, no fabricated dates, no silent "remote" assumption for ATSes that don't say.
- **Predictable cost ceiling.** The Actor respects your run's `maxCost`: once your limit is
  reached it stops storing and stops charging, and says so in the run log.
- **Fair coverage under a cap.** With `maxItems` set, results are taken round-robin across the
  companies you asked for, instead of one big board consuming the whole quota.

### How it works

Greenhouse, Ashby and Lever expose public, unauthenticated JSON endpoints so a company can
embed its own career page:

| ATS | Endpoint the Actor calls | Jobs per request |
| --- | --- | --- |
| Greenhouse | `https://boards-api.greenhouse.io/v1/boards/{token}/jobs?content=true` | whole board (typically 100–800) |
| Ashby | `https://api.ashbyhq.com/posting-api/job-board/{token}` | whole board |
| Lever | `https://api.lever.co/v0/postings/{token}?mode=json` | whole board |

That is the whole trick: the data is *already published as an API*, so this Actor reads it
instead of rendering the website. Requests are sequential per board and modest — a 1,000-company
run is ~1,000 requests, not 100,000.

**What this Actor will not do** (stated up front so you don't discover it later):

- It reads **Greenhouse, Ashby and Lever only**. LinkedIn, Indeed, Workday and company-built career
  pages are out of scope. **SmartRecruiters is out of scope for a different reason, and we corrected
  ourselves here**: an earlier note in this README claimed its public posting endpoint "returned zero
  results". That was wrong — re-tested 2026-11-01, `api.smartrecruiters.com/v1/companies/<slug>/postings`
  returned live listings for 2 of 5 company slugs we tried (e.g. 180 postings for one French retailer,
  2 for SmartRecruiters itself); the other 3 slugs were simply not the right identifiers. We still do
  not support it, because its field mapping, pagination and `applyUrl` shape are untested work we have
  not done - not because the endpoint is dead. Adding it would mean testing the mapping against real
  boards, which we have not done yet.
- It cannot see closed, filled or unlisted openings — only what the board currently lists.
- Salary appears **only** when the board itself publishes a structured compensation field.
- It does not circumvent blocks, logins, CAPTCHAs or rate limits. If a board refuses, the run
  records that company under `boardsFailed` and moves on.

### Input

```json
{
  "companies": [
    "stripe",
    "https://jobs.ashbyhq.com/linear",
    "https://jobs.lever.co/spotify"
  ],
  "keywords": ["engineer"],
  "excludeKeywords": ["staff", "principal"],
  "remoteOnly": false,
  "maxItems": 500,
  "includeJobContent": true
}
```

| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| `companies` | array of strings | required | ATS board tokens or careers URLs, one per entry. Tokens are probed against Greenhouse → Ashby → Lever; pin `ats` to skip the probing. |
| `ats` | `auto`/`greenhouse`/`ashby`/`lever` | `auto` | Force the vendor when you know it. |
| `maxItems` | integer | 500 | Hard cap on stored results (max 50,000). |
| `keywords` / `excludeKeywords` | array | – | Case-insensitive substrings on title + location + department. |
| `keywordMatchContent` | boolean | false | Also match inside the job description. |
| `locationKeywords` | array | – | Substrings of the location label the company published. Locations are free text. |
| `remoteOnly` | boolean | false | Only jobs the board itself marks remote. |
| `publishedWithinDays` | integer | 0 | Filter on the board's own published date; jobs without one are dropped when this is on. |
| `includeJobContent` | boolean | true | Store the description. Turn off for a much smaller, cheaper record. |
| `maxContentChars` | integer | 20000 | Truncates stored text and sets `contentTruncated: true`. |
| `concurrency`, `requestTimeoutSecs` | integer | 8 / 30 | Boards fetched in parallel, per-request timeout. |

### Output

One dataset item per job:

```json
{
  "source": "ashby",
  "company": "linear",
  "jobId": "d3bc1ced-3ce4-4086-a050-555055dbb1ff",
  "title": "Senior / Staff Fullstack Engineer",
  "url": "https://jobs.ashbyhq.com/linear/d3bc1ced-3ce4-4086-a050-555055dbb1ff",
  "applyUrl": "https://jobs.ashbyhq.com/linear/d3bc1ced-.../application",
  "locationText": "Europe",
  "department": "Product",
  "team": "Engineering",
  "employmentType": "full-time",
  "workplaceType": "remote",
  "remote": true,
  "publishedAt": "2021-04-27T20:13:45+00:00",
  "updatedAt": null,
  "contentHtml": "<p>At Linear, we're building ...",
  "contentText": "At Linear, we're building ...",
  "contentChars": 3000,
  "contentTruncated": true,
  "compensation": null,
  "extra": { "secondaryLocations": [], "postalAddress": { "addressCountry": "European Union" } },
  "collectedAt": "2026-10-28T13:07:53+00:00",
  "sourceEndpoint": "https://api.ashbyhq.com/posting-api/job-board/linear"
}
```

Field notes worth knowing before you build on them:

- `remote` is tri-state: `true`, `false`, or `null` when the board never said. Greenhouse has no
  remote flag, so its jobs get `true` only when the company's own location/office label literally
  says "remote"; otherwise `null`. `locationText` always keeps the original wording.
- `publishedAt` / `updatedAt` are UTC ISO 8601 converted from the board's own timestamps
  (Greenhouse `first_published`, Ashby `publishedAt`, Lever `createdAt`). `null` when absent.
- `workplaceType` is normalized to `remote` / `hybrid` / `onsite` / `unknown`.
- `sourceEndpoint` is on every record so you can verify a result against the live board.
- The run also writes a key-value record `RUN_SUMMARY` listing every board it read, how many jobs
  each board had, and every board that failed with the reason. Nothing is silently skipped.

### Pricing

Charged through Apify's *pay per event* model: one event per stored job result
(`job-result`), plus the standard per-run start event. **No platform-usage surcharge is added on
top** — you pay for data, not for the plumbing. The live **Pricing** tab on this Actor's page is
the source of truth for current rates and tiers.

To estimate your own spend: `results = jobs after filters`, and the run's transfer volume is
roughly 4 MB per 1,000 job results with descriptions included (measured), so no proxy or browser
cost is folded into the price. For comparison inside this segment: on 2026-10-31 we read the live
event prices of the Actors Apify's store returns for these ATS searches - **41 of 63 publish a
per-result price, and the median is $2.00 per 1,000 results**; this Actor is $1.00 per 1,000.

### Tutorial

1. Open the **Run** tab, put one company token or careers URL per line into *Companies*.
   Take the token from the live careers URL, not from memory: `jobs.lever.co/<token>`,
   `boards.greenhouse.io/<token>` or `jobs.ashbyhq.com/<token>`. Companies move ATS vendors, so a
   guessed token often 404s — that is the board being gone, not the Actor failing. A known-good
   starter set (verified 2026-10-29 against all three vendors): `stripe`, `canonical`, `spotify`,
   `clickhouse`.
2. Set `maxItems` to something small (say 20) for the first run and check the **Dataset** tab.
3. Add `keywords` / `remoteOnly` / `publishedWithinDays` to shape the set you actually want.
4. For recurring collection, use **Schedules** (e.g. hourly on a watchlist) and read the dataset
   through the [Apify client](https://docs.apify.com/api/client/python/docs.md) or the
   [dataset API](https://docs.apify.com/storage/dataset.md). CSV, Excel and JSON exports are built
   into Console.
5. Via API:

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

const client = new ApifyClient({ token: '<YOUR_APIFY_TOKEN>' });
const run = await client.actor('<ACTOR_ID>').call({
  companies: ['stripe', 'https://jobs.ashbyhq.com/linear'],
  keywords: ['engineer'],
  maxItems: 200,
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
```

Because results are ordinary dataset items, this Actor drops into Make, n8n, Zapier or an Airtable
sync without any custom code.

### FAQ

**Is this allowed?** It reads public, unauthenticated JSON endpoints the ATS vendors publish for
embedding company career pages. There is no login, no bypass of access controls, no CAPTCHA
defeat, and request volume is one per company rather than a crawl. Companies that do not want
their board aggregated can disable the public API; this Actor respects whatever the endpoint
returns.

**A token returned almost nothing — is the Actor broken?** Usually not: a bare token can exist
on more than one ATS (a stale legacy board plus the live one, or two unrelated companies that
happen to share a slug). The Actor never switches vendors on its own, because the bigger board
might belong to a different employer. Instead the run summary lists such tokens under
`boardsAmbiguous` with the alternative board and its job count, so you can re-run with
`"ats": "ashby"` (or `greenhouse`/`lever`) and get exactly the company you meant.

**Why did a company return nothing?** Either the token belongs to a different ATS (pin `ats`), the
board is empty, or it 404s — check `RUN_SUMMARY.boardsFailed`, which names the reason per company.

**My `publishedWithinDays` filter returned almost nothing.** That is usually the board's own data,
not a miss. Posting ages measured on 2026-10-30 (first 80 jobs per board): median age
**38 days** (lever/spotify), **41** (greenhouse/stripe), **91** (ashby/ramp), **210**
(ashby/clickhouse); with a 7-day filter those boards returned 11, 8, 4 and **0** of 80. Start with
`publishedWithinDays: 0` (off) or 30, and only tighten it once you have seen a board's cadence.

**Which fields are often empty?** `compensation` was `null` in **180 of 180** sampled records
(three boards, 60 each) — structured salary is rare in these APIs, and we publish it only when the
board itself provides it. `remote` is set on Ashby/Lever but on Greenhouse only when the board's own
location text literally says remote (**6 of 60** sampled). `employmentType` came back empty for the
Greenhouse board while Ashby and Lever filled ~all of it. Those are declared nullable in the dataset
schema, so an empty field never becomes a guessed one.

**Can you add LinkedIn/Indeed/Workday?** Those require browser rendering, proxies and, for some,
authenticated surfaces. This Actor deliberately does not do that, so we do not quote prices for
those sources. What we did measure (live store prices, 2026-10-30 and 2026-10-31): inside the ATS
segment the median is $2.00 per 1,000 results and this Actor is $1.00. If your need is a watchlist
of big tech companies, the Greenhouse/Ashby/Lever coverage is usually enough.

**Are the results deduplicated?** Yes, by `source + company + jobId`. Re-runs of the same board
return the same IDs, so you can diff by `jobId` to detect newly posted and withdrawn openings.

**What if my `maxCost` runs out mid-run?** The Actor stops storing and charging at that moment and
logs `buyer spending limit reached after N results`; the run finishes with the results already
paid for, which is exactly the behaviour Apify's docs require.

**How fresh is the data?** Each run reads the board live; there is no cache. Set a schedule to
keep a history.

### Support

Open an issue or message the developer through the Actor page. When reporting a wrong or missing
field, include the `company`, the `source`, and a run ID — every record carries `sourceEndpoint`,
so we can compare against the live board.

# Actor input Schema

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

One entry per line: an ATS board token (`stripe`, `linear`) or a careers URL (`https://jobs.ashbyhq.com/linear`, `https://boards.greenhouse.io/stripe`, `https://jobs.lever.co/spotify`). Tokens are tried against Greenhouse, Ashby and Lever in turn.

## `ats` (type: `string`):

"Auto" detects the ATS from a careers URL, otherwise tries Greenhouse, then Ashby, then Lever. Pin it if you know the vendor - it avoids two wasted lookups per company.

## `maxItems` (type: `integer`):

Maximum number of job results to store. Results are taken round-robin across companies, so a capped run still covers every company you asked for.

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

Case-insensitive substrings, matched against the job title, location and department (and the description if "Search inside job descriptions" is on). Any single match keeps the job. Leave empty to keep everything.

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

Any match drops the job. Applied after `keywords`.

## `keywordMatchContent` (type: `boolean`):

Match `keywords` / `excludeKeywords` against the full job text as well as the title and location. Costs no extra requests, but a keyword hit anywhere in the description keeps the job.

## `locationKeywords` (type: `array`):

Case-insensitive substrings of the location label the company published (e.g. `berlin`, `new york`, `emea`). Locations are free text in every ATS, so treat this as a substring filter, not an exact match.

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

Keeps only jobs the company itself marked remote. Greenhouse has no remote flag, so a Greenhouse job is kept only when its own location/office label says "remote"; anything ambiguous is excluded rather than guessed.

## `publishedWithinDays` (type: `integer`):

Uses the posting's own first-published/created date. 0 disables the filter. Jobs whose source does not publish a date are dropped when this filter is on - we never fill a date in.

## `includeJobContent` (type: `boolean`):

Stores `contentHtml` plus a tag-stripped `contentText`. Turning this off makes each result far smaller and cheaper to transfer; title, location, dates and links are always included.

## `maxContentChars` (type: `integer`):

Truncates the stored description and sets `contentTruncated: true` so you can tell. Applies to HTML and text alike.

## `requestTimeoutSecs` (type: `integer`):

Per HTTP request. Large boards return a few megabytes in one response, so keep this generous.

## `concurrency` (type: `integer`):

How many boards are read at once. Higher is faster but uses more memory; the run stays within the memory you selected for the run.

## Actor input object example

```json
{
  "companies": [
    "stripe",
    "https://jobs.ashbyhq.com/linear",
    "https://jobs.lever.co/spotify"
  ],
  "ats": "auto",
  "maxItems": 500,
  "keywordMatchContent": false,
  "remoteOnly": false,
  "publishedWithinDays": 0,
  "includeJobContent": true,
  "maxContentChars": 20000,
  "requestTimeoutSecs": 30,
  "concurrency": 8
}
```

# Actor output Schema

## `jobResults` (type: `string`):

All stored job postings as JSON. Each item carries source, company, jobId, title, location, dates, links, and optionally the full job description (contentHtml/contentText).

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

Per-run diagnostics: companies requested, jobs seen on each board, results stored, filtered out, duplicates, why the run stopped, and every board that failed with its reason.

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

// Run the Actor and wait for it to finish
const run = await client.actor("vocal_pedestal/ats-job-board-scraper").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": [
        "stripe",
        "https://jobs.ashbyhq.com/linear",
        "https://jobs.lever.co/spotify",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("vocal_pedestal/ats-job-board-scraper").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": [
    "stripe",
    "https://jobs.ashbyhq.com/linear",
    "https://jobs.lever.co/spotify"
  ]
}' |
apify call vocal_pedestal/ats-job-board-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,vocal_pedestal/ats-job-board-scraper"
        }
    }
}
```

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/diEbA7gMQX9jd69YW/builds/RS2gwXkXcAbBCMF7A/openapi.json
