Disclosure: I build and maintain the Apify Actor used in this tutorial (ocius-data/ats-jobs-scraper). The ATS endpoints described below are public, and you can call them directly if you prefer.
If you build a job board, a recruiting tool, a sales-intelligence product ("which companies are hiring data engineers this month?") or a personal job alert, the cleanest source of jobs is not LinkedIn or Indeed. It is the applicant tracking system (ATS) each company uses to publish its openings. Most tech companies use one of four: Greenhouse, Lever, Ashby or Workday. Their public job boards are fresh (jobs appear the minute they are published) and free of reposts from aggregators.
The catch: each ATS has a different endpoint, a different schema and different quirks.
- Greenhouse:
boards-api.greenhouse.io/v1/boards/{token}/jobs. Simple JSON, salaries only on some boards. - Lever:
api.lever.co/v0/postings/{company}, plus a separate EU host for EU tenants. - Ashby:
api.ashbyhq.com/posting-api/job-board/{name}, with structured compensation. - Workday: a POST endpoint per tenant and site that stops at 2,000 results per search, so large boards are silently truncated unless you split the search.
This tutorial shows how to get all four in one normalized schema, and how to run it as a monitor that returns only new jobs and jobs that closed.
You need Python 3.11 or later, a free Apify account with its API token (Console, Settings, API and Integrations) and the official client: pip install "apify-client>=3" pandas.
Step 1: jobs from several companies in one call
from apify_client import ApifyClient client = ApifyClient("YOUR_APIFY_TOKEN") run = client.actor("ocius-data/ats-jobs-scraper").call(run_input={ "companies": [ "https://boards.greenhouse.io/stripe", "lever:spotify", "ashby:ramp", "https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite", ], "keywords": ["engineer"], "maxJobsPerCompany": 50, }) for job in client.dataset(run.default_dataset_id).iterate_items(): print(job["ats"], job["company"], job["title"], job["location"]) Inputs can be board URLs, explicit forms such as greenhouse:stripe, bare names such as stripe (looked up on Greenhouse, Lever and Ashby), or a company's own careers page that links to one of these systems. Workday always needs the full career site URL, because Workday tenant names cannot be guessed.
Step 2: one schema for every ATS
Every row has the same fields, whatever the source:
-
jobKey: a stable unique key (ats:board:jobId) to deduplicate and update rows -
title,department,team,location,countryCode -
workplaceType(REMOTE,HYBRID,ONSITE) andisRemote -
employmentTypenormalized toFULL_TIME,PART_TIME,CONTRACT,TEMPORARYorINTERNSHIP -
salaryMin,salaryMax,salaryCurrency,salaryPeriodandsalarySource(atswhen it came from a structured field,descriptionwhen it was parsed from the text) -
descriptionText,descriptionHtml,jobUrl,applyUrl,postedAt
That makes questions across companies trivial:
import pandas as pd df = pd.DataFrame(client.dataset(run.default_dataset_id).iterate_items()) # Remote share per company print(df.groupby("company")["isRemote"].mean().round(2)) # Published salary ranges for engineering roles, annual USD only pay = df[(df["salaryCurrency"] == "USD") & (df["salaryPeriod"] == "YEAR")] print(pay[["company", "title", "salaryMin", "salaryMax"]].sort_values("salaryMax", ascending=False).head(10)) Step 3: a monitor that returns only what changed
For a job board or an alert, you do not want the full list every day. You want new jobs, and you want to remove jobs that were filled or taken down. Two options handle this:
run = client.actor("ocius-data/ats-jobs-scraper").call(run_input={ "companies": ["https://boards.greenhouse.io/stripe", "lever:spotify", "ashby:ramp"], "keywords": ["engineer"], "onlyNewSinceLastRun": True, "reportClosedJobs": True, "monitoringStoreName": "engineering-monitor", }) for row in client.dataset(run.default_dataset_id).iterate_items(): if row["recordType"] == "closed": print("CLOSED", row["jobKey"], row["closedDetectedAt"]) else: print("NEW", row["jobKey"], row["title"]) - The first run returns every matching job and stores what was delivered.
- Later runs return only jobs not delivered before.
- With
reportClosedJobs, a job missing from two complete listings in a row is reported once asrecordType: "closed". The two-run confirmation prevents false closures when an ATS has a temporary error, and a partial or failed listing never produces closures.
Save the input as a task, schedule it every few hours, and connect a webhook or Google Sheets (use jobKey as the unique column). Your job board now adds new jobs and removes dead ones without manual cleanup.
Step 4: large Workday boards
Workday returns at most 2,000 results per search. The Actor detects when a board is larger and splits the search by job category and location until every job is read. In testing, one board returned 2,679 of 2,679 jobs.
Cost
At the time of writing: USD 0.003 per unique job on the Apify Free plan, less on paid plans, and USD 0.003 per closed job reported. Companies with no jobs, filtered-out jobs, duplicates and errors are free. One company with 200 open jobs costs about USD 0.60 the first time; a daily monitor afterwards pays only for new and closed jobs. Check the Actor page for current pricing.
Try it: ATS Jobs API on Apify. If you need another ATS (SmartRecruiters, Personio, Recruitee, Workable), open an issue on the Actor page: requests are counted and prioritized.
Runnable versions of every example in this article: apify-data-apis-examples on GitHub.
This article was drafted with AI assistance and reviewed, with every code sample tested against the live API.

