All platforms

Lever Jobs API.

Pull complete postings — full HTML descriptions, salary ranges, and every location — from any Lever board in a single unauthenticated JSON request.

Get API access
Lever
Live
300K+jobs indexed monthly
<3haverage discovery time
1hrefresh interval
Companies using Lever
NetflixSpotifyAircall360Learning
Developer tools

Try the API.

Test Jobs and Feed endpoints against https://connect.jobo.world with live request/response examples, then copy ready-to-use curl commands.

What's in every response.

Data fields, real-world applications, and the companies already running on Lever.

Data fields
  • Full HTML job descriptions
  • Structured salary ranges
  • Department & team fields
  • Workplace type (remote/hybrid/onsite)
  • Multi-location listings
  • Employment type & posted date
Use cases
  1. 01Growth-company hiring signals
  2. 02Tech talent sourcing
  3. 03Compensation benchmarking
  4. 04Remote-role aggregation
Trusted by
NetflixSpotifyAircall360Learning
DIY GUIDE

How to scrape Lever.

Step-by-step guide to extracting jobs from Lever-powered career pages—endpoints, authentication, and working code.

RESTbeginnerNo published limit; robots.txt sets a 1-second crawl-delayNo auth

Extract the Company Slug (US or EU)

Lever boards live on two shards: US at jobs.lever.co and EU at jobs.eu.lever.co. One regex captures the company slug from either host so you can build the matching API URL.

Step 1: Extract the Company Slug (US or EU)
import re

# Lever boards live on two shards:
#   US: https://jobs.lever.co/{company}
#   EU: https://jobs.eu.lever.co/{company}
# A single pattern captures the slug from either host.
url = "https://jobs.lever.co/spotify"
match = re.search(r"jobs\.(?:eu\.)?lever\.co/([^/?#]+)", url)

if match:
    company_slug = match.group(1)
    print(f"Company slug: {company_slug}")  # "spotify"
else:
    print("Invalid Lever URL")

Fetch All Jobs in One Request

Call Lever's public postings API to get every job with full details in a single request. Route EU boards to api.eu.lever.co; if a slug 404s on one shard, retry the other before treating the company as gone.

Step 2: Fetch All Jobs in One Request
import requests

US_API_HOST = "api.lever.co"
EU_API_HOST = "api.eu.lever.co"

def get_lever_jobs(company_slug: str, eu_shard: bool = False) -> list:
    """Fetch every posting from a Lever board in a single request.

    EU tenants (jobs.eu.lever.co) must be queried on api.eu.lever.co;
    US tenants use api.lever.co. A 404 on one shard does not always mean
    the company is gone — tenants migrate between US and EU.
    """
    api_host = EU_API_HOST if eu_shard else US_API_HOST
    url = f"https://{api_host}/v0/postings/{company_slug}?mode=json"

    response = requests.get(url, timeout=10)
    if response.status_code == 404:
        raise ValueError(f'Company "{company_slug}" not found on {api_host}')

    response.raise_for_status()
    return response.json()

# Usage
jobs = get_lever_jobs("spotify")
print(f"Found {len(jobs)} jobs")

Parse Job Details from the Response

Flatten each posting into a structured record. The response carries rich metadata: department, team, employment type, workplace type, salary range, and a millisecond createdAt timestamp.

Step 3: Parse Job Details from the Response
from datetime import datetime, timezone

def parse_lever_job(job: dict) -> dict:
    """Parse a single Lever posting into a flat structure."""
    categories = job.get("categories", {})
    salary = job.get("salaryRange")
    created_at = job.get("createdAt")

    return {
        "id": job.get("id"),
        "title": job.get("text"),
        "location": categories.get("location"),
        "all_locations": categories.get("allLocations") or [categories.get("location")],
        "department": categories.get("department"),
        "team": categories.get("team"),
        "employment_type": categories.get("commitment"),
        "workplace_type": job.get("workplaceType"),  # remote / hybrid / onsite
        "description_plain": job.get("descriptionPlain"),
        "description_html": job.get("description"),
        "description_body": job.get("descriptionBody"),
        "opening": job.get("opening"),
        "additional": job.get("additional"),  # benefits / perks
        "lists": job.get("lists", []),         # structured requirements
        "salary": {
            "min": salary.get("min"),
            "max": salary.get("max"),
            "currency": salary.get("currency"),
            "interval": salary.get("interval"),
        } if salary else None,
        "apply_url": job.get("applyUrl"),
        "view_url": job.get("hostedUrl"),
        "country": job.get("country"),
        # Lever's createdAt is a Unix timestamp in milliseconds.
        "posted_at": datetime.fromtimestamp(created_at / 1000, tz=timezone.utc) if created_at else None,
    }

