ATS Job Change Feed - Greenhouse & Lever
Pricing
from $0.70 / 1,000 new job detecteds
ATS Job Change Feed - Greenhouse & Lever
Monitor Greenhouse and Lever job boards and get only what changed since your last run: new, updated, and removed postings, each corroborated by a content hash. You pay per change, and unchanged rows are free.
Pricing
from $0.70 / 1,000 new job detecteds
Rating
0.0
(0)
Developer
Dakota Styck
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
5 days ago
Last modified
Categories
Share
What does ATS Job Board Scraper & Change Tracker do?
ATS Job Board Scraper & Change Tracker scrapes company career pages across 38 ATS platforms and tells you what changed since your last run: which jobs are new, which were updated, and which were removed. Snapshot APIs leave the diffing to you, and the incremental job feeds with any real adoption report only new and closed postings. This actor reports the full diff on the boards you choose and corroborates every change with a content hash. It never bills you for a posting that didn't change.
It reads 38 public, no-authentication career-board feeds—structured endpoints and server-rendered listings—so companies can embed job boards on their own websites. The supported sources are Greenhouse, iCIMS, JazzHR, Factorial, Paylocity, Lever, Ashby, Avature, Workable, Recruitee, Rippling, Breezy HR, Comeet, Dover, Jobvite, Jobsoid, JOIN, Teamtailor, SmartRecruiters, SAP SuccessFactors, Oracle Taleo, BambooHR, Personio, Phenom, Oracle Cloud HCM, Occupop, Eightfold, Manatal, Polymer, Pinpoint, Zoho Recruit, Workday, UKG Pro Recruiting, Bullhorn, Recruiterflow, Gem, ADP, and Traffit. No browser is involved, which is why runs are fast and cheap.
Point it at 50 company boards, run it daily, and each morning you get the delta.
Why use ATS Job Board Scraper & Change Tracker?
- Stop diffing dumps yourself. You get
change_typeper record:new,updated,removed. - Catch edits too. A retitled role, a location change, or a new department shows up as
updated. - Know when a role closes.
removedtells you a posting is gone, which a snapshot API can't tell you at all. - Cheap to run. HTTP-only, no browser. A run over dozens of boards takes seconds.
- Honest about outages. If a board fails to fetch, its jobs are not reported as removed. See Reliability.
Recruiters use it to track competitor hiring, job boards use it to ingest fresh listings, and market researchers watch it for headcount signals. It also works as a plain alert that a specific company opened a specific kind of role.
How to use ATS Job Board Scraper & Change Tracker
Pick one setup path, then choose the first-run behavior before clicking Run:
- I know the company, not its ATS. Enter its name in Companies. The actor tries Greenhouse, Lever, Ashby, Workable, Recruitee, Rippling, Breezy HR, Teamtailor, BambooHR, Manatal, Dover, Gem, Occupop, and iCIMS in that order. A pasted careers-page URL also works. A name that matches none is listed in
RUN_SUMMARY.unresolved_companiesand is never billed. - I have the public careers URL. Use the matching Companies on ... or Career sites on ... field under Boards by platform. Paste the URL if that field permits it; otherwise copy the slug, hostname, or multi-part address from the URL. The field description gives the exact accepted form. This is required for sources whose address cannot be safely inferred from a company name. If a simple board token differs from the company name (for example, "Epic Games" becomes
epicgamesduring lookup), enter that token directly here. - I want changes only, with no initial job-event bill. Set First run: snapshot or silent baseline to Record baseline silently, emit nothing. The default returns every open job as
new, which is useful as a snapshot but bills each of those job events. Either mode stores the same baseline; from the second run onward, the actor returns only changes.
Optionally add filters for title, location, department, or remote work. Then schedule the actor (daily is typical).
Teamtailor is tried automatically, but its token is usually the career-site subdomain rather than the employer name (Teamtailor's own board is career). For a reliable result, copy that subdomain from the careers URL into Companies on Teamtailor.
First run behavior
There's no prior state on the first run, so "what changed" is undefined. You choose:
first_run_mode | Behavior |
|---|---|
emit_all_as_new (default) | Returns every open job tagged new. You get a full snapshot plus a baseline. |
baseline_only | Returns nothing, silently records the baseline. The next run reports changes only. |
Pick baseline_only if you only care about deltas and don't want to pay for an initial full dump.
Input
All fields are optional except that you must supply at least one company or board.
{"companies": ["airbnb", "anthropic", "spotify"],"greenhouse_boards": ["stripe", "databricks"],"title_keywords": ["engineer", "data"],"locations": ["remote", "new york"],"remote_only": false,"first_run_mode": "emit_all_as_new","emit_unchanged": false,"include_description": false,"hash_description": false,"state_store_name": "ats-job-feed-state"}
Filters are applied before change detection, so a job drifting out of your filter is never mislabeled as removed.
Workday boards are named by URL, not by slug. Workday needs the tenant, the numbered wd* host and the career-site id, and the
host number cannot be derived from the company name - nvidia is on wd5, salesforce
on wd12. So paste the career-site URL
(nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite), and note that Workday is not
covered by the automatic Companies lookup for the same reason. Keep the string you
first used: the board address is part of each job's identity, so rewriting it in a
different accepted form re-reports the whole board as new.
Jobvite is read from its careers board, which is HTML. Jobvite publishes no structured feed,
so its board is parsed from the page. That is an
ordinary request - no browser, nothing bypassed - but a Jobvite redesign can break parsing
where a JSON feed would not. When that happens the board is reported as a failed fetch and
your baseline is carried forward untouched, so a redesign never arrives as a wave of
removed events. Jobvite publishes no dates, no descriptions and no departments, so title,
location and the URL are what is watched, and a job in many places shows a summary such as
"3 Locations". A slug that is not a Jobvite customer answers 403 rather than 404, which is
why Jobvite is not covered by the automatic Companies lookup.
Polymer boards are named by the organization slug in the board URL. For
https://jobs.polymer.co/pirate-wires the slug is pirate-wires. Polymer publishes
created_at and published_at but no update stamp, so change detection there runs entirely
off the content hash, and descriptions are not fetched - they live behind one request per
posting. A slug that is not a Polymer customer answers 422 rather than 404, which is why
Polymer is not covered by the automatic Companies lookup.
Comeet boards are named by two values, not a slug. A Comeet board is a uid and a token
(54.007:45715B315B38AE22B8D051A0A457D051E61), and both are printed in the employer's own
careers page for anyone who opens it - view source and look for the uid and token handed
to the Comeet embed, or open the comeet.com/jobs/<company>/<uid> board page, which carries
both. That is also why Comeet is not covered by the automatic Companies lookup: a company
name cannot supply a token. Keep the string you first used, for the same reason as Workday.
Some Workday career sites cap their own search results at 2,000 jobs even when the board is larger - two of seventeen boards measured, one of them hiding 10,249 of its 12,249 openings. The actor detects that from the site's own category counts and works around it by reading the board one category at a time, so a capped site is still tracked in full. Those boards take longer to read; nothing else changes.
Output
Each dataset item is one emitted job event. Read change_type first: new is a posting not in that board's prior baseline, updated means a tracked field changed, and removed means a previously tracked posting disappeared. unchanged appears only when you request it and is never billed.
{"change_type": "updated","change_reason": "content","job_key": "greenhouse:stripe:5416444","source": "greenhouse","board": "stripe","external_id": "5416444","title": "Data Analyst","company": "Stripe","locations": ["San Francisco, CA", "US"],"department": "Data Science","team": null,"employment_type": null,"is_remote": null,"url": "https://stripe.com/jobs/search?gh_jid=5416444","posted_at": "2026-06-02T12:58:57Z","updated_at": "2026-07-27T15:17:30Z","first_seen_at": "2026-08-01T09:00:00Z","last_seen_at": "2026-08-04T01:04:17Z","content_hash": "3f2a91c04be7d158"}
The dataset downloads as JSON, CSV, Excel, or HTML.
A removed item has no current record - the posting is gone - so it carries source, board, external_id, job_key, first_seen_at, last_seen_at, and the last content hash. Current-job fields such as title, locations, and url are absent. Treat it as a deletion keyed by job_key.
Data fields
| Field | Description |
|---|---|
change_type | new, updated, removed, or opt-in unchanged; the first three use billable job events |
change_reason | For updated: content when the tracked-field hash moved, timestamp when only the vendor timestamp moved and you opted in, or content+timestamp when both moved |
job_key | Stable identity, source:board:external_id; use it for downstream upserts and deletions |
source | One of the 38 source keys in src/sources/__init__.py:SOURCES: greenhouse, icims, jazzhr, factorial, paylocity, lever, ashby, avature, workable, recruitee, rippling, breezy, comeet, dover, jobvite, jobsoid, join, teamtailor, smartrecruiters, successfactors, taleo, bamboohr, personio, phenom, oracle, occupop, eightfold, manatal, polymer, pinpoint, zoho, workday, ukg, bullhorn, recruiterflow, gem, adp, or traffit |
board | The board token the job came from |
title | Job title |
company | Company name, where the vendor publishes one (Greenhouse, Workable, Recruitee, Breezy, Teamtailor, SmartRecruiters; the rest do not) |
locations | All locations attached to the posting |
department / team | Org placement, where the source provides it |
employment_type | e.g. "Regular Full Time" (Lever), "FullTime" (Ashby), "Full-time" (Workable), "fulltime_permanent" (Recruitee); Greenhouse and Rippling publish none |
is_remote | true, false, or null when the source gives no signal |
url | Public posting URL |
posted_at | When the posting went live, ISO-8601 UTC |
updated_at | Vendor's last-modified claim, when its public feed provides a stable one; otherwise null |
first_seen_at | When this actor first saw the job; survives updates |
last_seen_at | This run's timestamp; on a removal, when the absence was observed rather than a vendor-supplied close time |
content_hash | Hash of the tracked fields, for your own downstream checks; it excludes updated_at |
A machine-readable receipt is written to the key-value store under RUN_SUMMARY. Check it before interpreting an empty or short dataset:
{"outcome": "partial","first_run_mode": "baseline_only","first_run_boards": ["greenhouse:stripe"],"silent_baseline_boards": ["greenhouse:stripe"],"totals": {"new": 0, "updated": 2, "removed": 0, "unchanged": 0},"billable_change_events": 2,"failed_boards": ["lever:example"],"unresolved_companies": ["unknown-company"],"mass_removals_withheld": [{"board": "workday:example", "jobs": 84}],"withheld_by_charge_limit": 3}
outcome: "complete" means every resolved board was processed and no changes were withheld. partial means at least one input failed or did not resolve, the mass-removal guard held removals, or the caller's charge limit stopped emission. totals counts records actually written; billable_change_events is exactly the emitted new + updated + removed count and excludes the separate platform run-start event. per_board carries the same diagnosis for each board, including first_run, tracked_jobs, emitted counts, and both withheld counts.
A successful zero-row dataset is therefore unambiguous: silent_baseline_boards means the requested first baseline was recorded without job-event charges; an empty withheld/failed section means the boards were simply quiet. Failed boards keep their old baseline and produce no removals. Mass-removal and charge-limit records are intentionally held out of both the dataset and the new baseline so they can be reconsidered or re-reported later.
How change detection works
Most of the difficulty is in deciding what counts as a change.
Every job carries a content hash. It covers the tracked fields: title, locations, department, team, employment type, remote flag, URL, posted date. A job is updated when the hash moves. Greenhouse also publishes an updated_at, and when that moves too you get change_reason: "content+timestamp", but on its own it never counts as a change unless you opt in via detect_timestamp_only.
That default comes from a measurement, not from caution. Greenhouse occasionally re-stamps nearly a whole board inside one minute with no tracked field changing at all. Under the opt-in, each of those events would bill you for hundreds of updates that never happened.
Only sources with a stable vendor modification timestamp populate updated_at. Greenhouse, Recruitee, Comeet, Eightfold, Dover, and Bullhorn currently provide one; the other 32 sources rely on the content hash for update detection. Creation, release, publication, and relative display dates are not treated as modification timestamps.
Two vendors also publish no description: Rippling and BambooHR return no body text at any request, and Breezy returns none either. On those boards a description-only edit cannot be detected, whatever hash_description is set to.
SmartRecruiters cannot report an empty board. Its API answers an unknown company id with an empty list rather than an error, which is indistinguishable from a real board that closed every job. Rather than risk reporting a whole board as removed because of one typo, a SmartRecruiters board returning zero jobs is treated as a failed fetch and your baseline is left untouched - so a genuine close-out of every job on such a board is never reported.
Only fingerprints are stored. A job id, a 16-character hash, two timestamps. Tracking 10,000 jobs costs well under a megabyte of state.
State lives in a named key-value store (state_store_name). Apify gives every run a fresh default key-value store, so a baseline written there would vanish and every run would look like a first run.
Adding a board doesn't reset the others. Baselines are tracked per board, so a newly added board is a first run for that board alone while every existing board keeps diffing normally. You aren't re-billed for your whole corpus because you added one company.
Changing what is hashed does start a clean baseline. Flipping hash_description changes what the hash covers, so old and new hashes can't be compared. The state key accounts for that, so you don't get every job reported as updated. Pin state_key if you want one continuous feed regardless.
Reliability
Change feeds have a specific failure mode: an outage that looks like news. Two guards address it.
A failed fetch is not an empty board. If a board returns an error, times out, or serves a non-JSON body, that board is skipped entirely: no records emitted, baseline preserved untouched. Without this, one transient 503 would report every job on that board as removed. Failed boards are listed in RUN_SUMMARY.failed_boards.
Mass removals are withheld pending corroboration. If more than removal_alarm_ratio (default 50%) of a board's tracked jobs disappear in a single run, the removals are held back and the baseline is kept, because a truncated-but-successful response looks exactly like a genuine mass close-out. Boards with fewer than 10 tracked jobs are exempt, where the ratio would be meaningless. Set suppress_mass_removals: false to let them through immediately.
Retries use exponential backoff with jitter and honor Retry-After. A 404 isn't retried: it means the board token is wrong, and hammering a free public endpoint over a typo is rude.
How much does it cost?
Pay-per-event, two prices:
| Event | Price |
|---|---|
| Run start | $0.002 (charged once per run up to 1 GB of memory; this actor runs at 512 MB by default, so once) |
Each new, updated, or removed record | $0.001 |
unchanged records, when you enable them, are never charged.
You pay per change, so a large board that sits still costs you almost nothing. Measured on live Greenhouse boards of ~550 and ~800 jobs: 22-35 billable changes on a weekday, with weekends much quieter. That's a few cents either way. A daily poll over 10 boards with ~1,000 tracked jobs comes to roughly $1-2/month, and a quiet day costs $0.002, the run fee and nothing else. A snapshot API bills you for the same 500 rows every day whether or not anything moved.
The first run is the exception: with no baseline yet, emit_all_as_new bills every open job once as new. Choose first_run_mode: "baseline_only" to skip that and pay only for changes from run two onward.
Runs are HTTP-only with no browser, so compute is a small fraction of a browser-based scraper's.
Tips
- Filter early. Title and location filters run before change detection, so narrow feeds mean cheaper runs.
- Leave
include_descriptionoff unless you need the body text; it makes Greenhouse responses much larger. - Turn on
hash_descriptiononly if you genuinely need description edits flagged, and know what it costs. Measured on Databricks' board, 2026-08-13: an employer added one legal boilerplate link to every posting at once, so 769 of 779 jobs would have billed asupdatedfor a change that says nothing about the job. Ten were genuine edits, and six of those were already caught without it. Board-wide edits like that are normal, not rare: of sixteen Greenhouse boards scanned that day, ten showed one, and eight of those had fired within the previous ten days. - Use
baseline_onlyon the first run if you only want deltas. - Keep
max_concurrencymodest. These are free public endpoints offered as a courtesy.
FAQ
Does this need an API key for any supported source? No. Every feed or public careers page used is unauthenticated and publicly available to job seekers.
Is this scraping? It reads public, unauthenticated job-board endpoints and server-rendered careers listings; there is no browser, CAPTCHA solving, or bypassing access controls. The payload format varies by source, including JSON, XML, and HTML.
Why is updated_at null on most of my jobs?
Because only Greenhouse, Recruitee, Comeet, Eightfold, Dover, and Bullhorn currently provide a stable modification timestamp. On the other 32 sources, change detection works entirely off the content hash. This is a property of the public feeds, not a limitation of this actor.
Why did a job show as updated with no visible difference?
Only possible if you enabled detect_timestamp_only: change_reason: "timestamp" means the vendor's updated_at advanced but nothing in the content hash moved. It's off by default because Greenhouse re-stamps nearly a whole board at once whenever the employer edits every posting - often just to add a legal link - and on the event measured 2026-08-13 that touched 769 of 779 jobs. If you do want description edits, enable hash_description; those arrive as change_reason: "content", and you should expect board-wide bursts along with the edits you actually care about.
Why did no removals appear when a board clearly emptied out?
The mass-removal guard held them. Check RUN_SUMMARY.mass_removal_flagged. Set suppress_mass_removals: false if you'd rather have them immediately.
Which ATS platforms are supported? Thirty-eight: Greenhouse, iCIMS, JazzHR, Factorial, Paylocity, Lever, Ashby, Avature, Workable, Recruitee, Rippling, Breezy HR, Comeet, Dover, Jobvite, Jobsoid, JOIN, Teamtailor, SmartRecruiters, SAP SuccessFactors, Oracle Taleo, BambooHR, Personio, Phenom, Oracle Cloud HCM, Occupop, Eightfold, Manatal, Polymer, Pinpoint, Zoho Recruit, Workday, UKG Pro Recruiting, Bullhorn, Recruiterflow, Gem, ADP, and Traffit. Fourteen are also reachable by company name alone: Greenhouse, Lever, Ashby, Workable, Recruitee, Rippling, Breezy HR, Teamtailor, BambooHR, Manatal, Dover, Gem, Occupop, and iCIMS. The other 24 must be named explicitly because their board address is not a company slug or they cannot safely distinguish an unknown board from an empty or demo result. SmartRecruiters carries one further limit worth stating: an unknown company id returns an empty success rather than a 404, indistinguishable from a board that closed every job, so a SmartRecruiters board that reads as empty is treated as a failed fetch and your baseline is carried forward untouched - a genuine total close-out there is never reported.
Support
Found a bug or want another ATS supported? Open an issue on the Issues tab.