--- name: greenhouse-jobs-scraper description: Read open roles from Greenhouse-hosted job boards as structured records via the Apify Actor arman-bd/greenhouse-jobs-scraper. Returns one record per job with title, location, office list, departments, the company's requisition ID, posting and update timestamps, a direct apply link and the full description as clean plain text. Use for job aggregation across many company boards, recruiting and hiring-signal intelligence, lead generation from hiring activity, or watching a shortlist of employers on a schedule. Not for salary data, applicant or ATS-internal information, boards on other ATS vendors, or keyword search across companies you have not named. --- # Greenhouse Jobs Scraper Apify Actor `arman-bd/greenhouse-jobs-scraper`. Give it a list of Greenhouse board tokens, get one dataset record per open role. Filtering by title, location and department happens inside the run, so a wide board can be reduced to the handful of roles you care about before anything reaches your dataset. It runs without credentials. ## When to use it - Ingest hundreds of company boards on a schedule into a job aggregator. - Watch which teams and locations a competitor is hiring into, by counting `departments` and `location` over time. - Generate leads from hiring activity: a company hiring for a role is usually buying for it. - Track a shortlist of employers and surface only the roles matching your own keywords. - Diff runs on `jobId` and `updatedAt` to catch new and edited postings. ## When not to use it - Salary or compensation as a structured field. Greenhouse's public feed has none, and the Actor does not invent one. Anything a company wrote into the body arrives inside `description` as prose. - Boards on other ATS vendors. A company that moved off Greenhouse returns a not-found. - Applicant, candidate or pipeline data. Nothing account-scoped is reachable and no credentials are accepted. - Discovery. You must already know which companies to ask about; there is no cross-company keyword search here, only filtering within the boards you name. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/greenhouse-jobs-scraper').call({ boards: ['stripe', 'figma'], searchTerms: ['engineer', 'developer'], locationFilter: ['remote'], departmentFilter: [], maxJobsPerBoard: 50, includeDescription: true, }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); const { value: summary } = await client .keyValueStore(run.defaultKeyValueStoreId) .getRecord('RUN_SUMMARY'); ``` One-shot over HTTP, when you want the rows back in the same request: ```bash curl -X POST "https://api.apify.com/v2/acts/arman-bd~greenhouse-jobs-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"boards":["stripe"],"searchTerms":["engineer"],"includeDescription":false}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/greenhouse-jobs-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `boards` | string[] | yes | | Board tokens, the company slug in a Greenhouse board URL. A full board URL works too and is reduced to its token, including the `embed/job_board?for=acme` form. Tokens are lower-cased and de-duplicated. Mixing tokens and URLs is fine. | | `searchTerms` | string[] | no | `[]` | Keep a job when its title contains at least one of these, case-insensitive substring match. Empty keeps every title. | | `locationFilter` | string[] | no | `[]` | Keep a job when its location string contains at least one of these. Matches the single location string, not the `offices` array. Empty keeps every location. | | `departmentFilter` | string[] | no | `[]` | Keep a job when any of its department names contains one of these. Departments are joined into one string before matching, so a term spanning two department names can match by accident. Empty keeps every department. | | `maxJobsPerBoard` | integer | no | `0` | Cap per board, applied **after** filtering. `0` means no cap. | | `includeDescription` | boolean | no | `true` | Attach the full description as plain text. Off makes the dataset several times smaller and the run faster; it does not reduce the number of requests. | **The description is the whole size decision.** Every board is one request whether or not you ask for descriptions, so turning `includeDescription` off saves storage and transfer, not time. Leave it on for a first pull or anywhere you need to read requirements or compensation prose; turn it off for scheduled change-detection sweeps, where `jobId` and `updatedAt` are all you compare. The three keyword filters combine with AND across filters and OR within each one: a job must match at least one term in every filter you actually populated. ## Output One record per job that survived filtering. | Field | Type | Notes | |---|---|---| | `board` | string | The normalised token that was requested. Join back to your input list on this. | | `company` | string | The company name Greenhouse reports, falling back to the token when the board does not set one. | | `jobId` | number | Greenhouse's own job ID. Stable, and the right primary key. | | `requisitionId` | string \| null | The company's internal requisition ID. Frequently null. | | `title` | string | Job title. | | `location` | string \| null | Free text as the company typed it, so `London`, `Remote (US)` and `San Francisco, CA or New York` all occur. Not a normalised geography. | | `offices` | string[] | Structured office names. Can be empty even when `location` is set, and can list several offices for a single role. | | `departments` | string[] | Every department the role is filed under. Often more than one. | | `url` | string | Direct apply link. Sometimes on the company's own domain rather than a Greenhouse one. | | `firstPublished` | string \| null | ISO 8601. First publication of the posting. | | `updatedAt` | string \| null | ISO 8601. The field to diff for edits. | | `applicationDeadline` | string \| null | Usually null; only a few boards set it. | | `description` | string | Full description as plain text, HTML stripped and entities decoded. **The key is absent entirely** when `includeDescription` is false. | | `scrapedAt` | string | Run timestamp, ISO 8601. | A real record, description trimmed: ```json { "board": "stripe", "company": "Stripe", "jobId": 8023928, "requisitionId": "REQ-1234", "title": "Account Executive, Bridge", "location": "London", "offices": ["London"], "departments": ["Sales"], "url": "https://stripe.com/jobs/search?gh_jid=8023928", "firstPublished": "2026-07-14T09:12:00Z", "updatedAt": "2026-08-01T11:03:22Z", "applicationDeadline": null, "description": "About the team\n\nBridge is a stablecoin platform …", "scrapedAt": "2026-08-06T12:00:00.000Z" } ``` ## RUN_SUMMARY Written to the run's key-value store under the key `RUN_SUMMARY`. **Read it.** It is where a partial run admits that it was partial. ```json { "boardsRequested": 3, "boardsFailed": 1, "failures": [ { "board": "notacompany", "error": "board \"notacompany\" not found (404)" } ], "jobsSaved": 214, "filters": { "searchTerms": ["engineer"], "locationFilter": ["remote"], "departmentFilter": [], "maxJobsPerBoard": 50 }, "finishedAt": "2026-08-06T12:00:07.221Z" } ``` `boardsRequested` is the de-duplicated token count, which can be lower than the length of the list you sent. `boardsFailed` plus `failures` accounts for every board that returned nothing at all. Note what is **not** here: there is no per-board job count and no per-board breakdown, so a board that succeeded but matched zero jobs is indistinguishable from one that has no open roles. Group the dataset by `board` yourself if you need that distinction. `filters` also omits `boards` and `includeDescription`, so do not read it back as a full echo of your input. ## Behaviour to plan around - **`jobsSaved` of zero with an empty `failures` means your filters excluded everything**, not that the boards are empty. Re-run with the filters cleared before concluding a company is not hiring. - **`maxJobsPerBoard` applies after filtering and per board.** Twenty boards with a cap of 50 can return 1000 rows. It also truncates silently: nothing in `RUN_SUMMARY` says the cap bit, so compare each board's row count against the cap yourself. - **`description` is an absent key, not an empty string**, when `includeDescription` is off. Check for presence rather than for a falsy value. - **`location` is free text.** Substring matching on it is the only option, and `locationFilter: ["remote"]` will also catch a role in "Remote-friendly Berlin office" while missing one marked "Distributed". Read `offices` for anything structured. - **`departmentFilter` matches against the joined department string.** With departments `["Data", "Science Ops"]` a filter term of `data science` matches, which is rarely what you meant. Prefer single-word terms. - **Descriptions are decoded twice before tags are stripped**, because some boards double-encode their HTML. You get real text rather than escaped markup, including bullet characters where the original used list items. - **One bad board never aborts the run.** An unknown token is a not-found, lands in `RUN_SUMMARY.failures`, and every other board continues. The Actor only throws when every board failed. - **Transient failures are retried** three times with linear backoff. Not-found and malformed-response errors are final for that board and are not retried. - **Boards are read one at a time**, one request each, so hundreds of boards in a single run is normal and finishes in a few minutes. ## Recipes **Aggregate a portfolio of boards.** Everything on every board, descriptions included. ```json { "boards": ["stripe", "figma", "airbnb"], "includeDescription": true } ``` Key your store on `jobId` and upsert; `board` and `company` give you the employer. **Cheap change-detection sweep.** No descriptions, no filters, run it daily. ```json { "boards": ["stripe"], "includeDescription": false } ``` Diff on `jobId` for new and removed roles, and on `updatedAt` for edits to roles you already have. **Targeted job hunt.** All three filters at once, capped. ```json { "boards": ["stripe", "figma", "notion"], "searchTerms": ["engineer", "developer"], "locationFilter": ["remote", "london"], "departmentFilter": ["engineering"], "maxJobsPerBoard": 25, "includeDescription": true } ``` If this comes back empty, drop one filter at a time: the AND across filters is usually what emptied it. **Hiring-signal census.** No description, no cap, filter to one function. ```json { "boards": ["stripe", "figma", "airbnb", "notion", "linear"], "departmentFilter": ["sales"], "includeDescription": false } ``` Count rows grouped by `company` and `location` to see who is expanding where.