Skip to content

Lever API Guide: Data API, Resumes, Webhooks and Parsing

How the Lever API works: API key auth, opportunities, pagination, rate limits, resume downloads and webhooks, with copy-paste Python code.

Published · 6 minutes read

Cover reading “Lever API guide for developers” with a code icon and a candidate icon

The Lever API is a REST API at https://api.lever.co/v1 that reads and writes the data of a Lever account as JSON: opportunities, contacts, resumes, files, notes and postings. You authenticate with HTTP Basic auth, sending an API key as the username and an empty password. A separate public Postings API lists your published jobs.

Key facts

Base URLhttps://api.lever.co/v1
AuthBasic auth: API key as username, empty password. OAuth only for registered partner apps
FormatJSON over HTTPS
Paginationlimit (1 to 100), then next as offset while hasNext is true
Rate limits10 requests per second per key, bursts up to 20, not guaranteed; back off on 429
WebhooksOne URL per event, HMAC SHA-256 signature, 5 retries
Resume filesGET /opportunities/:opportunity/resumes/:resume/download
Job postingsPublic Postings API at https://api.lever.co/v0/postings/, no key to read
DocsLever developer documentation and Postings API, checked November 12, 2026

1. Create an API key

  • A Super Admin creates the key on the API Credentials tab of the Integrations and API page in Lever's settings.
  • Each key has its own permissions: a missing or invalid key returns 401, an endpoint the key can't use returns 403.
  • Confidential postings and opportunities need confidential access, and it can only be granted when the key is created.
  • Test it with curl -u API_KEY: https://api.lever.co/v1/opportunities.

OAuth is only for products that many Lever customers connect, through Lever's partner program. Its access tokens expire after one hour and use scopes such as opportunities:read:admin. For your own stack, a key is all you need, and you can keep several active at once, one per integration.

2. List opportunities

A contact is a person, and an opportunity is one candidacy of that person, so one contact can have several. The old /candidates endpoints have been deprecated since 2020: use /opportunities for new code.

Filter with posting_id, stage_id, tag, archived or the created_at_start/ updated_at_start ranges, in milliseconds. Pass confidentiality=all if you also want confidential opportunities. This client walks every page and backs off on 429 and 503, wich are the two statuses Lever says to retry:

import os
import time

import requests

LEVER_API = "https://api.lever.co/v1"

session = requests.Session()
session.auth = (os.environ["LEVER_API_KEY"], "")  # key as username, empty password


def lever_get(path: str, params: dict | None = None, max_attempts: int = 6):
    """GET a Data API path, backing off on 429 (rate limit) and 503 (maintenance)."""
    for attempt in range(max_attempts):
        response = session.get(f"{LEVER_API}{path}", params=params, timeout=30)
        if response.status_code not in (429, 503):
            return response
        time.sleep(min(2**attempt, 30))
    return response


def list_opportunities(**filters):
    """Yield every opportunity matching the filters, page by page."""
    params = {"limit": 100, **filters}
    while True:
        response = lever_get("/opportunities", params)
        response.raise_for_status()
        page = response.json()
        yield from page["data"]
        if not page.get("hasNext"):
            break
        params["offset"] = page["next"]  # opaque token: never build one yourself


# Opportunities created in the last 24 hours (timestamps in milliseconds)
since = int((time.time() - 24 * 3600) * 1000)
for opportunity in list_opportunities(created_at_start=since):
    print(opportunity["id"], opportunity.get("contact"))

Honestly, the backoff matters more than it looks: a sync that fans out over thousands of opportunities hits 10 requests per second fast. Add expand=contact or expand=applications to inline related objects instead of making one extra call per opportunity.

One gotcha for incremental syncs: the updated_at filters match any change on the opportunity, while the updatedAt attribute only reflects some fields. Filter on the range and don't compare the attribute to your last run.

3. Download the resume

Lever keeps resumes and files apart. Resumes come from an application, a recruiter or the Chrome extension; files are everything else, like cover letters and portfolios, under /opportunities/:opportunity/files.

List an opportunity's resumes with GET /opportunities/:opportunity/resumes, then fetch the file from its /download endpoint. Use the API endpoint, not the downloadUrl field, which points to the Lever web app.

A 422 on download means Lever couldn't process the file. The file.status field warns you ahead of time with values like unsupported or error, and resumes added from an online profile can have no file at all. Treat both as a skip, not a retry:

def latest_resume_file(opportunity_id: str):
    """Return (file name, bytes) of the newest resume file, or None."""
    response = lever_get(f"/opportunities/{opportunity_id}/resumes")
    response.raise_for_status()
    # Resumes added from an online profile can come without a file
    resumes = [r for r in response.json()["data"] if r.get("file")]
    if not resumes:
        return None
    resume = max(
        resumes, key=lambda r: r["file"].get("uploadedAt") or r["createdAt"]
    )

    download = lever_get(
        f"/opportunities/{opportunity_id}/resumes/{resume['id']}/download"
    )
    if download.status_code == 422:
        return None  # Lever could not process this file
    download.raise_for_status()
    return resume["file"]["name"], download.content

