Back to articles

How to Scrape Lever Job Listings

CleanJobData Engineering

Lever's public postings API is better structured than Greenhouse's in a couple of specific ways worth knowing before you build around it — most notably, it can return a real salary range object, not just freeform text. Here's the full shape and what to actually expect from it.

Finding the Company Slug

A Lever-hosted careers page lives at https://jobs.lever.co/{company}. The slug is right there in the URL — visit the company's careers page (usually linked from their own site's "Careers" or "Jobs" footer link) and copy it. As with any ATS, this only gets you the slug for a company whose careers page you already know — there's no public directory mapping every company to its Lever slug, and finding those at scale across thousands of employers is a separate, much harder problem than fetching one board you already have the address for.

Fetching the Full Posting List in One Call

curl "https://api.lever.co/v0/postings/acme?mode=json"

The mode=json parameter matters — without it, this endpoint serves an HTML-rendered careers page instead of JSON. With it, you get the full array of posting objects, including description content, in a single request. Like Greenhouse's content=true, there's no need for a separate per-job detail call for the common case.

The Full Response Shape

[
  {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "text": "Senior Backend Engineer",
    "hostedUrl": "https://jobs.lever.co/acme/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "applyUrl": "https://jobs.lever.co/acme/a1b2c3d4-e5f6-7890-abcd-ef1234567890/apply",
    "createdAt": 1751097600000,
    "categories": {
      "location": "San Francisco, CA",
      "commitment": "Full-time",
      "allLocations": ["San Francisco, CA", "Remote - US"]
    },
    "country": "US",
    "workplaceType": "hybrid",
    "salaryRange": {
      "min": 150000,
      "max": 180000,
      "currency": "USD",
      "interval": "per-year-salary"
    },
    "description": "<p>We're looking for...</p>",
    "lists": [
      { "text": "Requirements", "content": "<ul><li>5+ years...</li></ul>" },
      { "text": "Responsibilities", "content": "<ul><li>Own the...</li></ul>" }
    ],
    "additional": "<p>Lever is an equal opportunity employer...</p>"
  }
]

Field-by-field:

  • id — a UUID, not a sequential number like Greenhouse's. Stable and safe as a dedup key.
  • hostedUrl — the canonical, cleaner application link; prefer this over applyUrl, which points to an /apply sub-path variant of the same posting and is mainly useful if you specifically want to link straight to the application form rather than the posting page.
  • createdAt — a Unix timestamp in milliseconds, not an ISO string like Greenhouse uses. A common bug: passing this straight into a date parser that expects seconds produces a date decades in the future — divide by 1000 first if your date library expects seconds, or use new Date(createdAt) directly in JS, which already expects milliseconds.
  • categories.location — same freeform-text problem as every other ATS: "San Francisco, CA", "NYC", "Remote", all inconsistent between postings.
  • categories.allLocations — an array covering multi-location postings. Worth capturing separately, since categories.location alone often only shows the first or primary location and silently drops the others.
  • workplaceType — actually structured ("remote" / "hybrid" / "on-site", in practice), unlike Greenhouse's total absence of a remote flag. Still worth cross-checking against categories.location text, since not every company fills this field in consistently — some leave it blank and only mention remote status in the location string or description.
  • salaryRange — a real structured object when present: min, max, currency, and interval (values in practice include yearly, monthly, weekly, daily, and hourly variants). Not every posting has one — salary disclosure depends on the employer and, in some regions, local law — but when it's there, it's genuinely parseable without touching free text. If you need everything normalized to an annual figure, you'll need to convert non-yearly intervals yourself (multiply hourly by ~2080, daily by ~260, weekly by 52, monthly by 12 — these are approximations, not exact, since they assume a standard work year).
  • description — the main HTML body.
  • lists — separate structured sections (commonly "Requirements," "Responsibilities," "Benefits" — the section names themselves are whatever the company typed in, not a fixed enum), each with its own HTML content. If you only read description you'll miss these — a posting's actual requirements are often entirely inside a lists entry, not the main description.
  • additional — closing boilerplate (EEO statements, disclaimers), also HTML, usually not useful to show prominently in a UI but worth keeping for completeness.

The Real Pain Points

Composing the full description takes three fields, not one. description, lists, and additional are all separate HTML fragments that together make up what a human reading the actual careers page sees as one continuous posting. Render only description and you'll produce postings that look truncated or missing their requirements section.

Salary coverage is inconsistent and interval math is approximate. Even among companies that fill in salaryRange, the interval conversion to a comparable annual figure involves an assumption about hours/days worked per year that won't be exactly right for every role.

categories.commitment isn't a fixed enum. Values like "Full-time", "Contract", "Part-time" are typed by whoever set up the posting template — expect casing and wording variance across companies, not a small closed set of values you can switch on safely without normalizing first.

No webhooks, same as every ATS in this category. Poll and diff ids to detect closures.

When This Is Enough

For one company's careers page, or a handful you're tracking manually, Lever's API is solid — better structured than most alternatives, and the salaryRange object alone can save real parsing work if the companies you care about tend to disclose pay.

When It Isn't

The moment you're aggregating across companies — some on Lever, some on Greenhouse, some on Workday — you're back to reconciling three (or more) different response shapes, three different salary representations (or none), and three different location conventions into one schema. That reconciliation work, done once and normalized across ATS platforms, is what a data API like JobsDataAPI exists to skip.