DEV Community

Your Evenings Data
Your Evenings Data

Posted on Edited on

Get only the jobs you want from Greenhouse, Lever and Ashby boards (and prove you didn't miss any)

Article type: How-to guide

Prerequisites: Python 3 with requests. No API key is needed for the public Greenhouse, Lever and Ashby board APIs.

Tested on: 1 November 2026 (the Python script; the Actor results in the post are from 22 October 2026, as the text says)

Two complaints come up again and again with job scrapers:

  1. "My filter didn't work": remote-only returned office jobs, a title search returned one result, a date filter did nothing.
  2. "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")
Enter fullscreen mode Exit fullscreen mode

Three details matter more than the code:

  • Match whole words, and list both forms. engineer shouldn't match "Engineering Manager" unless you want it to, so add engineering explicitly. 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_at changes whenever the board re-syncs, often for hundreds of jobs at once. Use first_published for "posted in the last N days". On Lever it's createdAt (epoch milliseconds); on Ashby it's publishedAt.
  • 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.

Part 2: the one-call version

Disclosure: we built this. Careers Page Scraper: Greenhouse, Lever, Ashby, Workday Jobs 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"]
}
Enter fullscreen mode Exit fullscreen mode

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."
Enter fullscreen mode Exit fullscreen mode

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)."
Enter fullscreen mode Exit fullscreen mode

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, so United Kingdom won't match a job listed only as "London". List both if you want both. Remote also 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 flagged postedAtUnknown: true. Turn off keepJobsWithoutPostedDate to 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.

Top comments (3)

Collapse
 
double_chen_70da460344c73 profile image
Double CHEN •

The COMPANY-STATUS CSV makes the cap visible, but I would add a failed-fetch fixture beside your xyzzy no-match fixture. Both yield zero jobs, yet only one should advance the monitor's baseline. On fetch failure, keep the last successful snapshot and mark that company stale.

Collapse
 
yourevenings profile image
Your Evenings Data •

Agreed, and that's a better test than mine. "No match" and "fetch failed" both give zero rows, and only the first should ever move the baseline.

Today, a thrown error or a 404 leaves the saved snapshot untouched and returns an error / not_found row, so that half already behaves the way you describe. Where your fixture would catch us out is a read that succeeds but is incomplete: a paged Workday board cut short by the time budget, or a board that answers with an empty list for one run. Both can currently look like real removals. We're adding exactly the three fixtures you imply (no match, failed fetch, partial or empty read) and only advancing the snapshot when the number of jobs read matches the board's reported total.

Thanks, this was worth writing up.

Collapse
 
yourevenings profile image
Your Evenings Data •

Follow-up: your fixture is in, as of build 0.1.14. The monitor tests now cover no match, failed fetch, partial read and a board that suddenly reads empty, using a mocked board.

  • A failed fetch gives a free fetch_failed row and leaves the baseline alone.
  • A partial read returns new jobs with status partial but reports no removals.
  • A sudden empty read gives a free empty_unconfirmed row. Removals only show if the next run also reads 0.

One trade-off: a board we can only ever read in part never reports removals. Thanks for pushing on this.