DEV Community

Cover image for Integration with Greenhouse public jobs API
Željko Šević
Željko Šević

Posted on • Originally published at sevic.dev on

Integration with Greenhouse public jobs API

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

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

Detect remote roles from location text:

function isRemoteJob(job) {
  const text = `${job.location?.name ?? ''} ${resolveLocation(job)}`.toLowerCase();
  return text.includes('remote') || text.includes('anywhere');
}
Enter fullscreen mode Exit fullscreen mode

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),
  };
}
Enter fullscreen mode Exit fullscreen mode

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.

Need help with your project?

Get personalized advice on your architecture, code, or career in a 45-minute 1-on-1 consultation.

Book a consultation

Top comments (0)