Lever Jobs API.
Pull complete postings — full HTML descriptions, salary ranges, and every location — from any Lever board in a single unauthenticated JSON request.
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.
- Full HTML job descriptions
- Structured salary ranges
- Department & team fields
- Workplace type (remote/hybrid/onsite)
- Multi-location listings
- Employment type & posted date
- 01Growth-company hiring signals
- 02Tech talent sourcing
- 03Compensation benchmarking
- 04Remote-role aggregation
How to scrape Lever.
Step-by-step guide to extracting jobs from Lever-powered career pages—endpoints, authentication, and working code.
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")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")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])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")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())}")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.
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.
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.
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.
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.
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.
- 1Route US boards to api.lever.co and EU boards (jobs.eu.lever.co) to api.eu.lever.co
- 2On a 404, retry the opposite shard before marking a company as gone
- 3Respect the robots.txt 1-second crawl-delay and keep one request in flight per board
- 4Strip query strings from hostedUrl to get a stable listing URL
- 5Prefer opening + descriptionBody (or description alone) to avoid duplicating the body
- 6Cache responses — Lever boards change infrequently
One endpoint. All Lever jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=lever" \
-H "X-Api-Key: YOUR_KEY" 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.