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:
idtitlelocation.nameabsolute_urlfirst_publishedupdated_atrequisition_idcompany_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:
- The description is entity-escaped HTML.
contentarrives as<h2>...rather than<h2>.... Unescape it once (Python'shtml.unescape) before you render or parse it. - Pay ranges need
pay_transparency=true. The docs document this parameter only on the single-job endpoint (/jobs/{job_id}?pay_transparency=true, fieldpay_input_ranges). In our test, adding it to the list call (/jobs?pay_transparency=true) returnedpay_input_rangeson all 139 Figma jobs in one request, 88 of them with a range (min_cents,max_cents,currency_typeand 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 onboards.greenhouse.io/{token}redirect there. Job links look likeboards.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_jidparameter. A job link such asstripe.com/jobs/search?gh_jid=8172487means 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/stripereturns{"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=trueto 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=truewhen 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
ETagheader. When we repeated the request withIf-None-Matchset to that value, it returned304 Not Modifiedwith no body. We observed this behaviour, but the docs don't promise it, so your code should also handle a plain200.
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.