Greenhouse Job Board API: list every open job for a company (no API key)

The public Greenhouse jobs API behind company careers boards: endpoints, pay ranges, board tokens, polite use and two tested Python scripts. No API key needed.

Requests and scripts tested on 26 October 2026.

Many companies run their hiring on Greenhouse, and their public careers boards are backed by a public JSON API. If you want a company's job list in a spreadsheet, a CRM or your own job board, you don't need a Greenhouse account, a partner agreement or an API key. You need the company's board token and one HTTP request to the Greenhouse jobs API.

This guide covers:

  • which Greenhouse API to use
  • the endpoints worth knowing
  • how to find a board token
  • what the responses look like
  • how to be a polite client
  • how to track new and closed jobs over time

Every request and script below was run on 26 October 2026.

Job Board API or Harvest API: which "Greenhouse API" do you need?

Searching for "greenhouse api" turns up two different products:

  • The Job Board API (boards-api.greenhouse.io) serves what a company already shows on its public careers page: open jobs, departments, offices and job descriptions. The official docs say: "Job Board data is publicly available, so authentication is not required for any GET endpoints." You only need a key to submit applications through it. Job Board API docs
  • The Harvest API is the private API for a company's own Greenhouse account, and it requires authentication. It covers candidates, applications, offers, interviews and users. That company's Greenhouse admins grant access, so you can only use it for an employer you work for or build an integration with. Harvest v1 and v2 were scheduled for removal on 31 August 2026, and the docs point to Harvest v3: use v3 for anything new. Harvest API docs

If your question is "what jobs is company X hiring for?", use the Job Board API. If your question is "which candidates applied to our roles?", you need Harvest and your own admin's permission.

The endpoints

All Job Board endpoints start from one base URL:

https://boards-api.greenhouse.io/v1/boards/{board_token}
Endpoint What you get Useful parameters
/v1/boards/{token} Board name and intro text none
/v1/boards/{token}/jobs Every published job content=true, pay_transparency=true (undocumented here, see below)
/v1/boards/{token}/jobs/{job_id} One job in full questions=true, pay_transparency=true
/v1/boards/{token}/departments Departments with their jobs render_as=tree
/v1/boards/{token}/offices Offices with departments and jobs render_as=tree

Try it in a terminal:

curl -s -A "my-job-tracker/1.0 (you@example.com)" "https://boards-api.greenhouse.io/v1/boards/figma/jobs"

Each job in the plain list has these fields:

  • id
  • title
  • location.name
  • absolute_url
  • first_published
  • updated_at
  • requisition_id
  • company_name

