What the Greenhouse Job Board API is
Companies that hire with Greenhouse publish their open jobs on a job board, at boards.greenhouse.io/<token> or job-boards.greenhouse.io/<token>, or embedded on their own careers page. Greenhouse documents a read-only API for that same list at boards-api.greenhouse.io. In Greenhouse's words: "Job Board data is publicly available, so authentication is not required for any GET endpoints." (Job Board API documentation.) No key, no login, one request per board.
The API host's robots.txt, checked today, only disallows /embed/:
curl -s https://boards-api.greenhouse.io/robots.txt# See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
User-agent: *
Disallow: /embed/Find the board token
The token is the last part of the board address, usually the company name in lowercase. Open the company's careers page and look at a job link. Or let curl look:
curl -s https://www.figma.com/careers/ | grep -o 'boards.greenhouse.io/[a-z0-9]*' | sort -uboards.greenhouse.io/figmaThe token is figma. Other signs of Greenhouse behind a company's own domain: a gh_jid= parameter in job links, or an embed such as boards.greenhouse.io/embed/job_board?for=<token> in the page source. If a careers page loads its jobs with JavaScript, grep finds nothing; open a job in the browser and read the address instead. A wrong token answers HTTP 404:
curl -s -w '\nHTTP %{http_code}\n' https://boards-api.greenhouse.io/v1/boards/no-such-board-xyz/jobs{"status":404,"error":"Job not found"}
HTTP 404List the jobs
One GET returns every open job on the board. There is no paging: meta.total is the number of jobs in the answer.
curl -s https://boards-api.greenhouse.io/v1/boards/figma/jobs |
python3 -c "import json,sys; d=json.load(sys.stdin); print(d['meta']); print(json.dumps(d['jobs'][0], indent=2))"{'total': 162}
{
"absolute_url": "https://boards.greenhouse.io/figma/jobs/5426468004?gh_jid=5426468004",
"data_compliance": [
{
"type": "gdpr",
"requires_consent": false,
"requires_processing_consent": false,
"requires_retention_consent": false,
"retention_period": null,
"demographic_data_consent_applies": false
}
],
"internal_job_id": 4868517004,
"location": {
"name": "San Francisco, CA • New York, NY • United States"
},
"metadata": null,
"id": 5426468004,
"updated_at": "2026-07-22T05:37:08-04:00",
"requisition_id": "1451",
"title": "Account Executive, Enterprise",
"company_name": "Figma",
"first_published": "2025-01-28T18:57:29-05:00",
"language": "en",
"application_deadline": null
}Without parameters you get the title, the location text, the first publication date, the last update and the job's link. id is the number in the job's address and stays the same while the job is open, so it is the key to compare runs by.
Add descriptions, departments and offices: content=true
?content=true adds the full description, the departments and the offices of each job. It also makes the answer much bigger:
curl -s -o /dev/null -w '%{size_download} bytes\n' https://boards-api.greenhouse.io/v1/boards/figma/jobs
curl -s -o /dev/null -w '%{size_download} bytes\n' "https://boards-api.greenhouse.io/v1/boards/figma/jobs?content=true"102471 bytes
1814194 bytescurl -s "https://boards-api.greenhouse.io/v1/boards/figma/jobs?content=true" |
python3 -c "
import json, sys
j = json.load(sys.stdin)['jobs'][0]
print('new keys:', sorted(set(j) - {'absolute_url', 'data_compliance', 'internal_job_id', 'location', 'metadata', 'id', 'updated_at', 'requisition_id', 'title', 'company_name', 'first_published', 'language', 'application_deadline'}))
print('departments:', j['departments'])
print('offices:', j['offices'])
print('content:', j['content'][:90])"new keys: ['ai_disclaimer', 'ai_opt_out_request_url', 'content', 'departments', 'include_ai_disclaimer', 'offices']
departments: [{'id': 4016255004, 'name': 'Sales', 'child_ids': [], 'parent_id': None}]
offices: [{'id': 4024285004, 'name': 'US', 'location': None, 'child_ids': [], 'parent_id': 4033142004}]
content: <div class="content-intro"><p>Figma is growing our team of passionatNote the content field: it is HTML that has been escaped once more (<div>, not <div>). Unescape it once to get HTML, then strip the tags if you want plain text. The script below does both.
Departments and offices form trees (parent_id, child_ids). Ask for content=true only when you need these fields; for a quick count or a list of titles the plain call was about 18 times smaller here.
Jobs per department
/departments returns every department with its jobs in one call. /offices does the same by office.
curl -s https://boards-api.greenhouse.io/v1/boards/figma/departments |
python3 -c "
import json, sys
deps = [d for d in json.load(sys.stdin)['departments'] if d['jobs']]
for d in sorted(deps, key=lambda d: -len(d['jobs'])):
print(len(d['jobs']), d['name'])"56 Sales
27 Engineering
17 Business Operations
13 Design
9 Early Career
9 Marketing
8 People
6 Product
5 Customer Operations and Support
4 Business Development
4 Talent
3 Weavy - Figma Weave
1 LegalPay ranges: pay_transparency=true
Greenhouse documents pay_transparency=true for a single job: it adds pay_input_ranges with the ranges the employer set for the post. On the list endpoint it worked the same way today:
curl -s "https://boards-api.greenhouse.io/v1/boards/figma/jobs?content=true&pay_transparency=true" |
python3 -c "
import json, sys
jobs = json.load(sys.stdin)['jobs']
with_pay = [j for j in jobs if j.get('pay_input_ranges')]
print(len(with_pay), 'of', len(jobs), 'jobs have a pay range')
r = with_pay[0]['pay_input_ranges'][0]
print(with_pay[0]['title'], '|', r['title'], r['min_cents'] // 100, '-', r['max_cents'] // 100, r['currency_type'])"107 of 162 jobs have a pay range
Account Executive, Enterprise | Annual Base Salary Range: 165000 - 190000 USDAmounts are in cents. A job can carry several ranges (for example by location), each with its own label. How many jobs have one depends on the employer and on local pay-transparency laws; do not expect Figma's share elsewhere.
A complete script: every job to CSV
Standard library only. One request per board, so there is nothing to pace for a single company; if you loop over many boards, wait a moment between them.
"""List every open job on one Greenhouse job board and save jobs.csv.
Usage: python3 greenhouse_jobs.py figma"""
import csv, html, json, re, sys, urllib.request
board = sys.argv[1]
url = (f"https://boards-api.greenhouse.io/v1/boards/{board}/jobs"
"?content=true&pay_transparency=true")
req = urllib.request.Request(url, headers={"User-Agent": "my-job-export/1.0 ([email protected])"})
with urllib.request.urlopen(req, timeout=60) as r:
data = json.load(r)
def text(escaped_html):
# content arrives HTML-escaped: unescape once to get HTML, then strip the tags
raw = html.unescape(escaped_html or "")
return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", " ", raw))).strip()
rows = []
for j in data["jobs"]:
pay = (j.get("pay_input_ranges") or [{}])[0]
rows.append({
"id": j["id"],
"title": j["title"],
"department": ", ".join(d["name"] for d in j.get("departments", [])),
"location": j["location"]["name"],
"first_published": j.get("first_published"),
"updated_at": j["updated_at"],
"pay_min": pay["min_cents"] // 100 if pay else "",
"pay_max": pay["max_cents"] // 100 if pay else "",
"currency": pay.get("currency_type", ""),
"url": j["absolute_url"],
"description": text(j.get("content"))[:300],
})
with open("jobs.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0]))
w.writeheader()
w.writerows(rows)
print(f"{board}: {data['meta']['total']} jobs, "
f"{sum(1 for r in rows if r['pay_min'])} with a pay range")python3 greenhouse_jobs.py figma
head -3 jobs.csv | cut -c1-200figma: 162 jobs, 107 with a pay range
id,title,department,location,first_published,updated_at,pay_min,pay_max,currency,url,description
5426468004,"Account Executive, Enterprise",Sales,"San Francisco, CA • New York, NY • United States",2025-01-28T18:57:29-05:00,2026-07-22T05:37:08-04:00,165000,190000,USD,https://boards.greenhouse.
5579204004,"Account Executive, Enterprise (Bengaluru, India)",Sales,"Bengaluru, India",2025-07-21T23:53:16-04:00,2026-07-22T05:37:08-04:00,,,,https://boards.greenhouse.io/figma/jobs/5579204004?gh_jid=Tracking new jobs
To see what a company opened since yesterday, save the set of id values from each run and compare: ids in today's list that were not in yesterday's are new jobs; ids that disappeared are closed or filled. One detail matters. If a board that had jobs suddenly answers with none, check again later before you mark everything closed.
What the public board does not give you
- Only open, published jobs. No history, no closed jobs, no internal-only posts.
- No applicant or recruiter data. Everything behind an applicant login stays there.
- No employment type or remote flag. "Remote" appears, when it does, in the location text or the title.
- Boards that hide Greenhouse behind JavaScript on the company's own domain need a manual look to find the token.
When a hosted tool is easier
For one board, the script is enough. For fifty boards every morning, with only the new jobs and the same columns every time, you need a schedule, stored ids and outage handling. The Greenhouse Jobs Scraper on the Apify Store does that with this API. For boards on several vendors (Workday, Lever, Ashby and others), the ATS Jobs Scraper reads all of them in one run with the same fields.
Tinlark is not affiliated with Greenhouse Software or Figma. Figma's board appears only as an example of a public job board. The postings belong to the employers: check their terms and the laws that apply to you before you republish them.