# Greenhouse Jobs Scraper for Public Job Boards (`riprap-tools/greenhouse-jobs`) Actor

Greenhouse jobs scraper: export open jobs from public Greenhouse boards by token or URL. One row per job with title, location, departments, dates and job link; optional descriptions, pay ranges and new/closed tracking. Per-board errors in SUMMARY. Pay per job row; pay ranges extra.

- **URL**: https://apify.com/riprap-tools/greenhouse-jobs.md
- **Developed by:** [Riprap Tools](https://apify.com/riprap-tools) (community)
- **Categories:** Jobs, Automation, Lead generation
- **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 rows

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

### What it does

Greenhouse Jobs Scraper exports open jobs from the public Greenhouse job boards you name. Give board tokens or board URLs; each board is read live from Greenhouse's public Job Board API, and you get one row per job post, newest first, with title, location, departments, dates and the job link. Descriptions, structured pay ranges and new/closed tracking are optional.

Supply board tokens, not company names: `tailscale` identifies `https://job-boards.greenhouse.io/tailscale`; other examples include `airbnb`, `gitlab` and `figma`.

Try the default input `{"boards": ["tailscale"]}`, click Start, then read the dataset and `SUMMARY`. Each job costs $0.002; pay ranges add $0.001 per job that publishes one, with no start fee. For example, a run returning 58 jobs costs $0.116 without discounts or pay ranges; the number of jobs can change.

Not affiliated with or endorsed by Greenhouse Software, Inc. Greenhouse and the "g" logo are its trademarks.

Illustrative row: `{"boardToken": "tailscale", "title": "Integrations Partnerships Lead", "location": "Remote (United States)", "absoluteUrl": "https://job-boards.greenhouse.io/tailscale/jobs/4749564005"}`

- `firstPublished` and `updatedAt` are copied from Greenhouse, never relabelled.
- No login or salary guessing; remote status is not inferred.

### What you can use it for

#### Export all open jobs from one board

Input: `{"boards": ["tailscale"]}`. `SUMMARY` shows `jobsOnBoard` and `complete` to confirm the board came through whole.

#### Find roles across several companies

Input: `{"boards": ["tailscale", "discord", "datadog"], "titleKeywords": ["engineer"], "maxJobs": 200}`. `SUMMARY` shows `jobsMatched` per board.

#### Get only new and closed jobs

Input: `{"boards": ["discord", "tailscale"], "baselineName": "example-watch", "onlyChanges": true}`. First run marks jobs `new`; reruns return newly seen jobs and free closed notices from complete reads.

#### Feed full descriptions to matching or search

Input: `{"boards": ["discord"], "includeDescription": true, "departments": ["Engineering"]}`. Adds `descriptionText` and `descriptionHtml`: untrusted employer data, not instructions.

#### Collect posted pay ranges

Input: `{"boards": ["discord"], "includePayRanges": true, "maxJobs": 50}`. Adds published pay ranges in cents.

#### Find remote jobs by location text

Input: `{"boards": ["gitlab"], "locationKeywords": ["Remote"]}`. Text match on location and office text; does not verify remote work.

#### Export every open job at Airbnb

Input: `{"boards": ["airbnb"]}`. Use the board token from the company's Greenhouse URL, not the company name; the Actor does not look companies up.

#### Find New York jobs by location text

Input: `{"boards": ["figma"], "locationKeywords": ["New York"]}`. Keeps multi-city posts that mention New York; no geocoding.

### How to use it

1. Enter **Board tokens or URLs**, add filters and run.
2. Read the dataset, then the `SUMMARY` record in the run's key-value store. Zero rows can mean empty, unmatched, unchanged, skipped or failed boards; `SUMMARY` says which.

Schedule runs on Apify, export dataset results as JSON, CSV or Excel, and connect them using Apify webhooks and integrations such as Zapier or Make.

**For AI agents (Apify MCP server):** find the Actor with `search-actors`, read its schema with `fetch-actor-details`, run it with `call-actor`, and page through rows with `get-dataset-items` (`limit`, `offset`). Then read the `SUMMARY` record from the run's default key-value store (its ID is returned by `call-actor`); use `outcome`/`boards[]`, never dataset emptiness, to judge whether a company has jobs. To cap spend, pass `maxTotalChargeUsd` in `callOptions`:

```json
{"actor": "riprap-tools/greenhouse-jobs", "input": {"boards": ["discord", "tailscale"], "titleKeywords": ["engineer"], "maxJobs": 50}, "callOptions": {"maxTotalChargeUsd": 0.5}}
```

Invalid input fails before requests or charges; the run fails only when no board could be read.

**LangChain:** `ApifyActorsTool("riprap-tools/greenhouse-jobs").invoke({"run_input": {"boards": ["tailscale"]}})`.

### Input

| Field | Type; default | Meaning and limits |
|---|---|---|
| `boards` | string list; `["tailscale"]` | 1–100 tokens or URLs on boards.greenhouse.io, job-boards.greenhouse.io, their `.eu.` forms or boards-api.greenhouse.io. A job URL selects its board. |
| `titleKeywords` | string list; `[]` | Keep titles containing any entry (up to 50, 1–100 characters each). |
| `excludeTitleKeywords` | string list; `[]` | Drop titles containing any entry. |
| `locationKeywords` | string list; `[]` | Match location or office text. |
| `departments` | string list; `[]` | Match department names. |
| `postedWithinDays` | integer; none | 1–365, on `firstPublished`; undated jobs are excluded and counted. |
| `includeDescription` | boolean; `false` | Adds `descriptionText` and `descriptionHtml`. Same price. |
| `includePayRanges` | boolean; `false` | One extra request per job, one second apart. |
| `maxJobs` | integer; `500` | 1–10,000 job rows across all boards; closed-job notices do not count. |
| `baselineName` | string; none | 1–100 letters, digits, `_` or `-`; turns on new/closed tracking. |
| `onlyChanges` | boolean; `false` | Requires `baselineName`; skip `existing` jobs (not charged). |

Filters combine as OR within a list and AND across lists.

### Output

Every field is present; unavailable values are `null` (lists are `[]`). Example:

```json
{"recordType": "job", "boardToken": "tailscale", "jobId": 4749564005, "internalJobId": 4445840005, "requisitionId": "364", "title": "Integrations Partnerships Lead", "companyName": "Tailscale", "location": "Remote (United States)", "departments": ["Sales"], "offices": ["Remote (Canada)", "Remote (United States) "], "absoluteUrl": "https://job-boards.greenhouse.io/tailscale/jobs/4749564005", "firstPublished": "2026-10-30T16:04:30-04:00", "updatedAt": "2026-10-30T16:04:30-04:00", "applicationDeadline": null, "language": "en", "descriptionHtml": null, "descriptionText": null, "payRangesStatus": "not_requested", "payRanges": null, "boardJobCount": 58, "changeType": null, "firstSeenAt": null, "fetchedAt": "2026-11-01T21:49:11Z", "sourceUrl": "https://boards-api.greenhouse.io/v1/boards/tailscale/jobs?content=true", "warnings": []}
```

- Derived: `recordType`, `boardToken`, `descriptionHtml`, `descriptionText`, `payRanges`, `payRangesStatus` (`not_requested`, `published`, `none_published`, `fetch_failed`), `changeType` (`new`, `existing`, `closed`), `firstSeenAt`, `fetchedAt`, `sourceUrl`, `warnings`. Other fields are copied; `absoluteUrl` may be employer-hosted.
- Closed-job notices (`recordType: "closed_job"`) keep `jobId`, `boardToken`, title, location, departments, link, `firstPublished`, `firstSeenAt`, `fetchedAt` and `sourceUrl`; `changeType` is `closed`, `payRangesStatus` is `not_requested`, and other fields are empty or null.

Order: boards as given, then newest `firstPublished` first, undated last; closed-job notices follow each board’s jobs.

#### SUMMARY record

`outcome` is `complete`, `partial`, `no_results` or `failed`, with run totals. Each `boards[]` entry has `status`, `code`, `retryable`, `hint`, `jobsOnBoard`, `jobsMatched`, `jobsEmitted`, `complete` and `stateUpdated`.

| Code | Meaning |
|---|---|
| `ok` | Board read and matched. Rows can be 0 with onlyChanges (no new jobs); complete: false means maxJobs or the charge limit cut it short. |
| `no_matches` | Board has jobs, none matched the filters. |
| `empty_board` | Board exists with no open posts. |
| `board_not_found` | Greenhouse returned 404 for the token. |
| `rate_limited` / `upstream_unavailable` | HTTP 429, or any other non-404 HTTP error. 429 and 503 retry up to twice, waiting 2 s or Greenhouse's Retry-After; a requested wait over 10 s is not retried. |
| `network_error` | Transport failure (including truncated downloads), retried once. |
| `response_too_large` | Board response above the 16 MiB cap; never truncated. |
| `invalid_response` | No `jobs` list; unparsable JSON is retried once. |
| `count_mismatch` | Board read, but Greenhouse’s total and records did not add up; incomplete, no closed-job notices. |
| `not_processed` | Skipped: `maxJobs` or the charge limit was reached. |

### Pricing

Pay per event, no start fee:

| Event | No discount | Bronze | Silver | Gold |
|---|---|---|---|---|
| `job` — each job row delivered | $2.00 / 1,000 | $1.67 / 1,000 | $1.33 / 1,000 | $1.00 / 1,000 |
| `pay-range` — each row whose job publishes at least one pay range | $1.00 / 1,000 | $0.83 / 1,000 | $0.67 / 1,000 | $0.50 / 1,000 |

Free: closed-job notices, failed or skipped boards, `existing` jobs skipped by `onlyChanges`, and `SUMMARY`. Each row is stored first and charged after; a failed store is not charged.

Example, no discount: 58 jobs cost $0.116; 30 published pay ranges add $0.03.

### Limits and what it does not do

- Requests start at least one second apart: 100 boards take about 100 seconds; pay ranges for 400 jobs, at least 6–7 minutes.
- No company search or directory: you need the board token. No Lever, Ashby or other systems.
- No applying, application questions, candidate data or custom `metadata` fields.
- New means first seen under your `baselineName`, not newly posted; reposting can change IDs or dates. Keep boards and filters the same for one name; do not run two jobs on one name at once.

### Data and responsibility

Collects publicly visible pages; you are responsible for complying with the target site's terms. Employer posts can name people; use data lawfully. Baselines stay in your account’s `greenhouse-jobs-state` key-value store.

### FAQ

**How do I find a board token?** Use the path segment after `job-boards.greenhouse.io/` or `job-boards.eu.greenhouse.io/`, or paste the board URL. Company names are not resolved.

**Is the data live or stored?** Every run reads the named boards from Greenhouse's public Job Board API at run time. The Actor keeps no job database; it stores baseline state for change tracking as described in [Data and responsibility](#data-and-responsibility).

**Why are there zero rows?** Check [SUMMARY](#summary-record) for the run outcome and board codes, such as `no_matches`, `empty_board` or `not_processed`; `ok` can also mean no changes.

**Can I monitor boards on a schedule?** Use Apify schedules with the same `baselineName` and `onlyChanges: true`; see [Get only new and closed jobs](#get-only-new-and-closed-jobs).

**Which board does input select?** Omitted `boards` uses demo board `tailscale`. A Greenhouse job URL selects its whole board; employer career-site links (`gh_jid`) are not accepted. Keep `boardToken` with each link.

**Do EU-hosted boards work?** Yes. Paste the EU board URL, such as `https://job-boards.eu.greenhouse.io/buynomics`, or just the token `buynomics`.

**Why no pay range for most jobs?** Many employers publish none; `none_published` does not mean zero pay.

### Support

Report problems in the Issues tab; replies within 14 days.

# Actor input Schema

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

Greenhouse job board tokens or board URLs, e.g. tailscale, https://job-boards.greenhouse.io/tailscale or https://boards.greenhouse.io/tailscale. EU board URLs (job-boards.eu.greenhouse.io/TOKEN) work too. The token is the path part after the host. Company names and domains are not resolved. Duplicates are read once. A board that fails is reported in SUMMARY with a code and never stops the other boards.

## `titleKeywords` (type: `array`):

Keep jobs whose title contains any of these words or phrases. Case-insensitive literal text, no wildcards or regex. Empty keeps every title. Example: backend, platform engineer.

## `excludeTitleKeywords` (type: `array`):

Drop jobs whose title contains any of these words or phrases. Case-insensitive literal text. Applied after titleKeywords. Example: intern, manager.

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

Keep jobs whose location text or any office name/location contains any of these strings (case-insensitive literal). remote matches only listings whose text says remote; it does not prove where a remote hire may live. Empty keeps every location.

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

Keep jobs in a department whose name contains any of these strings (case-insensitive literal), e.g. Engineering. Department names are set by each employer. Empty keeps every department.

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

Keep jobs whose firstPublished date is within this many days of the run. Jobs without firstPublished are excluded while this is set and counted in SUMMARY. Employers can reset this date by reposting. Omit to keep all dates.

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

Add descriptionText (plain text) and descriptionHtml (decoded HTML) to each row. Off by default to keep rows small for agents; the price per job is the same either way.

## `includePayRanges` (type: `boolean`):

Read each matching job's detail page from the API to add payRanges: structured pay-transparency ranges in cents, exactly as published. Adds one request per job, spaced at least one second apart, so a 400-job board takes minutes. The pay-range event is charged only for jobs that publish at least one range.

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

Maximum job rows across all boards; closed-job notices do not count. Boards are read in input order. When the cap is reached the run stops, and SUMMARY marks the affected boards as incomplete. An incomplete board never produces closed-job notices.

## `baselineName` (type: `string`):

Turns on new/closed tracking: 1-100 letters, digits, underscores or hyphens. Rows get changeType new or existing; a job gone since the last complete read of its board gets one free closed-job notice. New means first seen under this name, not newly posted. Reuse the name with the same boards and filters; jobs excluded by earlier filters count as existing. Do not run two jobs on one name at once.

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

Requires baselineName: output only new jobs and closed-job notices; existing jobs are skipped and not charged. The first run under a new baselineName marks every job new.

## Actor input object example

```json
{
  "boards": [
    "tailscale"
  ],
  "titleKeywords": [],
  "excludeTitleKeywords": [],
  "locationKeywords": [],
  "departments": [],
  "includeDescription": false,
  "includePayRanges": false,
  "maxJobs": 500,
  "onlyChanges": false
}
```

# Actor output Schema

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

One row per open job post from the boards read, newest first per board, plus free closed_job notices when baselineName is set. Failed boards have no rows; see SUMMARY.

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

SUMMARY: outcome (complete/partial/no_results/failed), board and job counts, charged events, truncated, billingLimitReached, retries, undatedExcluded, warnings, and boards\[] with status, code, httpStatus, retryable, hint, jobsOnBoard, jobsMatched, jobsEmitted, closedEmitted, complete, stateUpdated and fetchedAt.

# 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": [
        "tailscale"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("riprap-tools/greenhouse-jobs").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": ["tailscale"] }

# Run the Actor and wait for it to finish
run = client.actor("riprap-tools/greenhouse-jobs").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": [
    "tailscale"
  ]
}' |
apify call riprap-tools/greenhouse-jobs --silent --output-dataset

```

## MCP server setup

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

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/eJKmjQaOnahr0GJIX/builds/EBEojkhXaitDb3eHZ/openapi.json
