Sipki, LLC

How to pull job listings from Greenhouse, Lever and Ashby without scraping

If you track hiring at a list of companies — for a job board, a recruiting pipeline, or a "who is hiring" signal for sales — you quickly notice that a large share of tech companies host their careers pages on one of three applicant tracking systems: Greenhouse, Lever or Ashby.

The good news: you do not need to scrape their HTML. All three publish a public, documented job-board API meant for reading published postings. No login, no browser, no proxy.

The less good news: the three APIs return three different shapes, and the details that matter most — salary, remote status, posting date — live in different places in each one.

This post walks through the endpoints, the differences that bite, and the trade-offs between doing it yourself and using a ready Actor.

The three endpoints

ATSPublic endpointRequests per company
Greenhouseboards-api.greenhouse.io/v1/boards/{token}/jobs?content=true1
Leverapi.lever.co/v0/postings/{company}?mode=json1 per 500 jobs
Ashbyapi.ashbyhq.com/posting-api/job-board/{name}?includeCompensation=true1

The {token} / {company} / {name} is the board name you see in the company's job URL, for example job-boards.greenhouse.io/discord or jobs.lever.co/spotify.

That is the whole "API key" story: there is none. One GET per company gives you every open posting.

Where it gets fiddly

1. Salary lives in three places. Ashby can return structured compensation when you ask for it. Lever has a salaryRange field on some postings. Greenhouse usually has nothing structured — but many US postings include a pay-transparency line in the description, such as "$139,200 - $235,200 USD". If you want salary across all three, you need a parser for the description text and a flag that says where each number came from.

2. Remote is not one field. Some boards set a workplace type, some only write "Remote" into the location string, some list several locations of which one is remote. Decide on one boolean and one rule for computing it.

3. Dates differ in precision. Treat "posted at" and "updated at" as separate fields and keep both, or your "new in the last 7 days" filter will lie.

4. Rate limits. Lever's robots.txt says Crawl-delay: 1. If you poll many Lever boards, keep to one request per second per host for the whole run — not per board.

5. Finding the board from a careers page. Users rarely give you a board token; they give you company.com/careers. Often the page embeds the ATS and the board name is in the HTML. Sometimes the page is JavaScript-only and you can only guess the board name from the domain — and a guess can hit a board with the same name that belongs to another company. Mark guessed rows as unverified.

6. "Only new since last run". For monitoring, you want each scheduled run to return only postings you have not seen before. That needs state: a store of job IDs per watch-list, keyed so that the same board written two different ways is still the same board.

None of this is hard. It is just a few days of edge cases, and then maintenance.

The ready-made option

We packaged the above as an Apify Actor: Greenhouse, Lever & Ashby Jobs API — https://apify.com/sipki/ats-jobs-api

Input is a plain list. You can mix board URLs, careers pages, ats:token, or bare tokens:

{
  "companies": [
    "https://job-boards.greenhouse.io/discord",
    "lever:spotify",
    "https://linear.app/careers"
  ],
  "keywords": ["engineer", "developer"],
  "excludeKeywords": ["intern"],
  "remoteOnly": true,
  "postedAfter": "30 days",
  "descriptionFormat": "text",
  "includeDescriptions": true,
  "onlyNewSinceLastRun": false
}

Output is one flat row per job, the same fields for all three systems. A real row:

{
  "ats": "greenhouse",
  "company": "GitLab",
  "title": "Senior Software Engineer, Security Factory: Code Security",
  "location": "Remote, Canada; Remote, United States",
  "remote": true,
  "department": "Sec Engineering",
  "salary_min": 139200.0,
  "salary_max": 235200.0,
  "salary_currency": "USD",
  "salary_interval": "year",
  "salary_source": "description",
  "posted_at": "2026-10-10T00:31:32Z",
  "url": "https://job-boards.greenhouse.io/gitlab/jobs/8738179002",
  "apply_url": "https://job-boards.greenhouse.io/gitlab/jobs/8738179002"
}

(Abridged; the full row also has locations, workplace_type, team, employment_type, requisition_id, updated_at and the description as text or HTML.)

salary_source tells you whether the range came from structured ATS data or from the description text. Every row also says how its board was found (detected_by: url, explicit, page_html, token_probe or domain_guess), so you can drop guessed boards if you need certainty.

For monitoring, set onlyNewSinceLastRun: true and schedule it. The Actor stores job IDs in a key-value store in your own account, and each run returns — and charges for — only postings it has not returned before.

What we measured (and what we did not)

These are small runs. We have no figures for very large boards with descriptions on, and we do not claim any.

Price, and how it compares

The Actor charges $0.002 per job returned — $2 per 1,000 jobs. No start fee. Jobs removed by your filters and boards that fail are free, and the run stops exactly at your spending limit. The prefilled example (up to 60 jobs) costs at most $0.12.

Honest comparison of the options:

Limits

If you build it yourself, the endpoint table and the six gotchas above should save you a day. If you would rather not, the Actor is here: https://apify.com/sipki/ats-jobs-api

Not affiliated with Greenhouse Software, Inc., Lever (Employ Inc.) or Ashby. Names identify the public APIs only.