homeresume
 
   
🔍

Integration with Lever public jobs API

July 2, 2026

Lever is an ATS with a public Postings API for read-only access to published job listings. No API key is required - you only need the company's Lever site slug.

This post covers fetching postings with pagination, optional filters, and EU vs US API hosts. For other ATS public feeds, see the Ashby, Greenhouse, and Workable posts.

Lever's authenticated Data API (https://api.lever.co/v1/...) manages candidates and pipeline data and is separate from the public postings feed.

Prerequisites

  • Node.js version 26
  • A company's Lever site slug (see below)
  • No API key required for the Postings API

Find the site slug

Lever-hosted career pages use https://jobs.lever.co/{site_slug}/. The first path segment after jobs.lever.co is the slug passed to the API.

Examples: unlimithttps://jobs.lever.co/unlimit/, API https://api.lever.co/v0/postings/unlimit.

Some accounts are hosted in the EU region and answer on https://api.eu.lever.co/v0/postings/{site_slug} instead.

API overview

ItemValue
US basehttps://api.lever.co/v0/postings/{site_slug}
EU basehttps://api.eu.lever.co/v0/postings/{site_slug}
AuthNone
FormatJSON array (mode=json)

Common query parameters:

ParameterDescription
mode=jsonReturn JSON instead of HTML
skip, limitPagination (limit defaults to 100)
team, department, location, commitmentFilters; repeat a key for multiple values
groupGroup results by location, team, or commitment

Each posting includes id, text (title), hostedUrl, applyUrl, categories (team, department, location), workplaceType, country, and plain-text description fields.

Basic integration

Fetch one page of postings:

const siteSlug = process.env.LEVER_SITE_SLUG ?? 'unlimit';
const url = new URL(`https://api.lever.co/v0/postings/${encodeURIComponent(siteSlug)}`);
url.searchParams.set('mode', 'json');
url.searchParams.set('limit', '100');
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Lever API ${response.status}: ${response.statusText}`);
}
const postings = await response.json();
for (const posting of postings) {
console.log(posting.text, '-', posting.categories?.location, '-', posting.hostedUrl);
}

Paginate until a page returns fewer rows than your limit:

const PAGE_SIZE = 100;
async function fetchAllLeverPostings(siteSlug, baseUrl = 'https://api.lever.co/v0/postings') {
const all = [];
let skip = 0;
for (;;) {
const url = new URL(`${baseUrl}/${encodeURIComponent(siteSlug)}`);
url.searchParams.set('mode', 'json');
url.searchParams.set('skip', String(skip));
url.searchParams.set('limit', String(PAGE_SIZE));
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Lever API ${response.status}`);
}
const page = await response.json();
if (!Array.isArray(page) || page.length === 0) break;
all.push(...page);
if (page.length < PAGE_SIZE) break;
skip += PAGE_SIZE;
}
return all;
}

If the US host returns 404, retry against the EU host:

const EU_BASE = 'https://api.eu.lever.co/v0/postings';
const US_BASE = 'https://api.lever.co/v0/postings';
async function fetchPostingsWithRegionFallback(siteSlug) {
try {
return await fetchAllLeverPostings(siteSlug, US_BASE);
} catch (error) {
if (String(error.message).includes('404')) {
return fetchAllLeverPostings(siteSlug, EU_BASE);
}
throw error;
}
}

Filter at the source

Request only roles in a team or location:

url.searchParams.append('team', 'Engineering');
url.searchParams.append('location', 'Berlin');
url.searchParams.append('location', 'Remote');

Fetch a single posting:

const detailUrl = `https://api.lever.co/v0/postings/${siteSlug}/${postingId}?mode=json`;

Normalize to a stable shape

function buildDescription(posting) {
const parts = [
posting.descriptionPlain,
posting.openingPlain,
posting.descriptionBodyPlain,
posting.additionalPlain,
].filter(Boolean);
for (const list of posting.lists ?? []) {
if (list.text && list.content) {
parts.push(`${list.text}\n${list.content.replace(/<[^>]+>/g, ' ')}`);
}
}
return parts.join('\n\n');
}
function normalizeLeverPosting(posting, companyName) {
const primary = posting.categories?.location?.trim() ?? '';
const extras = (posting.categories?.allLocations ?? []).filter(
(loc) => loc.trim().toLowerCase() !== primary.toLowerCase(),
);
const location = [primary, ...extras].filter(Boolean).join(' | ') || 'Unknown';
const isRemote =
posting.workplaceType?.toLowerCase() === 'remote' ||
/remote/i.test(location);
return {
id: posting.id ?? `${posting.text}:${posting.createdAt}`,
title: posting.text.trim(),
company: companyName,
location,
country: posting.country ?? null,
isRemote,
url: posting.hostedUrl || posting.applyUrl,
postedAt: posting.createdAt ? new Date(posting.createdAt) : null,
team: posting.categories?.team ?? null,
commitment: posting.categories?.commitment ?? null,
description: buildDescription(posting),
};
}

