You want to know the day a company you care about opens a new role, and checking ten careers pages by hand every morning is not a plan.
Maybe you are a job seeker with a short list of dream companies. Maybe you sell to engineering teams and "they just opened 4 data engineer roles" is your best buying signal. Maybe you run a niche job board. In every case the data is public, but it is spread across different applicant tracking systems (ATS), each with its own page layout and its own JSON shape.
I built a small tool for this, and in this post I show how to use it from Python: pull jobs from Greenhouse, Lever and Ashby (plus Workday and six other ATS) in one call, save them to CSV, and run it every day so you only see new postings.
Why the usual approach hurts
The usual path looks like this:
- Scrape the careers page HTML. It breaks on the next redesign, and big career sites are rendered with JavaScript.
- Find the ATS behind it. Greenhouse has
boards-api.greenhouse.io, Lever hasapi.lever.co, Ashby has its own posting API, Workday has its own paginated endpoint per tenant. Each one returns different field names for the same thing. - Write a normaliser per ATS, a dedupe step, and some state so tomorrow's run knows what you already saw.
Steps 2 and 3 are where the time goes. None of it is hard, but it is a weekend of glue code, and then you have to maintain it.
What I built
ATS Jobs Scraper is an Apify actor that reads the public job-board endpoints of 10 ATS (Workday, Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee, Personio, Teamtailor, Breezy HR) and returns every job in one schema: title, department, location, remote flag, salary range when the company publishes one, posting date and apply link. No login, no browser, no CAPTCHA, because it only uses the endpoints the careers pages themselves call.
You give it a list like this:
greenhouse:stripe lever:palantir ashby:ramp https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite A plain careers URL works too (https://boards.greenhouse.io/stripe, https://jobs.lever.co/palantir), and for most ATS a bare company slug is auto-detected.
Step 1: get an Apify token
Create a free Apify account and copy your API token from Settings -> API & Integrations. Put it in an environment variable so it never ends up in your code:
export APIFY_TOKEN="your-token-here" Step 2: one call with curl
The run-sync-get-dataset-items endpoint starts the actor, waits for it to finish and returns the results in the same HTTP response:
curl -s -X POST \ "https://api.apify.com/v2/acts/martin10291~ats-jobs-aggregator/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "companies": ["greenhouse:stripe", "lever:palantir", "ashby:ramp"], "titleKeywords": ["engineer"], "maxJobsPerCompany": 2, "maxItems": 6, "includeDescription": false }' I ran exactly this input today (2026-11-05). The run took 5.5 seconds. These are the 6 rows it returned, trimmed to the main fields:
| company | title | location | postedAt | salary |
|---|---|---|---|---|
| Stripe | AI Engineer | Chicago | 2026-10-07 | - |
| Stripe | Android BSP Engineer | Taipei, Taiwan | 2026-09-29 | - |
| Palantir | Backend Software Engineer - Application Development | London, United Kingdom | 2024-03-11 | - |
| Palantir | Backend Software Engineer - Application Development | New York, NY | 2024-03-11 | - |
| Ramp | Security Engineer, Cloud | New York, NY (HQ) | 2026-04-07 | 211,400-290,600 |
| Ramp | Mobile Engineer, Android | New York, NY (HQ) | 2025-07-31 | 189,000-330,000 |
And one full row, exactly as it came back:
{ "id": "greenhouse:stripe:8144196", "ats": "greenhouse", "companySlug": "stripe", "company": "Stripe", "title": "AI Engineer", "department": "1155 SA Transformation", "location": "Chicago", "locations": ["US"], "isRemote": false, "salaryMin": null, "salaryMax": null, "salaryCurrency": null, "postedAt": "2026-10-07T15:39:36Z", "url": "https://stripe.com/jobs/search?gh_jid=8144196", "applyUrl": "https://stripe.com/jobs/search?gh_jid=8144196", "scrapedAt": "2026-11-05T20:16:52Z" } The run also writes a RUN_SUMMARY record to its key-value store. Today it said Stripe had 763 open jobs, Palantir 308 and Ramp 170, and that 2 were saved from each because of my limits. That summary is handy when you want to chart how many roles a company has open over time.
Step 3: the same thing in Python, saved to CSV
import csv import os import requests TOKEN = os.environ["APIFY_TOKEN"] URL = ("https://api.apify.com/v2/acts/martin10291~ats-jobs-aggregator" "/run-sync-get-dataset-items") payload = { "companies": [ "greenhouse:stripe", "lever:palantir", "ashby:ramp", "https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite", ], "titleKeywords": ["engineer", "data"], "excludeKeywords": ["intern"], "locations": ["London", "Berlin", "Remote"], "includeRemoteWithLocations": True, "postedWithinDays": 14, "includeDescription": False, "maxItems": 200, } resp = requests.post(URL, params={"token": TOKEN}, json=payload, timeout=300) resp.raise_for_status() jobs = resp.json() print(f"{len(jobs)} jobs") fields = ["company", "title", "location", "isRemote", "salaryMin", "salaryMax", "salaryCurrency", "postedAt", "applyUrl"] with open("jobs.csv", "w", newline="", encoding="utf-8") as f: w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore") w.writeheader() w.writerows(jobs) The filters run inside the actor, so you only pay for the rows you keep. postedWithinDays and minSalary are useful here; note that minSalary drops jobs that publish no salary at all.
If you want the rows in Google Sheets instead of a CSV, gspread takes the same list:
import gspread gc = gspread.service_account(filename="service_account.json") ws = gc.open("Job feed").sheet1 ws.append_rows([[j.get(k) for k in fields] for j in jobs]) Step 4: run it daily and get only new jobs
This is the part that saves the most glue code. Add one flag:
payload["onlyNewJobs"] = True payload["postedWithinDays"] = 0 # let the state decide what is new With onlyNewJobs the actor stores the IDs it has already returned for that exact company list in a named key-value store in your Apify account. The next run with the same list returns only jobs that were not there before. The first run returns everything (that is your baseline); after that you get the daily delta.
Two ways to schedule it:
- Apify Schedules: open the actor, save your input as a task, then add a schedule (for example
0 7 * * *). Connect the task to a webhook, Slack, email or Zapier/Make so new jobs land where you work. - Your own cron: keep the Python script above and run it from cron or GitHub Actions:
0 7 * * * cd /home/me/jobfeed && APIFY_TOKEN=xxx python3 feed.py >> feed.log 2>&1 If you change the company list, the state key changes too, so a new list starts a fresh baseline.
Not on Apify? Same data as a plain REST API
I also list the same scraper as a regular REST API on RapidAPI and API.market (search for "Jobs API - Workday, Greenhouse, Lever, Ashby"). That route makes sense if you already have a RapidAPI key and want one HTTP call without an Apify account. The Apify route above is the most direct one and the one I tested for this post.
Honest limits
- It only sees companies you name. It is not a search engine over all jobs on the internet. You bring the list; the tool reads those boards.
- Salary is only as good as the source. Ashby, Lever and Recruitee often return structured ranges (like the Ramp rows above). For other ATS the actor tries to detect a range in the text, and many postings simply have none, like the Stripe rows above.
- Fields mean what the ATS says. Palantir's backend roles show
postedAt2024-03-11 because those are evergreen postings that stay open for years. Stripe'slocationandlocationscan differ because Greenhouse stores them separately. I keep the original values instead of guessing. - Workday tenants are slower. Big Workday sites have thousands of roles and paginate, so set
maxJobsPerCompanyor keyword filters if you only need a slice. - ATS changes happen. If a company moves from Lever to Ashby, its old slug returns 0 jobs. The
RUN_SUMMARYshows jobs per company, so a sudden 0 is easy to spot.
Price
Pay per result: you pay only for jobs saved after your filters, with no start fee. It is $1 per 1,000 jobs until 13 November 2026 and $2 per 1,000 jobs after that. With pay-per-result pricing the Apify compute is included, so the 6-row run above cost $0.006 at today's price. Apify's free plan includes a small monthly credit, enough to try it on your own company list.
Wrap-up
I built this because I wanted one feed of "new roles at these companies" without maintaining ten scrapers. If you try it and a company's board does not parse, or you need an ATS I do not cover yet, leave a comment or open an issue on the actor page and I will look at it.
Next in this series: turning the daily new-jobs feed into a hiring-signal list for sales (which companies just opened roles in a given department).
