# Greenhouse Jobs Scraper: Whole Boards, Descriptions Included (`firstpartydata/greenhouse-jobs-scraper`) Actor

Scrapes job postings from any Greenhouse job board through Greenhouse's public API. One request returns an entire board with full descriptions, departments and real posted dates. Incremental mode ignores Greenhouse's bulk updated\_at restamps, so you are not re-billed for rows that did not change.

- **URL**: https://apify.com/firstpartydata/greenhouse-jobs-scraper.md
- **Developed by:** [First Party Data](https://apify.com/firstpartydata) (community)
- **Categories:** Jobs, Lead generation, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 1 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.10 / 1,000 job posting returneds

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

## Greenhouse Jobs Scraper: Whole Boards, Descriptions Included

Job postings from any Greenhouse board (`job-boards.greenhouse.io/...`) through
Greenhouse's own public API. No login, no proxy, no browser.

| | |
|---|---|
| You give it | Greenhouse board URLs, company names like `stripe`, or nothing: switch on **Search the built-in company list** and filter 5,011 live Greenhouse boards |
| You get back | one row per posting: title, company, department, office, the full description, the date it was first published and the employer's own apply link |
| It costs | $3.50 per 1,000 rows on Apify's Free and Starter plans, $2.80 on Scale, $2.10 on Business and above, plus $0.01 per run. Filters are applied before you are charged, and a run that finds nothing costs nothing |
| The form as it opens | reads Stripe and Anduril Industries, 10 postings each: 20 rows, 8 cents on the Free plan, under 10 seconds on Apify. For more, raise **Maximum jobs from any one job board** and **Maximum jobs to return** |

Greenhouse publishes no pay field, no remote field and no country field. What
that means for each filter is set out under "What Greenhouse does not publish"
below, before you rely on one.

Greenhouse is among the cheapest applicant tracking systems to read, and the
easiest one to build a broken feed on. Both come from the same place, and both
are measured below rather than asserted.

***

### One request returns the whole board, descriptions and all

Four of the thirteen systems this project reads publish a description only on a
per-posting detail endpoint: Workday, SmartRecruiters, BambooHR and Rippling. On
Workday that is one extra request for every posting on the board. Greenhouse is
not one of those four. It hands back the entire board in a single response, and
`?content=true` brings the description along at no extra request.

Measured across **150 boards sampled across the size range and 9,323 live
postings** (15 September 2026):

| | |
|---|---:|
| Boards returned complete in one request | **150 of 150** |
| Postings carrying a real description | **100%** |
| Postings carrying a department | **99.8%** |
| Postings carrying an office | **99.1%** |
| Postings carrying a real first-published date | **99.9%** |

That is why this actor leaves `includeDescriptions` on by default here, where the
combined listing warns about the cost. On Greenhouse the description is free.

What that means in practice, measured on Apify against the largest board in the
bundled list: **4,980 postings with full descriptions, in 6.6 seconds.** One
request. The same 4,980 postings on a system that publishes descriptions only on
a detail endpoint would be 4,981.

***

### `updated_at` is not a change signal, and a feed built on it bills you twice

This is the thing worth knowing before you build anything on Greenhouse.

Every posting carries `updated_at`, and it is the obvious field to hash if you
want to deliver only what changed since yesterday. On Greenhouse it moves across
a whole board at once, without the postings changing. An employer runs a bulk
edit, or Greenhouse touches the board, and every row is restamped within the same
hour.

Of the **47 sampled boards carrying 20 or more postings, 7 had one hour covering
90% or more of their postings**: 14.9%. Some examples from that sample:

```
4,980 postings all stamped 2026-09-01 14:00   Svetness Personal Training
  241 postings all stamped 2026-09-14 15:00   Wolt
  116 postings all stamped 2026-09-10 17:00   Fin
   75 postings all stamped 2026-09-11 13:00   Theoria Medical
```

A normal board looks nothing like that: the median board in the sample spreads
its postings across **18 distinct update hours**.

**Read the board count, not the posting count.** Those 7 boards carry 64.5% of
the sampled postings, and that figure is worthless: 4,980 of the 5,539 are one
franchise board. The honest number is the board-weighted one, 14.9%.

So `onlyNewOrChanged` here deliberately **does not watch `updated_at`.** It
fingerprints title, location, pay, work arrangement, department, the *posted*
date and description length. A real edit still shows up, because a real edit
moves one of those. A bulk restamp does not, so you are not charged for 4,980
rows that are identical to yesterday's.

Set **Maximum jobs from any one job board** to 0 for a feed. The form opens at
10 per company to keep a first look cheap, and a limit left in place caps what
every run can deliver from each company.

***

### Coverage, published so you can check it before paying

This actor ships **5,011 verified live Greenhouse boards carrying
178,005 open postings**, and **4,988 of them carry the employer's own
display name**. Greenhouse puts `company_name` on every posting, so the coverage
list is a list of companies rather than a list of slugs.

**The ten largest Greenhouse boards in the bundled list**

1. **Svetness Personal Training**: 4,980 roles
2. **EōS Fitness**: 2,704 roles
3. **Pulse Healthcare**: 2,670 roles
4. **BAYADA Home Health Care**: 2,573 roles
5. **SpaceX**: 2,444 roles
6. **Anduril Industries**: 2,284 roles
7. **Carvana**: 1,743 roles
8. **Centria Autism**: 1,227 roles
9. **Upstream Rehabilitation**: 1,210 roles
10. **Private Equity Insights**: 1,152 roles

Every board was verified live at build time (2026-09-24): it answered Greenhouse's
API with at least one open posting. Dead and private boards are not shipped.

***

### Input

Paste board URLs, or just name the company:

```
https://job-boards.greenhouse.io/stripe
https://boards.greenhouse.io/anduril
stripe
```

The board identifier is the last path segment, and both the old
`boards.greenhouse.io` and the current `job-boards.greenhouse.io` forms are
accepted. `companyToken` always holds the exact, unmodified identifier.

Or switch on **Search the built-in company list** to read the bundled boards,
largest employers first.

***

### What a row looks like

```json
{
  "id": "greenhouse:stripe:8172487",
  "sourceId": "8172487",
  "requisitionId": "See Opening ID",
  "title": "Abuse Investigator",
  "company": "Stripe",
  "companyToken": "stripe",
  "department": "8611 Security Analytics",
  "departments": ["8611 Security Analytics"],
  "locationRaw": "Dublin",
  "locations": ["Dublin", "Ireland Locations"],
  "country": null,
  "countryInferred": false,
  "workplaceType": null,
  "compensation": null,
  "postedAt": "2026-09-03T17:30:34.000Z",
  "updatedAt": "2026-09-12T17:25:25.000Z",
  "descriptionText": "Who we are\n\nAbout Stripe\n\nStripe is a financial …",
  "applyUrl": "https://stripe.com/jobs/search?gh_jid=8172487",
  "boardUrl": "https://job-boards.greenhouse.io/stripe",
  "ats": "greenhouse"
}
```

Note `postedAt` and `updatedAt` on that row: **nine days apart**. `postedAt`
comes from Greenhouse's `first_published` and is the one you want. The `null`s
are not missing data, they are Greenhouse. See below.

***

### What Greenhouse does not publish, stated plainly

The four fields below are empty on Greenhouse rows because **Greenhouse has no
field for them**, not because this actor failed to read one. Nothing here guesses:
a wrong value behind a paid filter is worse than an empty one.

- **No structured pay range, anywhere.** Greenhouse returned one on **0 of the
  9,323 postings** sampled. What this actor does instead is read a range out of
  the description prose when an employer wrote one there: **26.4% and 29.3% of
  rows across two samples** (4,000 rows from 99 companies, and 6,000 from 399).
  That only works with `includeDescriptions` on. With descriptions off it is
  **zero**, so `withSalaryOnly` and descriptions-off cannot be combined here.
- **No remote or hybrid field**, so **`workplaceTypes` matches nothing**: 0.0% on
  both samples. Greenhouse never says whether a role is Remote, Hybrid or
  OnSite. **`remoteOnly` does work**, because it reads a different field:
  `isRemote` is derived from the location, the title and the description, and
  comes out **true on 10.3%** of rows. Use that, not `workplaceTypes`. The two
  are separate on purpose, and this is the one place on Greenhouse where this
  actor reads something rather than passing through a null: a remote flag is
  cheap to be wrong about, a country behind a paid filter is not.
- **No country field.** Location is prose: `"Dublin"`, `"San Francisco"`,
  `"Remote - US"`. `inferCountry` resolves **80.9% and 77.6%** of it across those
  same two samples and marks every such row `countryInferred: true`. A country
  filter therefore misses about a fifth of Greenhouse rows, and that is a floor
  on what any Greenhouse product can do rather than a limitation of this one.
- **No employment type.** Full-time against contract is not in the API. 0.0% on
  both samples.

**Both samples cap each company at 15 or 40 rows on purpose**, and the two
figures are given rather than averaged. A single large employer will otherwise
decide a Greenhouse percentage on its own: two earlier uncapped runs measured
country coverage at 77% and at **10%**, and the second was 92% one gym chain
whose locations are internal store codes. Any single Greenhouse percentage you
read anywhere, including here, is a property of its sample.

***

### Honest limits

- **Greenhouse sits behind a CloudFront WAF.** It will `403` a client it decides
  is a crawler, and it blocked this project's own IP for about twenty minutes
  during corpus building. A `403` is reported in its own bucket as
  `boardsForbidden`, never as "board not found", because a blocked source and a
  closed board need opposite responses. Keep `concurrency` at its default unless
  you have a reason.
- **`department` is whatever the employer typed.** Stripe files a role under
  `8611 Security Analytics`. There is no shared taxonomy across boards and this
  actor does not invent one.
- **A board can be large.** The biggest in the bundled list carries 4,980
  postings and arrives in one response. Set `maxItems` if you are sampling.
- **Boards disappear.** Companies switch ATS constantly. Those return 404, are
  counted in `RUN_SUMMARY.boardsNotFound`, and do not fail the run.
- **This reads what Greenhouse publishes and nothing else.** Public board data
  only. Nothing authenticates, and no candidate data is touched.

***

### Billing

Per job row returned, plus a small fee per run that delivers something. Rows
excluded by a filter are never charged, and in incremental mode unchanged
postings are not charged either, which on this platform is the whole point of the `updated_at`
section above.

***

Greenhouse is a trademark of Greenhouse Software, Inc. This actor is not
affiliated with or endorsed by them. It reads the public job-board API that
Greenhouse provides so that an employer's own postings can be republished.

# Actor input Schema

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

One or more Greenhouse job boards. A full URL such as https://job-boards.greenhouse.io/stripe works, and so does the older boards.greenhouse.io form, the bare board identifier (stripe), or the company domain (stripe.com). The identifier is the last path segment and is kept exactly as you typed it in companyToken. Leave this empty and switch on the built-in list below to search every bundled board instead.

## `useBuiltInCorpus` (type: `boolean`):

Read the bundled list of verified live Greenhouse boards instead of, or as well as, the companies above. Ordered largest employer first. Greenhouse returns a whole board in one request, so reading many boards costs one request each rather than one per posting.

## `corpusSample` (type: `integer`):

How many bundled Greenhouse boards to read, largest first. Boards are size-ordered, so a few hundred already covers most of the postings: 250 covers about 50% of them, 500 about 63%, 1000 about 78% and 2000 about 90%. 0 means every bundled board.

## `withSalaryOnly` (type: `boolean`):

Keep only postings with a real compensation range, meaning one with numbers in it. Greenhouse publishes no structured pay field at all: it returned one on 0 of 9,323 sampled postings. This actor reads a range out of the description prose instead, which reaches 26% to 29% of rows across two samples, and only works with Return descriptions switched on. With descriptions off this filter matches nothing.

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

Keep only postings this actor reads as remote. Greenhouse has no remote or hybrid field, so this is derived from the location, the title and the description, and comes out true on about 10% of rows. It is a judgement rather than something Greenhouse stated, which is why it is a separate switch from the exact work-arrangement filter the other Actors in this family offer.

## `titleInclude` (type: `array`):

Keep only postings whose title contains one of these. Matched case-insensitively against the title alone, so it is narrower than Keywords and better for a role type: "engineer" here will not pull in every posting that mentions engineering in its description.

## `titleExclude` (type: `array`):

Drop postings whose title contains any of these. Applied after Job title must contain, and useful for stripping the senior or intern ends of a search.

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

Keep only postings containing one of these anywhere in the title or description. Greenhouse returns the full description in the same request as the board, so this costs nothing extra here, unlike on systems that charge a request per posting for it. Needs Return descriptions on.

## `keywordsMatchAll` (type: `boolean`):

Require every keyword rather than any one of them.

## `location` (type: `array`):

Keep only postings whose location text contains one of these. Greenhouse publishes location as prose that the employer typed, so this is a string match rather than a structured lookup: "Dublin", "San Francisco", "Remote - US". A posting can carry several locations and all of them are matched.

## `countries` (type: `array`):

Keep only postings in these countries, by ISO code (DE) or name (Germany). Greenhouse publishes no country field, so this reads one off the location prose and resolves 78% to 81% of rows across two samples. Every row it resolves is marked countryInferred. Roughly a fifth of Greenhouse postings cannot be placed at all and will never match a country filter.

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

Keep only postings first published in this many days. 0 disables it. Greenhouse publishes a real first-published timestamp on 99.9% of postings, so this is exact here rather than rounded to the day. Note it reads the published date, never the updated date, for the reason in the listing.

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

Return the description as both HTML and plain text. On Greenhouse this is free: the description arrives in the same single request as the rest of the board, so turning it off saves payload size but no time. Leave it on unless you only need to know who is hiring for what. Keywords and the pay filter both need it.

## `onlyNewOrChanged` (type: `boolean`):

Return only postings that are new or have changed since the last run with the same state key. The first run has no history and returns everything. On Greenhouse this matters more than elsewhere: the change fingerprint deliberately ignores the vendor updated\_at field, which whole boards restamp at once without anything actually changing.

## `stateKey` (type: `string`):

Names the saved state that Only new or changed compares against. Leave it empty and one is derived from your input, so two different searches keep separate histories. Set it explicitly to share a history across runs whose input you expect to edit.

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

Hard ceiling on results, so an over-broad search cannot produce a surprise bill. The run stops as soon as it is reached. 0 means no limit, which on the whole bundled list is well over 100,000 postings.

## `maxPerCompany` (type: `integer`):

Stops a single large board filling the whole result. It matters here: Greenhouse hands back an entire board in one response, so without a limit the first board to answer can take the whole ceiling by itself, and the largest bundled board carries 4,980 postings. The form opens at 10, so every company you name shows up in a first run, and a sample of the built-in list reaches at least 30 boards. A number set here always applies, including to companies you name. Set 0 and a capped run over the built-in list spreads itself across at least 40 boards, while named companies get no limit. Counted per board, not per employer.

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

How many Greenhouse boards to read at once. 20 is the figure two 400-board runs cleared with zero refusals, not a guess. Greenhouse sits behind a CloudFront WAF that will refuse a client it decides is a crawler, and it blocked this project once for about twenty minutes during corpus building. A refusal is reported separately from a missing board rather than confused with one, because a blocked source and a closed board need opposite responses. Raise it only if you have a reason.

## `atsPlatforms` (type: `array`):

Fixed to Greenhouse for this Actor.

## Actor input object example

```json
{
  "companies": [
    "https://job-boards.greenhouse.io/stripe",
    "andurilindustries"
  ],
  "useBuiltInCorpus": false,
  "corpusSample": 300,
  "withSalaryOnly": false,
  "remoteOnly": false,
  "keywordsMatchAll": false,
  "postedWithinDays": 0,
  "includeDescriptions": true,
  "onlyNewOrChanged": false,
  "stateKey": "",
  "maxItems": 300,
  "maxPerCompany": 10,
  "concurrency": 20,
  "atsPlatforms": [
    "greenhouse"
  ]
}
```

# Actor output Schema

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

One item per posting, in a single schema across all supported applicant tracking systems.

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

Counts of boards read, not found, rate-limited, forbidden and read with nothing open, postings scanned, the filters applied, and any diagnostics explaining an empty result.

## `removedSinceLastRun` (type: `string`):

Written on every incremental run, empty array included: the ids your previous run returned that this one did not. Usually the employer took the posting down, but a posting that ages past postedWithinDays or is retitled out of a keyword match leaves the same way. Boards this run could not read are excluded, so a vendor outage does not empty your feed on paper.

# 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/stripe",
        "andurilindustries"
    ],
    "maxItems": 300,
    "maxPerCompany": 10
};

// Run the Actor and wait for it to finish
const run = await client.actor("firstpartydata/greenhouse-jobs-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": [
        "https://job-boards.greenhouse.io/stripe",
        "andurilindustries",
    ],
    "maxItems": 300,
    "maxPerCompany": 10,
}

# Run the Actor and wait for it to finish
run = client.actor("firstpartydata/greenhouse-jobs-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": [
    "https://job-boards.greenhouse.io/stripe",
    "andurilindustries"
  ],
  "maxItems": 300,
  "maxPerCompany": 10
}' |
apify call firstpartydata/greenhouse-jobs-scraper --silent --output-dataset

```

## MCP server setup

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