Integration with Greenhouse public jobs API
June 30, 2026Greenhouse is a widely used ATS. Its public Job Board API returns published jobs, departments, and offices as JSON - no authentication for read access.
This post covers listing jobs for one company, loading descriptions, and handling location metadata. For other ATS public feeds, see the Ashby, Workable, and Lever posts.
Greenhouse also ships a private Harvest API for full recruiting workflows (candidates, pipelines, offers). That API requires credentials and is out of scope here.
Prerequisites
- Node.js version 26
- A company's Greenhouse board token (see below)
- No API key required for GET job listings
Find the board token
Greenhouse career pages live at https://boards.greenhouse.io/{board_token}. The path segment after /boards/ is the token.
Examples: stripe → https://boards.greenhouse.io/stripe, API base https://boards-api.greenhouse.io/v1/boards/stripe/jobs.
API overview
| Item | Value |
|---|---|
| List jobs | GET https://boards-api.greenhouse.io/v1/boards/{board_token}/jobs |
| Single job | GET .../jobs/{job_id} |
| Auth (read) | None |
| Auth (apply) | HTTP Basic with Job Board API key - only for POST .../jobs/{id} applications |
Useful query parameters on the list endpoint:
| Parameter | Effect |
|---|---|
content=true | Include HTML job descriptions in the list response |
department_id, office_id | Filter by department or office |
Each job typically includes id, title, location.name, absolute_url, updated_at, departments, offices, and optionally content and metadata (custom fields the employer exposed to the job board).
Basic integration
List published jobs with descriptions:
const boardToken = process.env.GREENHOUSE_BOARD_TOKEN ?? 'stripe';const url = new URL(`https://boards-api.greenhouse.io/v1/boards/${encodeURIComponent(boardToken)}/jobs`,);url.searchParams.set('content', 'true');const response = await fetch(url);if (!response.ok) {throw new Error(`Greenhouse API ${response.status}: ${response.statusText}`);}const data = await response.json();for (const job of data.jobs ?? []) {console.log(job.title, '-', job.location?.name, '-', job.absolute_url);}
Fetch one job by id when you need the full posting or application questions:
async function getGreenhouseJob(boardToken, jobId) {const url = `https://boards-api.greenhouse.io/v1/boards/${encodeURIComponent(boardToken)}/jobs/${jobId}?questions=true`;const response = await fetch(url);if (!response.ok) {throw new Error(`Greenhouse API ${response.status}`);}return response.json();}
Location and metadata
Some boards store only work mode in location.name (Remote, Hybrid; In-Office) while the city and country live in a custom metadata field such as Job Posting Location:
const JOB_POSTING_LOCATION = 'Job Posting Location';function extractPostingLocation(metadata) {const item = metadata?.find((m) => m.name?.trim() === JOB_POSTING_LOCATION);if (!item?.value) return null;if (Array.isArray(item.value)) {return item.value.map(String).join(', ');}return String(item.value).trim() || null;}function resolveLocation(job) {const primary = job.location?.name?.trim() ?? '';const fromMetadata = extractPostingLocation(job.metadata);if (fromMetadata && (!primary || /^(remote|hybrid|on-?site|in-?office)$/i.test(primary))) {return fromMetadata;}return primary || fromMetadata || 'Unknown';}
Detect remote roles from location text:
function isRemoteJob(job) {const text = `${job.location?.name ?? ''} ${resolveLocation(job)}`.toLowerCase();return text.includes('remote') || text.includes('anywhere');}
Normalize to a stable shape
function normalizeGreenhouseJob(job, companyName) {const location = resolveLocation(job);return {id: String(job.id),title: job.title.trim(),company: companyName,location,isRemote: isRemoteJob(job),url: job.absolute_url,postedAt: job.updated_at ? new Date(job.updated_at) : null,description: job.content ?? '',departments: (job.departments ?? []).map((d) => d.name).filter(Boolean),};}
Cache responses when polling. Greenhouse does not publish hard rate limits for the job board API, but aggressive hammering can get blocked - poll on a schedule (for example every few hours) rather than on every page view.