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
18 days 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, and an entry that is not a token at all, 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 not delivered before (per saved task) — 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, 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 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, 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:
errorCode | Meaning |
|---|---|
board-not-found | The API answered 404: nobody publishes a board under this token. |
board-empty | The board exists and has no open positions right now (company is filled when the board names it). |
board-unreachable | The API gave no usable answer after retries (timeout, server errors, throttling, a refusal). |
invalid-token | The 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
| 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, 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"}
| Field | Type | Always filled | Meaning |
|---|---|---|---|
boardToken | string | yes | The company's board token, in lower case (for an invalid-token row: the entry as typed) |
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 |
errorCode | string | on found: false | board-not-found, board-empty, board-unreachable or invalid-token — see When a board gives nothing |
sourceUrl | string | except invalid-token | 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.","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 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 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.
| Run | Cost |
|---|---|
| 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