It also carries internal_job_id, metadata (the employer's custom fields), language, application_deadline and data_compliance. The response ends with "meta": {"total": 139}, the number of jobs on that board when we ran it.

Adding ?content=true adds, in the docs' words, "the full post description, department, and office of each job post". You get a content field with the description, plus departments and offices arrays. The plain list has no department field. If you need departments but not descriptions, call /departments once and map job IDs to departments from it: for Stripe that response was about 0.56 MB, against about 5.6 MB for /jobs?content=true. Keep content=true for when you actually want the descriptions.

Two details that trip people up:

  1. The description is entity-escaped HTML. content arrives as &lt;h2&gt;... rather than <h2>.... Unescape it once (Python's html.unescape) before you render or parse it.
  2. Pay ranges need pay_transparency=true. The docs document this parameter only on the single-job endpoint (/jobs/{job_id}?pay_transparency=true, field pay_input_ranges). In our test, adding it to the list call (/jobs?pay_transparency=true) returned pay_input_ranges on all 139 Figma jobs in one request, 88 of them with a range (min_cents, max_cents, currency_type and a title). Because this is undocumented, fall back to the single-job endpoint if the field is missing. An empty list is normal: each employer decides whether to publish pay, and a Stripe role we checked returned none.

Finding a company's board token

The board token is the company's short name for its board on Greenhouse. Here are four ways to find it:

  • The careers page URL. Hosted boards live at job-boards.greenhouse.io/{token}. Older links on boards.greenhouse.io/{token} redirect there. Job links look like boards.greenhouse.io/figma/jobs/5426468004.
  • An embedded board. Many companies show the board inside their own site. In the page source, look for job-boards.greenhouse.io/embed/job_board?for={token}.
  • A gh_jid parameter. A job link such as stripe.com/jobs/search?gh_jid=8172487 means the site runs on Greenhouse, even when the URL doesn't show the token. In that case, try the obvious name (stripe) as the token.
  • Check your guess. GET /v1/boards/stripe returns {"name":"Stripe","content":""}. A wrong token returns HTTP 404 with {"status":404,"error":"Job board not found"}.

The token is often, but not always, the company name in lower case. Some companies use a longer form, a former name or a regional variant, so check the token before you rely on it.

Pagination and response size

The jobs list isn't paginated: one request returns every job. In our tests meta.total matched the length of the jobs array (718 of 718 for Stripe, 139 of 139 for Figma). The docs describe a page cursor only for the education lookup endpoints, not for jobs.

Response size, on the other hand, varies a lot. For Stripe on the day we tested:

Request Response size
/jobs about 0.45 MB
/jobs?content=true about 5.6 MB
/departments about 0.56 MB
/offices about 28.8 MB

The offices endpoint repeats every department and its jobs under every office, so on a large board it's by far the heaviest call. If you need offices, take them from the offices array of /jobs?content=true instead.

Rate etiquette

The Job Board docs don't publish a rate limit. (Harvest has one, but it's a separate API.) That doesn't mean you can send as many requests as you like. These are our habits:

  • Make one list call per board per run. The list already holds every job, so you don't need to fetch jobs one by one. Add pay_transparency=true to the list call for pay ranges, and use the single-job endpoint only when that field is missing or you need application questions.
  • Skip content=true when you only need titles and IDs. It made the Stripe response more than ten times larger.
  • Send a User-Agent that says who you are and how to contact you.
  • Pause between boards, and cache responses for at least a few hours. Hiring pages don't change minute to minute.
  • Use conditional requests. The jobs endpoint returned an ETag header. When we repeated the request with If-None-Match set to that value, it returned 304 Not Modified with no body. We observed this behaviour, but the docs don't promise it, so your code should also handle a plain 200.

Tested script 1: export every open job to CSV

This script uses only the Python standard library. We tested it on Python 3.14.

import csv, html, json, sys, urllib.request

UA = "my-job-tracker/1.0 (you@example.com)"  # say who you are
token = sys.argv[1] if len(sys.argv) > 1 else "figma"
url = f"https://boards-api.greenhouse.io/v1/boards/{token}/jobs?content=true"

req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=30) as resp:
    data = json.load(resp)

rows = []
for job in data["jobs"]:
    rows.append({
        "id": job["id"],
        "title": job["title"],
        "department": ", ".join(d["name"] for d in job.get("departments", [])),
        "office": ", ".join(o["name"] for o in job.get("offices", [])),
        "location": (job.get("location") or {}).get("name"),
        "first_published": job.get("first_published"),
        "updated_at": job.get("updated_at"),
        "url": job["absolute_url"],
        # content arrives entity-escaped: unescape once to get real HTML
        "description_html": html.unescape(job.get("content") or ""),
    })