4. Parse it with CV Extract

Lever's own parsedData only holds positions and schools. When you need skills, languages, contact details or the same schema across several sources, send the file to HireLayer CV Extract. It reads resumes in 70 languages, scans included.

One response gives you the candidate profile, every position and diploma with normalized dates, languages with a CEFR-based level, typed skills linked to the HireLayer Skills taxonomy, certifications and the full text.

Pass the Lever opportunity ID as application_id: it comes back in info_resume.application_id, so the two records stay linked.

HIRELAYER_PARSER = "https://hirelayer.co/api/v3/parser"


def parse_resume(file_name: str, content: bytes, application_id: str):
    """Send one resume file to HireLayer CV Extract and return its JSON."""
    response = requests.post(
        HIRELAYER_PARSER,
        headers={"X-API-Key": os.environ["HIRELAYER_API_KEY"]},
        files={"file": (file_name, content)},
        data={"application_id": application_id},
        timeout=150,  # a parse takes about 35 s, and the API stops at 145 s
    )
    if response.status_code == 422:
        return None  # not a resume, unreadable or no text: no credit used
    response.raise_for_status()
    return response.json()


for opportunity in list_opportunities(created_at_start=since):
    resume = latest_resume_file(opportunity["id"])
    if resume is None:
        continue
    parsed = parse_resume(*resume, application_id=f"lever-{opportunity['id']}")
    if parsed is None:
        continue
    print(
        opportunity["id"],
        parsed["info_candidate"]["job_title"],
        len(parsed["work_experiences"]),
        [s["skill_title"] for s in parsed["skills"]][:5],
    )

A trimmed response:

{
  "info_candidate": {
    "full_name": "Alex MORGAN",
    "job_title": "Senior Software Engineer",
    "experience_level": "5 to 10 years"
  },
  "work_experiences": [
    { "company_name": "Northstar Labs", "start_date": "2022-03-01", "end_date": null }
  ],
  "skills": [{ "skill_title": "React", "skill_type": "Software skill" }]
}
  • Cost: 1 credit per successful parse; failed calls are free. The Free plan includes 50 credits a month.
  • Timing: about 35 seconds per resume, with a 504 after 145 seconds. For async, send Prefer: respond-async with a webhook_url.
  • Errors: 422 is not a resume or has no text, 403 is out of credits, 5xx can be retried. See the errors guide.

In practice, results end up in a note, tags or a profile form, since parsedData is read-only through the API. The Lever integration page maps each field to its place in Lever.

5. Keep in sync with webhooks

A Super Admin adds one URL per event in the integration settings, for example applicationCreated, candidateStageChange or candidateHired. The payload only carries IDs, never the resume, so you still call steps 3 and 4 yourself.

Verify each request: HMAC SHA-256 of token + triggeredAt, keyed with your signature token, compared to signature. Answer 2xx quickly; anything else counts as a failure and Lever retries five times, so queue the real work and dont parse inline:

import hashlib
import hmac
import os


def is_from_lever(body: dict) -> bool:
    """Check the signature Lever puts in the body of each webhook request."""
    expected = hmac.new(
        os.environ["LEVER_SIGNATURE_TOKEN"].encode(),
        (body["token"] + str(body["triggeredAt"])).encode(),
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, body.get("signature", ""))


def handle_lever_event(body: dict) -> int:
    """Return the HTTP status to answer. Keep it fast: queue the real work."""
    if not is_from_lever(body):
        return 401
    if body["event"] == "applicationCreated":
        enqueue_parse(body["data"]["opportunityId"])  # your job queue
    return 200

If your handler was down, the webhooks page keeps up to 1,000 deliveries from the past two weeks, each with a Rerun button. Webhook payloads still carry a deprecated candidateId: read opportunityId instead.

Backfilling a whole account? The bulk parsing guide is the next read, espcially if you have thousands of opportunities.

Frequently asked questions

How do I get a Lever API key?

A Super Admin creates it on the API Credentials tab of the Integrations and API page and picks which endpoints it can reach.

What is the Lever API rate limit?

10 requests per second per API key, with bursts up to 20. Lever doesn't guarantee it and recommends exponential backoff on 429.

Are the Lever candidates endpoints deprecated?

Yes, since 2020. They still work, but new code should use /opportunities and read opportunityId in webhooks.

Can I download resumes with the Lever API?

Yes: list them with GET /opportunities/:opportunity/resumes, then call /resumes/:resume/download. A 422 means Lever couldn't process the file.

See what a parsed Lever resume looks like

Upload one of your candidates’ resumes in the live demo to see every JSON field, then check limits and errors in the API reference.

Sources and further reading

  1. Lever Developer: Data API documentation
  2. Lever Postings API (official GitHub repository)
  3. HireLayer: Lever integration
  4. HireLayer API documentation: Parse a resume (V3)
  5. HireLayer API documentation: Errors and retries

Louis Desclous

Published on · Reading time: 6 minutes