Back to articles

How to Scrape Greenhouse Job Listings

CleanJobData Engineering

Most companies that use Greenhouse for hiring also expose a public JSON API behind their careers page, whether they know it or not. If you've ever wondered how job boards and browser extensions pull listings from a company's Greenhouse page without touching HTML, this is how — including the full response shape and the gotchas that only show up once you're parsing real postings at scale.

Finding the Board Token

Every Greenhouse-hosted careers page is backed by a "board token," usually the company's slug. If a company's job board lives at https://boards.greenhouse.io/acme or is embedded on careers.acme.com via Greenhouse's embed script, the board token is acme. You can spot it directly in the embed script tag, the page URL, or by watching the network tab for a call to boards-api.greenhouse.io while the careers page loads. This only tells you the token for a company whose careers page you already know — there's no public directory mapping every company to its board token, and finding those at scale across thousands of employers is a separate, much harder problem than fetching one board you already have the address for.

Fetching Jobs — With Full Content in One Call

The list endpoint:

curl "https://boards-api.greenhouse.io/v1/boards/acme/jobs"

By default this returns a light summary — no description. But add content=true to the same list request, and Greenhouse includes the full HTML description for every job in one response, not per-job:

curl "https://boards-api.greenhouse.io/v1/boards/acme/jobs?content=true"

This matters because it's easy to assume (reasonably, since most ATS APIs work this way) that you need a separate detail request per job to get description content — you don't, for Greenhouse. A 200-job board is one request, not 201.

The Full Response Shape

{
  "jobs": [
    {
      "id": 4837291,
      "title": "Senior Backend Engineer",
      "updated_at": "2026-07-10T18:22:03-05:00",
      "first_published": "2026-06-28T09:00:00-05:00",
      "location": { "name": "San Francisco, CA" },
      "absolute_url": "https://boards.greenhouse.io/acme/jobs/4837291",
      "content": "<div>We're looking for...</div>",
      "requisition_id": "REQ-1042",
      "company_name": "Acme Inc",
      "language": "en",
      "departments": [{ "id": 123, "name": "Engineering" }],
      "offices": [{ "id": 456, "name": "San Francisco", "location": "San Francisco, CA" }],
      "metadata": []
    }
  ]
}

Field-by-field, what you can actually rely on:

  • title — clean, generally reliable as-is.
  • first_published — the field to trust for the original publish date. updated_at changes whenever the posting is edited (even a typo fix bumps it), so if you're sorting or filtering by "when was this posted," first_published is the correct field, not updated_at. Some boards omit first_published; fall back to updated_at only when it's missing.
  • location.name — free-text, entered by whoever created the posting. Expect "NYC", "New York", "New York, NY", and "New York City Metro Area" for the same city, sometimes from the same company. No city/state/country ID, no lat/lng.
  • content — HTML, and it's entity-encoded in the response (&lt;div&gt; rather than <div>). Decode &lt;, &gt;, &amp;, &quot;, &#39;, and &nbsp; — and watch for double-encoding on some boards, where you'll see literal &amp;nbsp; in the raw string instead of &nbsp;. A naive single-pass decoder misses that case and leaves visible escaped entities in the rendered description.
  • absolute_url — stable, safe to use as a dedup key alongside id.
  • departments / offices — present only if the company configured them, and even then they're inconsistent: one company's "Engineering" department is another's "Product & Engineering". offices sometimes carries a second, cleaner location string worth cross-referencing against location.name.
  • requisition_id — an internal req number, not always present, not a reliable dedup key on its own (some companies reuse or leave it blank across multiple boards).
  • No salary fields at all. Not salary_min, not a compensation object — nothing. If pay is disclosed, it's inside content as freeform text, in whatever format the recruiter typed it ("$150k-$180k", "Salary: 150,000 - 180,000 USD", or not mentioned at all). There's no reliable structured signal to extract from.
  • No remote flag. You have to infer remote/hybrid status by checking whether "remote" or "hybrid" appears in location.name or the title — which produces false negatives for postings that say "Distributed", "Work from anywhere", or just list a city with remote-friendliness buried in the description text.

The Real Pain Points

Location is unstructured text with no fallback structure. There's no city/state/country breakdown anywhere in the response — you're parsing location.name yourself or not getting structured location at all.

No cross-company aggregation. Every company has its own board token and its own API call. Fifty companies means fifty separate boards to track, fifty sets of informal rate limits to respect, and fifty slightly different conventions for department and location labeling.

No webhooks. The public boards API is pull-only. To know when a job closes or a new one opens, you poll on an interval and diff the job IDs you saw last time against what you see now. Miss a poll window and a short-lived posting can open and close without you ever seeing it.

Rate limiting is undocumented. Greenhouse doesn't publish hard numbers for the public boards API. If you're polling many boards on a schedule, back off conservatively and avoid parallel requests against a single board token — there's no header telling you how close you are to a limit, unlike some other ATS platforms.

When This Is Enough

If you only need one company's jobs — say, a "jobs at Acme" widget for their own site — the boards API is a genuinely good fit: free, unauthenticated, and stable for that scope, especially now that you know content=true gets you full descriptions in the same bulk call.

When It Isn't

Once you need jobs from many companies with a consistent schema — structured city/state/country, parsed salary ranges, a normalized seniority field, and deduplication across sources — writing and maintaining that pipeline yourself gets expensive fast, and Greenhouse is only one of several ATS platforms; Lever, Ashby, Workday, and Workable each have their own API shape and their own version of every problem above. That's the gap a normalized data API like JobsDataAPI is built to fill: one schema across sources instead of one client, and one set of parsing quirks, per ATS.