Greenhouse Job Board Scraper - Company Jobs, Salary, Remote avatar

Greenhouse Job Board Scraper - Company Jobs, Salary, Remote

Pricing

from $1.05 / 1,000 job posting returneds

Go to Apify Store
Greenhouse Job Board Scraper - Company Jobs, Salary, Remote

Greenhouse Job Board Scraper - Company Jobs, Salary, Remote

Open positions from any company's Greenhouse board, read from the public Job Board API - no key, no browser. One row per posting: title, location, department path, office, published salary range, first published and updated time in UTC, apply link and optional description text.

Pricing

from $1.05 / 1,000 job posting returneds

Rating

0.0

(0)

Developer

Samat Makatov

Samat Makatov

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

18 days ago

Last modified

Share

Give this actor the board token of a company that hires through Greenhouse and get its open positions as flat rows: job title, the location text the employer typed, the department path from the company's own tree, the office, the published pay range as two numbers and a currency, when the posting first appeared and when it last changed (both in UTC), the apply link and — on request — the full description. The data comes from the public Greenhouse Job Board API, the same one a company's own career page reads. No API key, no proxy, no browser, no login.

Coverage is per company, not a search across all employers: you name the companies, the actor reads their boards. That is the point — the rows are exactly what the employer publishes, with no aggregator in between.

Use cases

  • Sourcing for one employer (recruiters, agencies): every open engineering role at one company, newest change first, with the apply link — a shortlist you can refresh every morning.
  • Hiring signals for a B2B pipeline (GTM, sales, lead gen): watch a list of accounts with postedWithinDays: 30; a company that starts hiring data engineers is a company that just bought something.
  • Pay benchmarking (compensation, people analytics): withSalaryOnly: true gives only the postings with a published band, already converted from cents into whole currency units, with the label the employer gave the band.
  • A niche job board or newsletter: a shortlist of employers plus titleKeywords and remoteOnly, refreshed on a schedule with onlyNew: true, so each run writes only what you have not published yet.
  • Feeding an agent or a search index: includeDescription: true adds the full posting as clean text and as the published HTML, ready for embeddings, summarisation or keyword search.
  • Org research (analysts, competitor watching): departments filters on the company's real department tree, so "everything under Sales" is a filter, not a guess from job titles.

Input

Only boardTokens is required. Every filter is optional; filters are combined with AND, and the values inside one filter with OR.

FieldTypeDefaultAllowed values / notes
boardTokensstring[]requiredOne or more board tokens, e.g. ["stripe", "duolingo"]. A full board URL works too — see Finding the board token. An unknown token, and an entry that is not a token at all, comes back as one row with found: false; it does not fail the run.
titleKeywordsstring[]all titlesKeep a posting when its title contains at least one of these, case-insensitive: ["engineer", "data"].
excludeTitleKeywordsstring[]noneDrop a posting when its title contains any of these: ["intern", "contract"]. Applied after titleKeywords.
locationContainsstring[]all locationsMatch the free-text location: ["London", "India"]. See Locations are free text.
departmentsstring[]all departmentsMatch at any level of the company's department tree: ["Sales"] also returns "Sales > Enterprise Sales". Typos are corrected against the board's own names — see Departments and offices.
officesstring[]all officesMatch the office a posting hangs under: ["Dublin", "Remote"]. Switches on the large response (slower, see Two responses).
remoteOnlybooleanfalseKeep only postings whose location or office text reads remote. This is a text verdict, not a source flag — see isRemote is our verdict.
withSalaryOnlybooleanfalseKeep only postings that publish a pay range.
postedWithinDaysintegerno limit1–365. Keep postings first published in the last N days (UTC).
sinceHoursintegerno limit1–8760. Keep postings changed in the last N hours (UTC), edits and re-posts included.
onlyNewbooleanfalseEmit only postings not delivered before (per saved task) — see onlyNew.
includeDescriptionbooleanfalseAdd descriptionHtml and descriptionText. Switches on the large response.
sortBystringupdatedDescupdatedDesc, publishedDesc, titleAsc, boardOrder — see Sort orders.
maxItemsinteger251–5000. Total rows written, across all companies.
maxItemsPerBoardintegerno cap1–2000. Rows taken from each company before the global cap, so one big employer cannot fill the dataset.
fieldsstring[]all fieldsKeep only these output fields, in this order. boardToken and found are always kept, first unless you list them. Names are checked — see fields.

Reference

Finding the board token

The token is the last part of the board address a company links to from its careers page:

What you haveToken
https://job-boards.greenhouse.io/stripestripe
https://boards.greenhouse.io/databricks/jobs/8439170002databricks
https://boards.greenhouse.io/embed/job_board?for=cloudflarecloudflare
https://boards-api.greenhouse.io/v1/boards/gitlab/jobsgitlab

