All platforms

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.

?source=greenhousesource reference

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#

BASH
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"
Illustrative — the shape, not a live response
{
  "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:

BASH
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 wildToken
boards.greenhouse.io/acmeanalyticsacmeanalytics
job-boards.greenhouse.io/acmeanalyticsacmeanalytics
boards.eu.greenhouse.io/acmeanalyticsacmeanalytics
boards.greenhouse.io/embed/job_board?for=acmeanalyticsacmeanalytics

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:

BASH
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 — &lt;p&gt; 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