Article type: How-to guide
Prerequisites: Python 3 with
requests. No API key is needed for the public job-board APIs. For the Actor section:apify-clientand an Apify API token.Tested on: 1 November 2026 (the Greenhouse API snippets; the Actor test run described in the post was on 22 October 2026)
If you've searched "greenhouse jobs scraper date filter not working" or "greenhouse API jobs posted last 24 hours", here's the short version: Greenhouse's public job-board API has no date parameter, and neither do Lever's or Ashby's public endpoints. Any date filter you've used was applied after the jobs were downloaded. When it misbehaves, it's that layer, not the job board.
The endpoints are free and public, and getting "new jobs since yesterday" right takes three small habits.
The free endpoints
You need the company's board token, which is usually the slug in its careers URL.
| ATS | Endpoint |
|---|---|
| Greenhouse | https://boards-api.greenhouse.io/v1/boards/{token}/jobs?content=true |
| Lever |
https://api.lever.co/v0/postings/{token}?mode=json (EU companies: api.eu.lever.co) |
| Ashby | https://api.ashbyhq.com/posting-api/job-board/{token}?includeCompensation=true |
import requests
jobs = requests.get("https://boards-api.greenhouse.io/v1/boards/airbnb/jobs").json()["jobs"]
print(len(jobs), jobs[0]["title"], jobs[0]["first_published"])
Habit 1: filter on the publish date, not the update date
Each Greenhouse job carries both first_published and updated_at. updated_at changes every time the employer edits the post, so "updated in the last 24 hours" is not "new in the last 24 hours". Filter on first_published:
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(days=1)
new_today = [j for j in jobs if j.get("first_published")
and datetime.fromisoformat(j["first_published"]) > cutoff]
The equivalents are Lever's createdAt (milliseconds) and Ashby's publishedAt.
Habit 2: for alerts, diff job IDs
Dates can be missing, and a reposted job gets a fresh date. For a daily alert, the most reliable test is "which job IDs weren't there yesterday?":
new_ids = {j["id"] for j in jobs} - set(yesterdays_ids)
closed_ids = set(yesterdays_ids) - {j["id"] for j in jobs}
Habit 3: never accept a silent zero
When a company comes back with 0 jobs, it's usually one of these:
-
Wrong token.
stripe.comisn't a token. Most ATSs 404 on a bad token, but some return HTTP 200 with an empty list, which looks exactly like "not hiring". -
Wrong ATS. The company switched systems. Look for
boards.greenhouse.io,jobs.lever.coorjobs.ashbyhq.comlinks on its careers page. - Unsupported system. Workday, iCIMS and in-house careers pages don't use these endpoints.
- Over-tight filters. An exact title like "Head Marketing" misses "Head of Marketing". Match keywords with word boundaries instead.
Log a reason for every company that returned nothing, and record the board's total next to what you kept. That way "0 matched my filters" and "0 because the board wasn't found" never look the same.
Duplicates
The same role posted in three cities is often three job IDs. Decide whether you want one row per posting or per role. If per role, key on (company, normalised title, location). Across days, dedupe on job ID.
The one-call version (disclosure: we built this)
We packaged all of the above as a pay-per-use Apify Actor: Careers Page Scraper: Greenhouse, Lever, Ashby, Workday Jobs.
-
Domains in, jobs out. Give it
ramp.com, a careers URL orats:token. It finds the board across Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee and Workday, and returns one schema: title, department, location,isRemote,postedAt, posted salary (only when the employer publishes it), job URL and apply URL. It reads each board live on every run. -
Filters applied before you're charged:
titleKeywords,excludeTitleKeywords,locations,remoteOnly,departmentsandpostedWithinDays. Jobs that don't match are never returned or charged. -
Proof you got the whole board. Every job row carries
companyTotalJobs, the board's open-job count before filters and limits. Each run also writes aCOMPANY-STATUSCSV with one line per input: status, total, returned, filtered out, and a note. -
No silent zeros. Companies it can't find get a
not_foundrow with a reason, and they're free. A board where nothing matched getsno_matching_jobsand its total. -
New since yesterday. Turn on
monitorModeand schedule it daily forchangeType: "new"and"removed"rows, diffed by job ID.
from apify_client import ApifyClient
client = ApifyClient("<APIFY_TOKEN>")
run = client.actor("yourevenings/careers-jobs-api").call(run_input={
"companies": ["stripe.com", "ramp.com", "https://jobs.lever.co/spotify"],
"titleKeywords": ["engineer"],
"locations": ["London"],
"postedWithinDays": 1,
})
for row in client.dataset(run["defaultDatasetId"]).iterate_items():
print(row["recordType"], row["company"], row.get("title"), row.get("companyTotalJobs"), row.get("note"))
In a test run on 22 Oct 2026 with an engineer, London, last-30-days filter, one board returned 1 matching job out of 726 open roles, with the other 725 filtered out and not charged. A second board reported no_matching_jobs with its total of 170, and a made-up domain came back not_found at no charge.
It costs $0.002 per job returned and $0.002 per company resolved. Companies it can't find are free. Ready-made inputs: Which ATS does a company use? ยท Greenhouse board export ยท Posted salary ranges.
Notes
-
Not covered yet: iCIMS, SuccessFactors and in-house careers sites. Those return
not_found, and you're not charged. (Workday was added on 22 Oct 2026. Its posted dates are approximate, because Workday's listing only says "Posted 3 Days Ago".) - Jobs without a published date are kept by default when you set
postedWithinDays, flaggedpostedAtUnknown: true. SetkeepJobsWithoutPostedDate: falseto drop them. - The tool drops recruiter contact details and strips emails and phone numbers from descriptions. Using job data is your responsibility under the terms and laws that apply to you.
Which ATS do your target companies use? Tell us in the comments.
Top comments (3)
Separating no_matching_jobs from not_found makes the daily diff much safer. The same distinction is needed for a timeout or failed fetch: keep yesterday's baseline and mark the source unavailable, rather than emitting every old job as removed. Do you only advance the monitor snapshot after a complete successful board read?
Good question. Mostly yes, but not in every case yet.
What already holds: if the board call fails (timeout, 5xx, anything that throws) or the board 404s, that company gets an
errorornot_foundrow and its saved snapshot isn't touched. The next run diffs against the last good read, so a failed call never turns into a wall ofremovedrows.The gap is partial reads. Greenhouse, Lever and Ashby return the whole board in one response, so there "read succeeded" means "read complete". Paged boards (mainly Workday) can be cut short by the run's time budget. The adapter knows when that happens, but monitor mode doesn't check it yet. Jobs it didn't reach show up as
removedand then come back asnewon the next run. The same goes for a board that briefly reads as empty after having jobs.The fix is the rule you're describing: only advance the snapshot when jobs read equals the total the board reports. Otherwise, emit a partial row and keep yesterday's baseline. "Had 40 jobs, now 0" is treated as unconfirmed until a second read agrees.
If you're rolling your own, the same check works anywhere the API reports a total. Workday's
/wday/cxs/.../jobsresponse carriestotal(read it from the first page; on very big boards it tops out at 2,000, so split the query by a facet such as location to get a true count). SmartRecruiters' postings response hastotalFound.Follow-up: this is fixed in build 0.1.14. Monitor mode now only treats a board as having lost jobs after a complete read:
fetch_failedrow. The saved snapshot isn't touched and nothing is reported as removed.partial.empty_unconfirmedrow, with the snapshot kept. Removals only show if the next run also reads 0.One limit: a board that's always read only in part never reports removals, because we'd rather miss a closed job than invent one.