Paste any of those addresses into boardTokens and the token is taken from them. The token is not always the company name (stripe works, plenty of others differ), and a link with ?gh_jid=… on a company's own domain does not contain it — open the board itself and copy the address. Tokens are matched case-insensitively and written back in lower case.

A token nobody publishes ends as one row with found: false and an error that says so; the other companies in the same run are unaffected, and a run whose only tokens are unknown still succeeds with those rows. An entry that is not a token at all (a company careers page, a ?gh_jid= apply link, two words) is not sent anywhere: it also becomes one found: false row (errorCode: "invalid-token") and the valid entries run. Only when no entry is a token does the run stop at once, with a message that names them.

When a board gives nothing

Every board that yields no postings gets one found: false row; errorCode says why, error says it in words:

errorCodeMeaning
board-not-foundThe API answered 404: nobody publishes a board under this token.
board-emptyThe board exists and has no open positions right now (company is filled when the board names it).
board-unreachableThe API gave no usable answer after retries (timeout, server errors, throttling, a refusal).
invalid-tokenThe boardTokens entry carries no board token; boardToken holds the entry as typed and sourceUrl is null.

When the API answers none of the boards a run tries, the run fails with that message and writes nothing, so it costs only the start. After 3 boards in a row without any answer the API is taken as down: the boards after them are not tried, and the status message and SUMMARY.boardsNotRead name them.

Sort orders

ValueOrder
updatedDescLast change first (updatedAt) — the default, best for watching a board.
publishedDescNewest posting first (firstPublishedAt) — best for hiring signals.
titleAscJob title A→Z.
boardOrderUntouched, exactly as the company's board returns them.

With several companies the rows are sorted across all of them, after maxItemsPerBoard has been applied per company.

Departments and offices

There is no global list: every company builds its own tree ("Sales > Enterprise Sales", "Engineering > Platform"). To see what a company uses, run it once without the filter and read departmentPath and offices. The filter matches at any level, so ["Sales"] returns the postings of every child department too.

A filter value no department on that board contains is compared with the board's own names: an obvious typo is corrected, logged and named in the status message (departments: "Enginering" read as "Engineering"), and a value nothing resembles is kept as typed and reported in the log and in the run's SUMMARY. A filter never silently widens into the whole board.

Two responses

The actor makes two requests per company: the board's postings and the department tree. When you ask for includeDescription, an offices filter or office columns in fields, the first request switches to the variant that carries the full posting text — for a board with a few thousand openings that is a multi-megabyte answer, so those runs take a few seconds longer. Nothing else changes the number of requests: one company is one pass, the API returns the whole board at once and there is no pagination to walk.

Locations are free text

location is a string the employer typed into Greenhouse, not a structured place. Real examples from one day: Dublin, Seattle, San Francisco, New York City, Remote - California, Bengaluru, India; Mumbai, India, US-SF-HQ, US-NYC, US-CHI, US-SEA, and at one company simply Hybrid. Match on a city, state or country word rather than on an exact string, and expect a posting to name several places at once.

isRemote is our verdict

Greenhouse has no remote flag. isRemote is true when the location or an office name contains wording such as remote, distributed, anywhere, work from home or virtual, and remoteOnly uses the same test. A posting an employer describes only as "Hybrid" is therefore not remote here, and a company that hides remote work in the description text will not be caught.

onlyNew across scheduled runs

With onlyNew: true the actor remembers company, job id and updatedAt of every row it wrote, in a named key-value store of your account, and skips those rows next time. An edited posting counts as new again, because its updatedAt moved. The first run returns everything it finds; rows cut off by maxItems, by your spending limit or by the run timeout are not remembered, so the next run picks them up.

Each saved task keeps its own memory (store greenhouse-jobs-state-<task id>), so two schedules that watch the same company with different filters no longer hide postings from each other. A task's first run starts from the memory that earlier runs of the actor left in the shared store greenhouse-jobs-state, so nothing you already received comes again. Runs started without a task (Console, API) share that store, one record per company. A found: false row is delivered once and then remembered too — except board-unreachable, which every run reports again.

fields

fields keeps only the columns you list, in your order; boardToken and found are always kept. A name in another letter case or one or two letters off is corrected (titel → title), a name that is not a column is left out, and both are named in the status message and SUMMARY.corrections. If none of the names is a column, the run stops with the list of valid names instead of writing rows that hold nothing you asked for. Asking for descriptionText or descriptionHtml switches includeDescription on; asking for office or offices switches on the large response that carries them.

Spending limit and run timeout

The run never costs more than your Maximum cost per run: when it is reached the run stops writing and ends with "Stopped at your spending limit: N rows delivered". Before the run timeout it stops starting new boards, writes what it has read and ends with "Stopped before the run timeout: N rows saved"; the boards it did not get to are named in the status. Measured on 2026-10-02 at the default 256 MB: a board reads in well under a second without descriptions and in 1–15 s with them, and rows are written at about 40 a second with descriptions — so the default 900 s covers hundreds of companies, and the run keeps time to write what it has read.