jobs = get_lever_jobs("spotify")
parsed = [parse_lever_job(job) for job in jobs]
print(parsed[0])

Assemble the Full Job Description

Rebuild a complete HTML description from Lever's content sections. Prefer opening + descriptionBody, or description on its own — never concatenate description with descriptionBody, because Lever's description already contains the body.

Step 4: Assemble the Full Job Description
def build_full_description(job: dict) -> str:
    """Reassemble a complete HTML description from Lever's sections."""
    parts = []

    # opening + descriptionBody is the structured pair; otherwise fall back
    # to description alone. Mixing description with descriptionBody would
    # duplicate the body, which is already inside description.
    if job.get("opening"):
        parts.append(job["opening"])
        if job.get("descriptionBody"):
            parts.append(job["descriptionBody"])
    elif job.get("description"):
        parts.append(job["description"])
    elif job.get("descriptionBody"):
        parts.append(job["descriptionBody"])

    # Structured lists: requirements, responsibilities, etc.
    for lst in job.get("lists", []):
        if lst.get("text"):
            parts.append(f"<h3>{lst['text']}</h3>")
        if lst.get("content"):
            parts.append(f"<ul>{lst['content']}</ul>")

    # Benefits / perks
    if job.get("additional"):
        parts.append(job["additional"])

    return "\n".join(p for p in parts if p)

for job in jobs:
    html = build_full_description(job)
    print(f"{job.get('text')}: {len(html)} chars")

Rate-Limit When Scraping Many Boards

When collecting several boards in a row, keep one request in flight at a time and pause between companies. Lever's robots.txt specifies a 1-second crawl-delay.

Step 5: Rate-Limit When Scraping Many Boards
import time

def scrape_many(slugs: list, delay_seconds: float = 1.0) -> dict:
    """Scrape several Lever boards back-to-back, one request at a time.

    Lever's robots.txt sets a 1-second crawl-delay, so keep a single
    in-flight request per board and pause between companies.
    """
    results = {}
    for slug in slugs:
        try:
            jobs = get_lever_jobs(slug)
            results[slug] = {"jobs": jobs, "error": None}
            print(f"[OK]   {slug}: {len(jobs)} jobs")
        except ValueError:
            results[slug] = {"jobs": [], "error": "not_found"}
            print(f"[SKIP] {slug}: not found")
        except requests.RequestException as exc:
            results[slug] = {"jobs": [], "error": str(exc)}
            print(f"[ERR]  {slug}: {exc}")

        time.sleep(delay_seconds)  # respect robots.txt crawl-delay

    return results

companies = ["spotify", "aircall", "actian", "anchorage"]
results = scrape_many(companies)
print(f"Total jobs: {sum(len(r['jobs']) for r in results.values())}")
Common issues
highjobs.eu.lever.co tenants return 404 on api.lever.co

EU boards are served from api.eu.lever.co, not the US host. Route jobs.eu.lever.co companies to api.eu.lever.co, and if a slug 404s on one shard, probe the other before concluding the board is gone — tenants migrate between US and EU.

mediumCompany slug 404s on both shards

The board was removed, the slug was renamed, or the public API was disabled. Confirm the live jobs.lever.co / jobs.eu.lever.co URL in a browser before retiring the company.

medium403, 401, or 429 responses

A 403 usually means IP-level blocking and 429 means rate limiting — back off and retry after a delay. A 401 means that board requires authentication and cannot be scraped anonymously.

lowsalaryRange is missing

Most boards do not publish pay. Treat salaryRange (and its min/max/currency/interval) as optional and null-check it before reading, rather than assuming it is always present.

lowDuplicated body text in descriptions

Lever's description field already contains the body, so do not concatenate it with descriptionBody. Use opening + descriptionBody, or description alone, and reach for descriptionPlain when you need clean text.

mediumEmpty postings array returned

The board may have no open roles, or all postings may be unlisted/internal. Verify against the live job board in a browser before treating an empty array as an error.

Best practices
  1. 1Route US boards to api.lever.co and EU boards (jobs.eu.lever.co) to api.eu.lever.co
  2. 2On a 404, retry the opposite shard before marking a company as gone
  3. 3Respect the robots.txt 1-second crawl-delay and keep one request in flight per board
  4. 4Strip query strings from hostedUrl to get a stable listing URL
  5. 5Prefer opening + descriptionBody (or description alone) to avoid duplicating the body
  6. 6Cache responses — Lever boards change infrequently
Or skip the complexity

One endpoint. All Lever jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=lever" \
  -H "X-Api-Key: YOUR_KEY"
Ready to integrate

Access Lever
job data today.

One API call. Structured data. No scraping infrastructure to build or maintain — start with the free tier and scale as you grow.

99.9%API uptime
<200msAvg response
50M+Jobs processed