Two complaints come up again and again with job scrapers:
- "My filter didn't work": remote-only returned office jobs, a title search returned one result, a date filter did nothing.
- "I got fewer jobs than I expected": was that everything, or did something break?
Both problems have the same cause: you can't see what happened between "the company's board" and "my spreadsheet". This post shows how to filter public ATS job boards so you can check the result, first by hand in Python and then with a one-call version.
Part 1: the DIY way
Greenhouse, Lever and Ashby all publish keyless JSON job boards, but none of them filters on the server. You download the whole board and filter it yourself:
import re, requests
from datetime import datetime, timedelta, timezone
def greenhouse(token):
r = requests.get(f"https://boards-api.greenhouse.io/v1/boards/{token}/jobs", timeout=20)
r.raise_for_status()
return [{"id": j["id"], "title": j["title"], "location": j["location"]["name"],
"posted": j.get("first_published")} for j in r.json()["jobs"]]
def word_match(text, words):
return any(re.search(rf"\b{re.escape(w)}\b", text or "", re.I) for w in words)
jobs = greenhouse("stripe")
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
keep = [j for j in jobs
if word_match(j["title"], ["engineer", "engineering"])
and word_match(j["location"], ["London"])
and (j["posted"] is None or datetime.fromisoformat(j["posted"]) > cutoff)]
print(f"{len(jobs)} on the board, {len(keep)} kept, {len(jobs) - len(keep)} filtered out")
Three details matter more than the code:
-
Match whole words, and list both forms.
engineershouldn't match "Engineering Manager" unless you want it to, so addengineeringexplicitly. Most "title search returns one result" complaints are an exact-phrase search on a title nobody uses. -
Use the right date field. On Greenhouse,
updated_atchanges whenever the board re-syncs, often for hundreds of jobs at once. Usefirst_publishedfor "posted in the last N days". On Lever it'screatedAt(epoch milliseconds); on Ashby it'spublishedAt. - Always print the board total next to your result. "7 jobs" means nothing on its own. "7 of 729, 722 filtered out" tells you the filter ran and the board was read in full.
Then add the same for Lever (api.lever.co/v0/postings/{slug}?mode=json, a bare array, remote via workplaceType) and Ashby (api.ashbyhq.com/posting-api/job-board/{slug}, isRemote). Map each to the same fields, and you have the whole thing. It's very doable for a handful of companies. If you don't know a company's board token yet, here's how to find it from its careers page.
Part 2: the one-call version
Disclosure: we built this. Greenhouse, Lever & Ashby Jobs API on Apify does the above for a list of company domains across seven ATSs (Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee and, since 22 Oct 2026, Workday). It doesn't cover Personio or iCIMS yet. Filters run before anything is returned or charged.
{
"companies": ["stripe.com"],
"titleKeywords": ["engineer"],
"locations": ["London"]
}
When I ran exactly that on 22 Oct 2026, Stripe's board had 729 open jobs and 7 matched. The cost was one company-resolved event plus 7 job events (about $0.016, against about $1.46 for all 729). Your numbers will differ, because boards change daily.
Other runs from the same morning:
| Input | Board total | Returned |
|---|---|---|
stripe.com, titleKeywords: ["engineer"], excludeTitleKeywords: ["senior","staff","intern"]
|
729 | 104 |
https://jobs.lever.co/spotify, remoteOnly: true
|
80 | 27 |
figma.com + Spotify's Lever board, postedWithinDays: 14
|
139 + 80 | 17 + 16 |
stripe.com, titleKeywords: ["xyzzy-no-such-title"]
|
729 | 0, with a no_matching_jobs row saying so (only the company-resolved event charged) |
Proving you got everything
Every job row carries companyTotalJobs, the board's open-job count before your filters. Every run also writes a COMPANY-STATUS CSV to the run's key-value store, linked from the Output tab, with one line per company you entered:
input,company,ats,boardToken,status,totalJobs,returned,filteredOut,note
stripe.com,Stripe,greenhouse,stripe,ok,729,7,722,"729 open jobs on the board; 7 matched your filters, 722 filtered out (not charged); 7 returned."
In normal mode, returned + filteredOut should equal totalJobs. When it doesn't, the note says why. For example, with maxJobsPerCompany: 3:
"729 open jobs on the board; 145 matched your filters, 584 filtered out (not charged); 3 returned. 142 matching jobs not returned: capped by maxJobsPerCompany (3)."
Companies it can't find get a not_found line, free of charge, instead of silently disappearing from your results.
How the filters match (so they don't surprise you)
-
titleKeywords/excludeTitleKeywords: case-insensitive whole words or phrases. -
locations: whole words in the board's location text. A few country spellings are grouped (for example United Kingdom/UK/England), but cities aren't inferred, soUnited Kingdomwon't match a job listed only as "London". List both if you want both.Remotealso matches jobs the board flags as remote. -
remoteOnly: the board's remote flag, or "remote" in the location or title. Hybrid jobs aren't included. -
postedWithinDays: counts back from each run, so a saved or scheduled run never goes stale. Jobs whose board gives no date are kept and flaggedpostedAtUnknown: true. Turn offkeepJobsWithoutPostedDateto drop them. - All filters combine with AND; the values inside one filter combine with OR.
Make it a daily feed
Turn on monitorMode with the same filters and schedule the run daily. You'll get only jobs that were opened or closed since the last run and match your filters. A company with no changes returns one cheap no_change row. Failed or partial reads are flagged (fetch_failed or partial), never shown as removals.
Prices at the time of writing: $0.002 per job returned, $0.002 per company resolved, $0.00005 per run start, with Silver and Gold plan discounts. The Actor's Pricing tab is authoritative.