Only published postings appear in this API. Confidential or internal-only roles are never returned.

Integration with Workable public jobs API

July 1, 2026

Workable is an ATS with a public careers layer you can read without authentication. The widget-style JSON endpoint powers Workable-hosted career sites and embeddable job widgets.

This post shows how to list published jobs for one Workable account, request full descriptions, and normalize location and remote fields. For other ATS public feeds, see the Ashby, Greenhouse, and Lever posts.

Workable's authenticated REST API v3 (https://{subdomain}.workable.com/spi/v3/) requires a bearer token and is meant for HR integrations, not public job aggregation.

Prerequisites

  • Node.js version 26
  • A company's Workable account slug (see below)
  • No API key required for the public widget endpoint

Find the account slug

Workable career pages use URLs like https://apply.workable.com/{account_slug}/ or legacy https://{account_slug}.workable.com/. The slug is the account identifier in API paths.

Examples: huggingface for Hugging Face, flosum for Flosum.

API overview

ItemValue
Jobs (with details)GET https://www.workable.com/api/accounts/{slug}?details=true
LocationsGET .../accounts/{slug}/locations
DepartmentsGET .../accounts/{slug}/departments
AuthNone
FormatJSON

Node fetch may follow a redirect to https://apply.workable.com/api/v1/widget/accounts/{slug}?details=true; both URLs return the same payload.

Set details=true to include description and full_description on each job. Without it you get summary fields only.

Common fields on each job in jobs[]:

FieldDescription
titleJob title
url, shortlinkApply links
location, locationsStructured location objects
experienceSeniority label (for example Mid-Senior level)
published_on, created_atTimestamps
stateOn public feeds this is often a region name, not listing status - prefer published_on to detect live roles

Basic integration

const accountSlug = process.env.WORKABLE_ACCOUNT_SLUG ?? 'huggingface';
const url = new URL(
`https://www.workable.com/api/accounts/${encodeURIComponent(accountSlug)}`,
);
url.searchParams.set('details', 'true');
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Workable API ${response.status}: ${response.statusText}`);
}
const data = await response.json();
for (const job of data.jobs ?? []) {
console.log(job.title, '-', job.location?.location_str, '-', job.url);
}

Keep only published listings:

function isPublished(job) {
if (job.published_on?.trim()) return true;
return !job.state || job.state === 'published';
}
const publicJobs = (data.jobs ?? []).filter(
(job) => job.title && (job.url || job.shortlink) && isPublished(job),
);

Locations and remote detection

Prefer location.location_str when present. Otherwise build a label from structured fields:

function stringifyLocation(entry) {
const city = entry.city?.trim();
const region = entry.state_code?.trim() || entry.region?.trim();
const country = entry.country_name?.trim() || entry.country?.trim();
if (city && region && country) return `${city}, ${region}, ${country}`;
if (city && country) return `${city}, ${country}`;
return country || city || '';
}
function resolveLocation(job) {
const primary = job.location?.location_str?.trim();
if (primary) return primary;
const parts = (job.locations ?? [])
.map(stringifyLocation)
.filter(Boolean);
return parts.join(' / ') || 'Unknown';
}
function isRemoteJob(job, locationLabel) {
const structured =
Boolean(job.location?.telecommuting) ||
job.location?.workplace_type?.toLowerCase() === 'remote' ||
(job.locations ?? []).some(
(loc) =>
Boolean(loc.telecommuting) ||
loc.workplace_type?.toLowerCase() === 'remote',
);
return structured || /remote/i.test(locationLabel);
}

Normalize to a stable shape

function normalizeWorkableJob(job, companyName) {
const location = resolveLocation(job);
const description = `${job.description ?? ''} ${job.full_description ?? ''}`.trim();
return {
id: job.id ?? job.shortcode,
title: job.title.trim(),
company: companyName,
location,
isRemote: isRemoteJob(job, location),
url: job.url || job.shortlink,
postedAt: job.created_at
? new Date(job.created_at)
: job.published_on
? new Date(job.published_on)
: null,
experience: job.experience ?? null,
description,
};
}

Optional companion calls enrich filters:

const [locationsRes, departmentsRes] = await Promise.all([
fetch(`https://www.workable.com/api/accounts/${accountSlug}/locations`),
fetch(`https://www.workable.com/api/accounts/${accountSlug}/departments`),
]);
const { locations } = await locationsRes.json();
const { departments } = await departmentsRes.json();

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.