Examples

Every open engineering role at one employer

{ "boardTokens": ["stripe"], "titleKeywords": ["engineer"], "sortBy": "updatedDesc", "maxItems": 20 }

Remote openings across a shortlist, fairly shared between companies

{ "boardTokens": ["duolingo", "gitlab", "databricks"], "remoteOnly": true, "maxItemsPerBoard": 10, "sortBy": "publishedDesc", "maxItems": 25 }

Only the postings with a published pay range

{ "boardTokens": ["gitlab"], "withSalaryOnly": true, "sortBy": "updatedDesc", "maxItems": 20 }

Hiring signals: what these accounts started advertising in the last 30 days

{ "boardTokens": ["stripe", "duolingo", "databricks", "cloudflare"], "postedWithinDays": 30, "maxItemsPerBoard": 10, "sortBy": "publishedDesc", "maxItems": 30 }

Everything under Sales, using the company's own department tree

{ "boardTokens": ["databricks"], "departments": ["Sales"], "sortBy": "updatedDesc", "maxItems": 20 }

Full posting text for a search index or an agent, few columns

{ "boardTokens": ["duolingo"], "includeDescription": true, "fields": ["company", "title", "url", "descriptionText"], "maxItems": 10 }

Output

One row per posting — a posting the board lists twice (it happens) comes once. A real row, written by the run uDIlCS5kneZZpDgUs on 2026-09-20 with {"boardTokens": ["gitlab"], "withSalaryOnly": true, "maxItems": 20}:

{
"boardToken": "gitlab",
"company": "GitLab",
"jobId": 8746461002,
"title": "Senior Professional Services Engineer- PubSec - DC Metro",
"url": "https://job-boards.greenhouse.io/gitlab/jobs/8746461002",
"location": "Remote, United States",
"isRemote": true,
"departments": ["Sales", "Consulting Delivery"],
"department": "Consulting Delivery",
"departmentPath": "Sales > Consulting Delivery",
"offices": [],
"office": null,
"salaryMin": 136080,
"salaryMax": 230040,
"salaryCurrency": "USD",
"salaryRangeTitle": "United States Salary Range",
"hasSalary": true,
"firstPublishedAt": "2026-09-02T18:53:10.000Z",
"updatedAt": "2026-09-18T20:46:24.000Z",
"applicationDeadline": null,
"requisitionId": "6881",
"internalJobId": 6497900002,
"language": "en",
"found": true,
"error": null,
"errorCode": null,
"sourceUrl": "https://boards-api.greenhouse.io/v1/boards/gitlab/jobs?pay_transparency=true",
"fetchedAt": "2026-09-20T19:23:38.640Z"
}
FieldTypeAlways filledMeaning
boardTokenstringyesThe company's board token, in lower case (for an invalid-token row: the entry as typed)
companystringfor found: trueCompany name as the board publishes it
jobIdnumberfor found: trueGreenhouse posting id (the gh_jid in apply links)
titlestringfor found: trueJob title
urlstringfor found: trueWhere the posting is published — often the company's own careers site
locationstringalmost alwaysFree text the employer typed (see Locations are free text)
isRemotebooleanyesOur text verdict, not a source flag
departmentsstring[]when the tree is readableDepartment names from root to leaf
departmentstringwhen the tree is readableThe leaf department
departmentPathstringwhen the tree is readabledepartments joined with >
officesstring[]only in the large responseOffices the posting hangs under, all of them
officestringonly in the large responseThe first office of offices
salaryMinnumberwhen publishedLower end of the pay range, whole currency units (the API sends cents)
salaryMaxnumberwhen publishedUpper end of the pay range
salaryCurrencystringwhen publishedISO currency code, e.g. USD, GBP
salaryRangeTitlestringwhen publishedThe employer's label for the band, e.g. "United States Salary Range", "Zone 3 Pay Range"
hasSalarybooleanyesWhether a pay range was published for this posting
firstPublishedAtstringfor found: trueWhen the posting first appeared, ISO 8601 UTC
updatedAtstringfor found: trueWhen it last changed, ISO 8601 UTC
applicationDeadlinestringrarelyDeadline the employer set, ISO 8601 UTC
requisitionIdstringusuallyThe employer's own requisition number
internalJobIdnumberfor found: trueGreenhouse's internal id of the job behind the posting
languagestringusuallyLanguage code of the posting
descriptionHtmlstringwith includeDescriptionThe description as published, HTML
descriptionTextstringwith includeDescriptionThe same text with entities decoded and tags stripped
foundbooleanyesfalse marks a board token that returned nothing
errorstringon found: falseWhy that token gave no postings
errorCodestringon found: falseboard-not-found, board-empty, board-unreachable or invalid-token — see When a board gives nothing
sourceUrlstringexcept invalid-tokenThe exact API URL this row came from
fetchedAtstringyesWhen the run read the board, ISO 8601 UTC

