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: unlimit → https://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
Item Value US basehttps://api.lever.co/v0/postings/{site_slug}
EU base
https://api.eu.lever.co/v0/postings/{site_slug}
Auth
None
Format
JSON array (mode=json)
Common query parameters:
Parameter Descriptionmode=json
Return JSON instead of HTML
skip, limit
Pagination (limit defaults to 100)
team, department, location, commitment
Filters; repeat a key for multiple values
group
Group 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);
}
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode
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;
}
}
Enter fullscreen mode Exit fullscreen mode
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');
Enter fullscreen mode Exit fullscreen mode
Fetch a single posting:
const detailUrl = `https://api.lever.co/v0/postings/${siteSlug}/${postingId}?mode=json`;
Enter fullscreen mode Exit fullscreen mode
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),
};
}
Enter fullscreen mode Exit fullscreen mode
Only published postings appear in this API. Confidential or internal-only roles are never returned.
Need help with your project?
Get personalized advice on your architecture, code, or career in a 45-minute 1-on-1 consultation.
답글 남기기