Integration with Ashby public jobs API

June 29, 2026

Ashby is an applicant tracking system (ATS) used by many startups and scale-ups. Ashby exposes a lightweight, unauthenticated Job Postings API so you can list published openings on a custom careers page or aggregate jobs into a job board.

This post shows how to fetch public job listings from Ashby with Node.js, normalize the response, and filter to listed roles only. For other ATS public feeds, see the Greenhouse, Workable, and Lever posts.

Prerequisites

  • Node.js version 26
  • A company's Ashby job board name (see below)
  • No API key required for the public posting API

Find the job board name

Open the company's Ashby-hosted careers page. The URL looks like https://jobs.ashbyhq.com/{JOB_BOARD_NAME}. The last path segment is the identifier you pass to the API.

Examples: notion for Notion (https://jobs.ashbyhq.com/notion), ashby for Ashby itself.

Ashby also offers authenticated endpoints (for example jobPosting.list) for customers who need filters or private data. The public API documented here is read-only and scoped to one job board at a time.

API overview

ItemValue
Base URLhttps://api.ashbyhq.com/posting-api/job-board/{JOB_BOARD_NAME}
AuthNone for read access
FormatJSON
Optional queryincludeCompensation=true adds salary bands when the employer exposes them

Common fields on each job in jobs[]:

FieldDescription
titleJob title
locationPrimary location label
secondaryLocationsAdditional offices or regions
isRemote, workplaceTypeRemote / hybrid / on-site hints
isListedWhen false, the role is unlisted and should not appear on a public board
jobUrl, applyUrlHosted Ashby pages
descriptionPlainPlain-text description (when provided)
publishedAtPublish timestamp

Basic integration

Fetch all published jobs for one board:

const boardName = process.env.ASHBY_BOARD_NAME ?? 'notion';
const url = new URL(
`https://api.ashbyhq.com/posting-api/job-board/${encodeURIComponent(boardName)}`,
);
url.searchParams.set('includeCompensation', 'true');
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Ashby API ${response.status}: ${response.statusText}`);
}
const data = await response.json();
const jobs = data.jobs ?? [];
for (const job of jobs) {
console.log(job.title, '-', job.location, '-', job.jobUrl);
}

Filter to roles that are safe to show publicly and have a link:

const publicJobs = jobs.filter(
(job) => (job.isListed ?? true) && job.title && (job.jobUrl || job.applyUrl),
);

Normalize to a stable shape

Different ATS products use different field names. Map Ashby jobs into a schema your app owns:

function normalizeAshbyJob(job, companyName) {
const locations = [job.location, ...(job.secondaryLocations ?? []).map((s) => s.location)]
.map((value) => value?.trim())
.filter(Boolean);
const isRemote =
Boolean(job.isRemote) ||
job.workplaceType?.toLowerCase() === 'remote' ||
locations.some((loc) => /remote/i.test(loc));
return {
id: job.id ?? `${job.title}:${job.publishedAt}`,
title: job.title.trim(),
company: companyName,
location: locations.join(' | ') || 'Unknown',
isRemote,
url: job.jobUrl || job.applyUrl,
postedAt: job.publishedAt ? new Date(job.publishedAt) : null,
description: job.descriptionPlain ?? '',
compensation: job.compensation ?? null,
};
}

When the primary location is generic (Remote, Hybrid) but postal data includes a country, append the country so filters still work:

function enrichLocation(location, country) {
const label = location?.trim();
const c = country?.trim();
if (!label || !c) return label ?? '';
if (/^(remote|hybrid|on-?site)$/i.test(label) && !label.toLowerCase().includes(c.toLowerCase())) {
return `${label}, ${c}`;
}
return label;
}

Ashby may return "European Union" in address.postalAddress.addressCountry; treat that as a region hint, not a country code.