--- name: lever-jobs-scraper description: Read public Lever-hosted careers sites as structured records via the Apify Actor arman-bd/lever-jobs-scraper. Returns one row per posting with title, team, department, level, commitment, workplace type, primary and additional locations, country, requirement and responsibility bullets split into arrays, the structured salary range where a company publishes one, the plain-text description and the direct apply URL. Use when a task needs bulk job postings, hiring-signal tracking across a portfolio, a normalised aggregator feed or pay-transparency research. Not for applicant data, candidate submission, or boards on other ATS platforms. --- # Lever Jobs Scraper Apify Actor `arman-bd/lever-jobs-scraper`. Give it a list of Lever company slugs, get one dataset record per public posting. It takes no credentials of your own. A board with a few hundred postings comes back in one pass, with the requirement blocks already split into bullet arrays. ## When to use it - A list of companies has to become a table of open roles with teams and locations. - Tracking hiring signals across a portfolio or a competitor set, week over week. - Ingesting many careers sites on a schedule into one normalised job feed. - Counting remote supply by team or by sector from `workplaceType`. - Collecting the structured `salaryRange` objects that pay-transparency rules put on public postings. ## When not to use it - Boards on Greenhouse, Ashby, Workable, Workday, Personio or Teamtailor. Those are separate Actors; this one only reads Lever. - Applying to a job, or anything on the employer side of a Lever account. - Candidate or applicant data of any kind. None is touched. - A single lookup of one known posting. This Actor earns its keep on lists. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/lever-jobs-scraper').call({ companies: ['leverdemo', 'https://jobs.lever.co/spotify'], searchTerms: ['engineer'], workplaceType: ['remote'], maxJobsPerCompany: 50, includeDescription: true, }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); const { value: summary } = await client .keyValueStore(run.defaultKeyValueStoreId) .getRecord('RUN_SUMMARY'); ``` One-shot over HTTP, when you want the rows back in the same request: ```bash curl -X POST "https://api.apify.com/v2/acts/arman-bd~lever-jobs-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"companies":["leverdemo"],"searchTerms":["engineer"],"maxJobsPerCompany":25}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/lever-jobs-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `companies` | string[] | yes | | Company slugs (`leverdemo`) or full careers URLs. Mixed forms are fine and are normalised to lower-case slugs. Duplicates are removed before any request. | | `searchTerms` | string[] | no | `[]` | Keep only postings whose **title** contains one of these terms, case-insensitive substring. Empty keeps everything. | | `teamFilter` | string[] | no | `[]` | Matched against team and department concatenated, so one term can hit either. Case-insensitive substring. | | `commitmentFilter` | string[] | no | `[]` | Substring match on the commitment string, which companies word freely: `full` catches both `Full-time` and `Regular Full Time (Salary)`. | | `workplaceType` | string[] | no | `[]` | **Exact** match on `remote`, `onsite`, `hybrid` or `unspecified`. Spaces and hyphens are stripped from your values, so `on-site` and `on site` both become `onsite`. Unlike the other filters this is not a substring match. | | `locationFilter` | string[] | no | `[]` | Matched against the primary location and every entry in `allLocations`, joined. Case-insensitive substring. | | `maxJobsPerCompany` | integer | no | `0` | Cap saved rows per company, applied **after** filtering. `0` means no limit. Minimum `0`. | | `includeDescription` | boolean | no | `true` | Include `descriptionPlain`, `lists` and `additionalPlain`. Off sets all three to `null` and makes the dataset far smaller. | **`includeDescription` is the size lever, not a cost lever.** A board arrives in one response whatever you ask for, so filters and caps trim the dataset rather than the fetch; a capped run is not a cheaper run. The three description fields are most of the payload, so turn them off for change detection over many companies and on for anything that has to read the role. All five filters combine with AND: a posting must match at least one term in each filter you actually filled in. ## Output One record per posting that passed the filters. | Field | Type | Notes | |---|---|---| | `company` | string | The slug this row came from, normalised. Join back to your input list on this. | | `postingId` | string | The posting's UUID. Stable primary key. | | `title` | string \| null | Job title. Lever's wire field is called `text`; it is renamed here and nowhere else is renamed. | | `team` | string \| null | Team from the category taxonomy. | | `department` | string \| null | Only set by companies that use departments. Frequently `null`. | | `level` | string \| null | Only set by companies that use levels. Frequently `null`. | | `commitment` | string \| null | Free-text employment type: `Full-time`, `Contract`, `Internship` and many local variants. | | `workplaceType` | string \| null | `remote`, `onsite`, `hybrid` or `unspecified`. | | `location` | string \| null | Primary location string as published. | | `allLocations` | string[] | Every location on the posting. Empty array when none is listed. | | `country` | string \| null | ISO country code, when the posting carries one. | | `categories` | object | The raw category object, kept verbatim. Useful when a company puts something in it that the flat fields do not cover. | | `salaryRange` | object \| null | `{ min, max, currency, interval }` when the company filled it in, otherwise `null`. | | `salaryDescription` | string \| null | Free-text pay note, when published. | | `descriptionPlain` | string \| null | Opening and body as plain text. `null` when `includeDescription` is off, `""` when the posting has none. | | `lists` | object[] \| null | Requirement and responsibility blocks as `{ text, items[] }`, bullets split into an array. `null` when `includeDescription` is off. | | `additionalPlain` | string \| null | The closing section, usually the "about the company" text. `null` when `includeDescription` is off. | | `hostedUrl` | string \| null | The posting page. | | `applyUrl` | string \| null | The direct application form. | | `createdAt` | string \| null | Posting creation time, ISO 8601, converted from epoch milliseconds. | | `scrapedAt` | string | Run timestamp, ISO 8601. | A real record, `descriptionPlain` trimmed: ```json { "company": "ro", "postingId": "d2e897ae-3b4c-4219-ab0d-0403decc2115", "title": "Engineering Manager, Core Infrastructure", "team": "Engineering", "department": null, "level": null, "commitment": "Full-time", "workplaceType": "hybrid", "location": "New York, NY", "allLocations": ["New York, NY"], "country": "US", "categories": { "commitment": "Full-time", "location": "New York, NY", "team": "Engineering", "allLocations": ["New York, NY"] }, "salaryRange": null, "salaryDescription": null, "descriptionPlain": "Join Tech @ Ro to build the future of healthcare, from the ground up …", "lists": [ { "text": "What You'll Do", "items": [ "Lead and develop a Core Infrastructure team responsible for production-critical systems and foundational engineering capabilities.", "Own execution across the team's portfolio: clarify priorities, sequence work, manage tradeoffs, and ensure high-quality delivery." ] } ], "additionalPlain": "The target base salary for this position ranges from $219,300 to $260,000 …", "hostedUrl": "https://jobs.lever.co/ro/d2e897ae-3b4c-4219-ab0d-0403decc2115", "applyUrl": "https://jobs.lever.co/ro/d2e897ae-3b4c-4219-ab0d-0403decc2115/apply", "createdAt": "2026-06-15T13:33:29.004Z", "scrapedAt": "2026-08-06T11:40:18.574Z" } ``` A filled-in `salaryRange` looks like `{ "min": 108097, "max": 135121, "currency": "USD", "interval": "per-year-salary" }`. ## RUN_SUMMARY Written to the run's key-value store under the key `RUN_SUMMARY`. **Read it.** It is where a partial run admits that it was partial. ```json { "companiesRequested": 2, "companiesFailed": 0, "failures": [], "jobsSaved": 212, "filters": { "searchTerms": ["engineer"], "teamFilter": [], "commitmentFilter": [], "workplaceType": ["remote"], "locationFilter": [], "maxJobsPerCompany": 50 }, "finishedAt": "2026-08-06T11:40:21.004Z" } ``` `companiesFailed` above zero means part of your list is missing from the dataset, and `failures` names each slug with its error, usually a wrong slug. Zero rows for a company that did not fail means the board is empty or your filters excluded everything, and the two are told apart in the run log, which prints listed, matched and saved counts per company. Note that `filters` echoes your raw input and does not include `includeDescription`. ## Behaviour to plan around - **`workplaceType` is the only exact-match filter.** Every other filter is a case-insensitive substring test. Passing `remo` to `searchTerms` matches; passing it to `workplaceType` matches nothing. - **Filters run after the fetch.** A board's entire posting set arrives in one response, so `maxJobsPerCompany` truncates results rather than paging through them, and it is applied after the filters. `searchTerms: ["engineer"]` with `maxJobsPerCompany: 25` gives 25 engineering roles, not 25 postings some of which are engineering. - **`salaryRange` is frequently `null` even when pay is disclosed.** Many companies state the band in prose instead, which lands in `additionalPlain` or `salaryDescription`. Do not conclude a role has no published pay from `salaryRange` alone. - **`department` and `level` are usually `null`.** They exist only for companies that use that part of the taxonomy. Group on `team` for anything that has to work across companies. - **`lists` splits bullets, it does not strip them.** Requirement blocks arrive as a bare run of list elements, and each block becomes `{ text, items[] }`, so requirements can be counted or filtered without touching markup. - **An empty board is not a failure.** A valid slug with no open roles yields zero rows and no entry in `failures`; a wrong slug yields an entry in `failures`. - **One bad slug never aborts the run.** The Actor only throws when every company failed. - **Requests get a long timeout on purpose.** Large boards return multi-megabyte payloads and can take around a minute and a half, so each request is given up to two minutes, with three attempts and linear backoff. An unknown company and an unexpected response shape fail immediately and are not retried. - **`createdAt` is a real first-published date,** so a daily scheduled run keyed on `postingId` reconstructs a hiring timeline, and a `postingId` that disappears is a closed or filled role. ## Recipes **Portfolio hiring tracker.** Everything, minimal payload, run weekly. ```json { "companies": ["leverdemo", "spotify", "ro"], "includeDescription": false } ``` Group by `company` and `team` and diff row counts against the previous run. Turning descriptions off keeps the dataset small enough to store every snapshot. **Remote engineering sourcing feed.** Three filters with AND, capped per company. ```json { "companies": ["leverdemo", "https://jobs.lever.co/ro"], "searchTerms": ["engineer", "developer"], "teamFilter": ["engineering"], "workplaceType": ["remote"], "maxJobsPerCompany": 50, "includeDescription": true } ``` Hand `title`, `location`, `applyUrl` and the first block of `lists` to a sourcer. **Pay-transparency research.** Descriptions on, because half the pay data lives in prose. ```json { "companies": ["leverdemo", "ro"], "locationFilter": ["new york", "california", "washington"], "includeDescription": true } ``` Take `salaryRange` where it exists and fall back to parsing `salaryDescription` and `additionalPlain` where it does not. **Change detection on one board.** Cheapest useful shape. ```json { "companies": ["spotify"], "includeDescription": false } ``` Diff the set of `postingId` values between runs: new ids are new roles, missing ids are closed ones. **Finding a slug.** It is the company path segment of the careers URL: for `https://jobs.lever.co/acme` and `https://jobs.eu.lever.co/acme/1234-5678` the slug is `acme` in both cases. You can paste the whole URL and the Actor will extract it.