with open(f"{token}_jobs.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=rows[0].keys())
    writer.writeheader()
    writer.writerows(rows)

print(f"{token}: {len(rows)} jobs (meta.total = {data['meta']['total']})")

Running python list_jobs.py figma printed figma: 139 jobs (meta.total = 139) and wrote figma_jobs.csv. One of its rows is "Account Executive, Enterprise", in the Sales department and the US office.

Job descriptions sometimes name a recruiter or a hiring manager. If you store or republish the description_html column, decide first how you handle that personal data, or drop the column.

Tested script 2: track new and closed jobs over time

The API only shows what is open right now. It has no "closed jobs" endpoint and no history. To see changes, you keep your own snapshot and compare it with the next one. A job ID that appears is a new job. A job ID that disappears has closed or been unpublished.

import json, pathlib, time, urllib.error, urllib.request
from datetime import date

UA = "my-job-tracker/1.0 (you@example.com)"
BOARDS = ["figma", "stripe"]           # board tokens to watch
STATE = pathlib.Path("greenhouse_state.json")

state = json.loads(STATE.read_text()) if STATE.exists() else {}
today = date.today().isoformat()

for token in BOARDS:
    old = state.get(token, {"etag": None, "jobs": {}})
    headers = {"User-Agent": UA}
    if old["etag"]:
        headers["If-None-Match"] = old["etag"]   # ask "anything changed?"
    url = f"https://boards-api.greenhouse.io/v1/boards/{token}/jobs"
    try:
        with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=30) as resp:
            jobs = {str(j["id"]): {"title": j["title"], "url": j["absolute_url"]}
                    for j in json.load(resp)["jobs"]}
            etag = resp.headers.get("ETag")
    except urllib.error.HTTPError as err:
        if err.code == 304:
            print(f"{token}: unchanged since last run")
            continue
        raise

    first_run = not old["jobs"]
    new_ids = jobs.keys() - old["jobs"].keys()
    closed_ids = old["jobs"].keys() - jobs.keys()
    if first_run:
        print(f"{token}: baseline of {len(jobs)} open jobs saved")
    else:
        print(f"{token}: {len(new_ids)} new, {len(closed_ids)} closed, {len(jobs)} open")
        for i in sorted(new_ids):
            print(f"  NEW    {jobs[i]['title']}  {jobs[i]['url']}")
        for i in sorted(closed_ids):
            print(f"  CLOSED {old['jobs'][i]['title']}  (gone on {today})")
    state[token] = {"etag": etag, "jobs": jobs}
    STATE.write_text(json.dumps(state, indent=1))  # save after each board
    time.sleep(1)                              # be polite between boards

The first run printed figma: baseline of 139 open jobs saved and stripe: baseline of 718 open jobs saved. A second run a few minutes later printed unchanged since last run for both boards, because the API answered with a 304. We then edited the state file by hand to fake a change, and the next run reported one new job and one closed job. To run it every day, schedule it with cron or Windows Task Scheduler.

The script follows three rules that are worth keeping in any tracker:

  • Treat the first run as a baseline. Otherwise every existing job shows up as "new".
  • Never mark jobs closed after a failed request. The script stops on errors instead of reading an empty answer as "every job closed". It saves its state after each board, so changes it has already printed are not reported again on the next run.
  • Expect some noise. If a company unpublishes a job and later republishes it, the job shows as closed and then as new.

Doing this for many companies: our Actor

The scripts above are free and are enough for a handful of boards. As your list grows, the work changes. You might have dozens of target companies, and some of them use Lever or Ashby instead of Greenhouse. Most of the effort then goes into four things:

  • finding board tokens
  • keeping state
  • handling failures
  • normalising three different formats

Our Greenhouse, Lever & Ashby Jobs API + Job Monitor on the Apify Store does this work. It reads the same public Job Board API shown here, plus Lever's and Ashby's public posting APIs, the Recruitee Careers Site API and Teamtailor's career-site RSS feed. It never uses Harvest, so you need no key and it sees no candidate data.

You paste up to 1,000 company names, domains or board URLs. The Actor tries the board names a company usually uses and reports which board it matched for each company. It also lists the companies it couldn't find. You get one table with titles, departments, locations, remote flags and published pay ranges.

For tracking, use Changes mode with a watchlist ID. Each scheduled run is compared with the previous complete run. It saves only new, updated and closed jobs, with first_seen and closed_at dates. As in script 2, the first run is a baseline.

Pricing is pay per event:

  • $2.00 per 1,000 companies checked
  • $1.50 per 1,000 job rows
  • free for companies it can't find and for unchanged jobs
  • plus Apify's standard Actor start event, a fraction of a cent per run

Quarrywise is independent and isn't affiliated with or endorsed by Greenhouse, Lever, Ashby, Recruitee or Teamtailor.

If you only watch a few boards, the free script is all you need.

Sources