Greenhouse Job Board Scraper - Company Jobs, Salary, Remote
Pricing
from $1.05 / 1,000 job posting returneds
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
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
a day ago
Last modified
Categories
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: truegives 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
titleKeywordsandremoteOnly, refreshed on a schedule withonlyNew: true, so each run writes only what you have not published yet. - Feeding an agent or a search index:
includeDescription: trueadds the full posting as clean text and as the published HTML, ready for embeddings, summarisation or keyword search. - Org research (analysts, competitor watching):
departmentsfilters 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.
| Field | Type | Default | Allowed values / notes |
|---|---|---|---|
boardTokens | string[] | required | One or more board tokens, e.g. ["stripe", "duolingo"]. A full board URL works too — see Finding the board token. An unknown token comes back as one row with found: false, it does not fail the run. |
titleKeywords | string[] | all titles | Keep a posting when its title contains at least one of these, case-insensitive: ["engineer", "data"]. |
excludeTitleKeywords | string[] | none | Drop a posting when its title contains any of these: ["intern", "contract"]. Applied after titleKeywords. |
locationContains | string[] | all locations | Match the free-text location: ["London", "India"]. See Locations are free text. |
departments | string[] | all departments | Match 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. |
offices | string[] | all offices | Match the office a posting hangs under: ["Dublin", "Remote"]. Switches on the large response (slower, see Two responses). |
remoteOnly | boolean | false | Keep only postings whose location or office text reads remote. This is a text verdict, not a source flag — see isRemote is our verdict. |
withSalaryOnly | boolean | false | Keep only postings that publish a pay range. |
postedWithinDays | integer | no limit | 1–365. Keep postings first published in the last N days (UTC). |
sinceHours | integer | no limit | 1–8760. Keep postings changed in the last N hours (UTC), edits and re-posts included. |
onlyNew | boolean | false | Emit only postings this actor has not delivered before — see onlyNew. |
includeDescription | boolean | false | Add descriptionHtml and descriptionText. Switches on the large response. |
sortBy | string | updatedDesc | updatedDesc, publishedDesc, titleAsc, boardOrder — see Sort orders. |
maxItems | integer | 25 | 1–5000. Total rows written, across all companies. |
maxItemsPerBoard | integer | no cap | 1–2000. Rows taken from each company before the global cap, so one big employer cannot fill the dataset. |
fields | string[] | all fields | Keep only these output fields, in this order. boardToken and found are always kept. |
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 have | Token |
|---|---|
https://job-boards.greenhouse.io/stripe | stripe |
https://boards.greenhouse.io/databricks/jobs/8439170002 | databricks |
https://boards.greenhouse.io/embed/job_board?for=cloudflare | cloudflare |
https://boards-api.greenhouse.io/v1/boards/gitlab/jobs | gitlab |
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.
Sort orders
| Value | Order |
|---|---|
updatedDesc | Last change first (updatedAt) — the default, best for watching a board. |
publishedDesc | Newest posting first (firstPublishedAt) — best for hiring signals. |
titleAsc | Job title A→Z. |
boardOrder | Untouched, 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 and logged (filter value "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 or an offices filter, 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 its own named
key-value store, 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 are not remembered, so the next run picks
them up.
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 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,"sourceUrl": "https://boards-api.greenhouse.io/v1/boards/gitlab/jobs?pay_transparency=true","fetchedAt": "2026-09-20T19:23:38.640Z"}
| Field | Type | Always filled | Meaning |
|---|---|---|---|
boardToken | string | yes | The company's board token, in lower case |
company | string | for found: true | Company name as the board publishes it |
jobId | number | for found: true | Greenhouse posting id (the gh_jid in apply links) |
title | string | for found: true | Job title |
url | string | for found: true | Where the posting is published — often the company's own careers site |
location | string | almost always | Free text the employer typed (see Locations are free text) |
isRemote | boolean | yes | Our text verdict, not a source flag |
departments | string[] | when the tree is readable | Department names from root to leaf |
department | string | when the tree is readable | The leaf department |
departmentPath | string | when the tree is readable | departments joined with > |
offices | string[] | only in the large response | Offices the posting hangs under, all of them |
office | string | only in the large response | The first office of offices |
salaryMin | number | when published | Lower end of the pay range, whole currency units (the API sends cents) |
salaryMax | number | when published | Upper end of the pay range |
salaryCurrency | string | when published | ISO currency code, e.g. USD, GBP |
salaryRangeTitle | string | when published | The employer's label for the band, e.g. "United States Salary Range", "Zone 3 Pay Range" |
hasSalary | boolean | yes | Whether a pay range was published for this posting |
firstPublishedAt | string | for found: true | When the posting first appeared, ISO 8601 UTC |
updatedAt | string | for found: true | When it last changed, ISO 8601 UTC |
applicationDeadline | string | rarely | Deadline the employer set, ISO 8601 UTC |
requisitionId | string | usually | The employer's own requisition number |
internalJobId | number | for found: true | Greenhouse's internal id of the job behind the posting |
language | string | usually | Language code of the posting |
descriptionHtml | string | with includeDescription | The description as published, HTML |
descriptionText | string | with includeDescription | The same text with entities decoded and tags stripped |
found | boolean | yes | false marks a board token that returned nothing |
error | string | on found: false | Why that token gave no postings |
sourceUrl | string | yes | The exact API URL this row came from |
fetchedAt | string | yes | When 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.","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), 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 were applied or corrected, 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 ApifyClientclient = 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 marker row of a board
token that returned nothing. Nothing else is charged; maxItems is the hard ceiling on what a run can cost.
| Run | Cost |
|---|---|
| 25 rows (the default) | $0.0385 |
| 200 rows | $0.301 |
| 1000 rows | $1.501 |
Apify's paid plans discount every tier: Bronze −10 %, Silver −20 %, Gold and above −30 % on the per-row price.
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 and without
an offices filter leaves offices/office empty. departmentPath is always filled — that tree is read separately.
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.
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. Invalid input is different: a
value that is not a board token stops the run with a message naming it, and a token the API does not serve becomes a
found: false row.
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