homeresume
 
   
🔍

Integration with Greenhouse public jobs API

June 30, 2026

Greenhouse 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: stripehttps://boards.greenhouse.io/stripe, API base https://boards-api.greenhouse.io/v1/boards/stripe/jobs.

API overview

ItemValue
List jobsGET https://boards-api.greenhouse.io/v1/boards/{board_token}/jobs
Single jobGET .../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:

ParameterEffect
content=trueInclude HTML job descriptions in the list response
department_id, office_idFilter 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.