A board token the API does not serve produces one row like this instead of failing the run:

{
"boardToken": "zzz-no-such-board-xyz",
"found": false,
"error": "no Greenhouse board is served for the token \"zzz-no-such-board-xyz\" (the API answered 404). Check it against the company's board URL job-boards.greenhouse.io/<token> — the token is often not the company name.",
"errorCode": "board-not-found",
"title": null,
"hasSalary": false,
"sourceUrl": "https://boards-api.greenhouse.io/v1/boards/zzz-no-such-board-xyz/jobs?pay_transparency=true",
"fetchedAt": "2026-09-20T19:23:52.437Z"
}

Dataset views: Open positions (the everyday columns, plus found and error), Published pay ranges (only the money columns), Recently published (first published, department, location — the hiring-signal view). A SUMMARY record in the run's key-value store holds what each company returned, which filters and field names were applied or corrected, which boards were not read, and what was cut.

Use it from code / agents

curl -X POST "https://api.apify.com/v2/acts/yadroo~greenhouse-jobs/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"boardTokens":["stripe"],"titleKeywords":["engineer"],"fields":["company","title","location","url"],"maxItems":20}'
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('yadroo/greenhouse-jobs').call({ boardTokens: ['gitlab'], withSalaryOnly: true, maxItems: 20 });
const { items } = await client.dataset(run.defaultDatasetId).listItems();
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("yadroo/greenhouse-jobs").call(run_input={"boardTokens": ["databricks"], "departments": ["Sales"], "maxItems": 20})
items = client.dataset(run["defaultDatasetId"]).list_items().items

The dataset can also be downloaded as JSON, CSV, XLSX or XML from the run's dataset URL, and fields keeps the export to the columns you need.

MCP: add https://mcp.apify.com to Claude / Cursor / any MCP client and call the yadroo/greenhouse-jobs tool with the same JSON input.

Pricing

Pay per event: $0.001 per run start + $0.0015 per dataset row. A row is one posting — or the found: false row of a board token that returned nothing or an entry that is not a token. Nothing else is charged; maxItems and your maximum cost per run are hard ceilings on what a run can cost. A run that fails because the API answered none of its boards writes no row and costs only the start.

RunCost
25 rows (the default)$0.0385
200 rows$0.301
1000 rows$1.501

Bronze −10 %, Silver −20 %, Gold and above −30 % on the row price; the start event is the same on every plan; platform usage is included.

Limits & FAQ

Which companies can I read? Only those that publish through Greenhouse and leave the board's public API on — which is the normal setup, because the company's own careers page reads the same API. There is no search across employers here: you bring the board tokens.

How fresh is it? The API answers live, so a run sees the board as it is at that moment. Closed postings disappear from the board, and this actor keeps no history — if you need one, run it on a schedule and keep the datasets, or use onlyNew.

Why does a posting have no pay range? Because the employer did not publish one. Ranges come from the pay transparency data an employer fills in, mostly under US, EU or UK disclosure rules; whole boards can be without them (on 2026-09-20, GitLab published a range on 86 of 216 postings, Stripe on none of 665). hasSalary tells you which case you are in. When an employer publishes several bands for one posting, the row carries the first one and salaryRangeTitle names it.

Why is office empty? Offices only come with the large response, so a run without includeDescription, without an offices filter and without office/offices in fields leaves offices/office empty. departmentPath does not depend on that: it is filled whenever the board serves its department tree, which is read with a request of its own (if that request fails, the postings still come back and the log says the tree was missing).

Rate limits. Greenhouse documents none for this API. The actor stays polite anyway: it reads the companies one after another, two requests each, and retries with a growing delay on 429 and 5xx. If a board keeps refusing, that company ends as a found: false row with the reason and the run continues; after 3 such boards in a row the rest is not tried in that run.

Empty result, no error? That means the filters matched nothing on boards that were read fine — the log and the SUMMARY record say how many postings each company had and how many the filters cut (with onlyNew, also how many were delivered before). Invalid input is different: an entry that is not a board token, like a token the API does not serve, becomes a found: false row, and a run in which no entry is a token stops with a message naming them.

What is not collected. No candidate, recruiter or applicant data — the Job Board API serves company postings only, and this actor reads nothing else. It never touches the /embed/ paths that the source's robots.txt disallows, uses no login, no captcha solving and no proxy.

If the source changes. Should Greenhouse restrict or retire the public Job Board API, this actor will be marked paused here rather than worked around.


Made by Yadroo. Sibling actors: hh-kz-vacancies · github-repo-intel · domain-intel · google-news-search · rss-to-json