DEV Community

Your Evenings Data
Your Evenings Data

Posted on Edited on

Greenhouse jobs API: get jobs posted in the last 24 hours (and why your scraper returns 0 jobs)

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-client and 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"])
Enter fullscreen mode Exit fullscreen mode

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

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

Habit 3: never accept a silent zero

When a company comes back with 0 jobs, it's usually one of these:

  1. Wrong token. stripe.com isn'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".
  2. Wrong ATS. The company switched systems. Look for boards.greenhouse.io, jobs.lever.co or jobs.ashbyhq.com links on its careers page.
  3. Unsupported system. Workday, iCIMS and in-house careers pages don't use these endpoints.
  4. 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 or ats: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, departments and postedWithinDays. 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 a COMPANY-STATUS CSV with one line per input: status, total, returned, filtered out, and a note.
  • No silent zeros. Companies it can't find get a not_found row with a reason, and they're free. A board where nothing matched gets no_matching_jobs and its total.
  • New since yesterday. Turn on monitorMode and schedule it daily for changeType: "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"))
Enter fullscreen mode Exit fullscreen mode

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, flagged postedAtUnknown: true. Set keepJobsWithoutPostedDate: false to 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)

Collapse
 
lucas_him_f7e05624dd9cc96 profile image
Lucas Him •

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?

Collapse
 
yourevenings profile image
Your Evenings Data •

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 error or not_found row 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 of removed rows.

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 removed and then come back as new on 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/.../jobs response carries total (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 has totalFound.

Collapse
 
yourevenings profile image
Your Evenings Data •

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:

  • Failed read: a free fetch_failed row. The saved snapshot isn't touched and nothing is reported as removed.
  • Partial read (Workday or Teamtailor cut short by the time limit, Workday jobs its search can't reach, SmartRecruiters stopping early): new jobs from the part that was read are returned, but nothing is reported as removed and unseen saved jobs are kept. The status is partial.
  • A board that suddenly reads 0 after having jobs: a free empty_unconfirmed row, 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.