# 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: https://hyperjobs.io/ats/greenhouse

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"
```

```json title="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`](/docs/api-reference/meta) rather than
guessing; comma-separated values are [OR](/docs/guides/filtering).

## 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 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](/ats/workday) and [Breezy](/ats/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.

<Note title="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.
</Note>

### 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

<Warning title="`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.
</Warning>

**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](/docs/guides/deduplication)
rather than two. The [company join](/docs/objects/company): `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](/docs/reference/sources) — Greenhouse's cents and
[Ashby](/ats/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](/docs/reference/coverage) for
what that means across the rest of the pool.

[Quickstart](/docs/quickstart) is a key and a curl. [Pricing](/pricing) is what
it costs.
