Integration with Lever public jobs API
DEV Community

Integration with Lever public jobs API

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

Item Value
US base https://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 Description
mode=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);
}

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.

Comments

No comments yet. Start the discussion.