Greenhouse Jobs API
Query jobs from Greenhouse boards through one API — and what the Greenhouse board endpoint actually returns if you'd rather fetch it yourself.
Greenhouse is the recruiting platform a large share of venture-backed and
mid-market tech companies run their hiring on. Every customer gets a board at
boards.greenhouse.io/<token> — or, increasingly, job-boards.greenhouse.io/<token>
— and behind that board sits a public, unauthenticated JSON API that returns the
whole board in one request. It is the friendliest ATS in this pool to read, which
is why it's usually the first one people scrape and the reason so many people
think every ATS is this easy. It isn't.
Try it#
curl -G https://api.hyperjobs.io/v1/jobs \
-H "Authorization: Bearer $HYPERJOBS_KEY" \
--data-urlencode "source=greenhouse" \
--data-urlencode "time_frame=7d" \
--data-urlencode "limit=2"{
"data": [
{
"id": "greenhouse:5412887003",
"title": "Senior Data Engineer",
"company": {
"slug": "acme-analytics",
"name": "Acme Analytics",
"domain": "acme-analytics.com",
"website": "https://acme-analytics.com",
"logo": "https://media.licdn.com/dms/image/…",
"industry": "Software Development",
"employee_count": 340
},
"location": {
"remote": false,
"countries": ["United States"],
"cities": ["Austin"],
"regions": ["Texas"],
"raw": ["Austin, TX"]
},
"employment_type": ["FULL_TIME"],
"work_arrangement": "Hybrid",
"seniority": "Mid-Senior level",
"salary": {
"min": 165000,
"max": 205000,
"currency": "USD",
"unit": "YEAR",
"annual_min": 165000,
"annual_max": 205000,
"summary": "$165,000 – $205,000 a year"
},
"skills": ["python", "airflow", "snowflake"],
"taxonomies": ["Software"],
"apply_url": "https://boards.greenhouse.io/acmeanalytics/jobs/5412887003",
"url": "https://boards.greenhouse.io/acmeanalytics/jobs/5412887003",
"source": "greenhouse",
"posted_at": "2026-07-09T16:31:00.000Z",
"expires_at": null,
"updated_at": "2026-07-13T08:02:14.000Z"
}
],
"total": 2,
"limit": 2,
"offset": 0
}source=greenhouse is exact and case-sensitive — ?source=Greenhouse matches
nothing, since source is a free-form filter rather than a checked enum. Read
the live vocabulary off /v1/meta rather than
guessing; comma-separated values are OR.
How Greenhouse boards actually work#
One GET, no auth, no pagination, whole board:
curl "https://boards-api.greenhouse.io/v1/boards/<token>/jobs?content=true"<token> is the board token — the segment in boards.greenhouse.io/<token>.
In practice you have to recognise four URL shapes to derive it reliably, because
Greenhouse has accumulated hosts over the years:
| URL you find in the wild | Token |
|---|---|
boards.greenhouse.io/acmeanalytics | acmeanalytics |
job-boards.greenhouse.io/acmeanalytics | acmeanalytics |
boards.eu.greenhouse.io/acmeanalytics | acmeanalytics |
boards.greenhouse.io/embed/job_board?for=acmeanalytics | acmeanalytics |
That embed/job_board?for= form is the one that catches people: a company's
careers page iframes the board, so the token is in a query parameter rather than
a path segment. Our detector matches all four, plus the API URL itself.
content=true is the whole game#
Without it you get titles and no bodies. With it, every posting's full HTML description ships inline in the same response — no per-job detail fetch, no N+1. Greenhouse is one of the few platforms in this pool where the list call is genuinely complete. Workday and Breezy both make you pay a request per posting for the same thing.
The response is a flat { "jobs": [...] }. Per posting you get id, title,
absolute_url, updated_at, first_published, location.name, offices[],
departments[], requisition_id, language, metadata[], company_name, and
content.
Salary is on a different endpoint, in cents#
The structured pay band — pay_input_ranges — is not in the list payload. It
exists only on the per-job detail, and only when you ask:
curl "https://boards-api.greenhouse.io/v1/boards/<token>/jobs/<id>?pay_transparency=true"The amounts come back in cents. A min_cents of 16500000 is $165,000, and
if you miss the divide you'll publish salaries a hundred times too large. A
posting can also carry several ranges at once — one per geo tier — so a single
job has a band per market rather than one number.
How we pay the N+1
We fetch the pay detail by default, but only for postings we haven't seen before — the list call tells us what's on the board, and only genuinely new ids get a detail request. Steady state is one list call plus a handful of details per board. The first drain of a backlog is the expensive one, which is exactly when it's worth turning off.
The metadata array is free structured data#
Greenhouse lets employers define custom fields, and they ship on the list payload for free. The field names aren't standardised, but the useful ones recur: employment type, workplace type, salary band, experience level, education level. Surveyed across 59 boards and roughly 39,000 postings on 2026-07-06, these were close to fully populated wherever they appeared at all.
The trick is that you can't key on the name — one board calls it
USA: Pay Transparency Range, another Salary Band, another Pay Range. A pay
range is unambiguous by shape instead: an object carrying min_value and
max_value. Key on that and the board's naming stops mattering.
What bites you#
`content` is entity-escaped HTML
The description comes back with its markup escaped — <p> rather than
<p>. Render it without decoding and your users read HTML tags as text. Decode
it and you have real HTML, which you then still have to sanitise before putting
it on a page.
A bare city loses its country. location.name is frequently just Barcelona
or Austin, with no country anywhere in the string — which fails to geocode, or
worse, geocodes to the wrong continent. The country is usually recoverable from
offices[], where Greenhouse names offices things like Spain Locations and
US Locations. We resolve a country from the office names and append it, but only
when it's unambiguous across every office on the posting. A job with offices in
three countries gets left alone rather than assigned one at random.
requisition_id is often junk. Employers type placeholder text into it. We
see see opening id, tbd, tba, n/a, none, and a bare -. If you treat
that field as an identifier without filtering the placeholders, you'll build an
index keyed on the string "tbd" holding hundreds of unrelated postings.
Custom fields include fields marked as dead. Employers flag their own junk
fields — names containing do not use, deprecated, or test. They're still in
the payload. Mining metadata without skipping them means importing a field the
employer already decided was wrong.
absolute_url is both the posting and the apply link. Greenhouse doesn't
publish a separate apply URL, so a distinct apply_url would just duplicate
url. That's why the two are identical on every Greenhouse posting here — it's
the platform, not a gap in the data.
Pay units are ambiguous. Even after the cents divide, nothing in
pay_input_ranges says whether a band is annual or hourly. A Salary Type
custom field settles it when a board publishes one; otherwise the magnitude is
the only signal available, and a very low band is more likely hourly than a very
cheap salary.
Or use ours#
Greenhouse is the ATS most worth scraping yourself, and if you only need a handful of known boards, go and do that — the endpoint above is all of it.
What you're buying by not doing it is everything around the fetch. Board
discovery: our queue tracks 9,903 Greenhouse boards, and finding boards is
consistently harder than reading them, because there's no global index of who
uses Greenhouse. Expiry: postings are reaped by diffing each board against its
last fetch, so a role that comes down goes with it. Deduplication: the same job
on Greenhouse and LinkedIn collapses to one row
rather than two. The company join: company_name gives
you a string, and a resolved company profile with a domain, industry and
headcount is a different thing. And normalisation across every other board in
the sixty-odd — Greenhouse's cents and
Ashby's typed compensation tiers arrive as the same salary
object.
Greenhouse sits in the fast refresh tier, attempted every cycle, so a new posting typically shows up within the hour. See coverage for what that means across the rest of the pool.
Quickstart is a key and a curl. Pricing is what it costs.
Every source above normalises onto the same schema, with the hiring company already joined onto each posting.
Get an API key