Hiring Signal Changefeed (Greenhouse, Lever, Ashby)
Pricing
Pay per event
Hiring Signal Changefeed (Greenhouse, Lever, Ashby)
Track which companies open or close roles: each run returns job changes versus the previous run from the companies' public job boards (Greenhouse, Lever, Ashby). Reads only job-board metadata, never people.
Pricing
Pay per event
Rating
0.0
(0)
Developer
Changefeeds Tools
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
a day ago
Last modified
Categories
Share
Give it a list of company job boards. Each run returns their open roles compared with the previous run of the same list: which roles were added, which were removed, and which changed their title, department or location. Plus a per-board summary: open-role count, the delta since last time, and how the roles distribute across departments.
Schedule it daily or weekly and the dataset becomes a change feed for sales prospecting, RevOps account intel, or recruiting-market tracking — using only each company's official public job-board JSON API (Greenhouse, Lever, Ashby). No API keys, no logins, no scraping of people: only job metadata.
What you get
One board row per board (type: "board"):
status:ok,not_found(no such board),error(the API failed) orinvalid(the input entry is not a recognizable board URL or shorthand)open_roles,open_roles_deltaversus the previous runadded,removed,changed,baselinecounts, andby_departmentis_baseline(first run of the list),previous_checked_at
One job row per job with a change (type: "job"), with a change_type:
baseline— first run, only when you setincludeBaselineJobs: every open roleadded— a job id the board did not have last runremoved— a job id that was on the board last run and is gone now (only ever reported when the board fetch fully succeeded)changed— the title, department or location of a known job id moved; the row carriesprevious(the old title/department/location)
Each row carries id, title, department, team, location, remote,
employment_type, url, published_at, compensation_summary (Ashby), and
description (Greenhouse/Lever, only when you set includeDescriptions).
The key-value store record OUTPUT holds a per-board summary plus totals
(changes, job_rows_returned, first_run, charged_events,
charge_limit_reached). If you set webhookUrl, the same summary is POSTed
there as JSON when the run ends.
Input
| Field | Default | Notes |
|---|---|---|
boards | required | Board URLs or shorthands: greenhouse:<token>, https://boards.greenhouse.io/<token>, https://job-boards.greenhouse.io/<token>, lever:<site>, https://jobs.lever.co/<site>, ashby:<org>, https://jobs.ashbyhq.com/<org>. Up to 1,000 per run. Invalid entries become an error row; the run continues. |
includeBaselineJobs | false | Off: a board's first run records its current roles silently and returns only the board summary row (open roles, by department), so you start watching 100 companies for 100 board checks. On: the first run also returns every open role as a charged baseline row. |
includeDescriptions | false | Also return each job's description text. Makes rows bigger and Greenhouse responses heavier. |
snapshotKey | derived | Which saved state to compare against. By default it is derived from the sorted board list, so the same list always compares with itself. Set it explicitly to keep history when you edit the list. Distinct explicit keys always get distinct storage records (the key is sanitised and suffixed with a hash of the full original), so watch/a and watch?a never share history. |
webhookUrl | none | Receives the run summary as a JSON POST. |
{"boards": ["greenhouse:gitlab", "lever:palantir", "ashby:ramp"],"includeDescriptions": false}
If a run finds nothing new, the dataset holds one no_changes row (not charged), so a quiet run is never mistaken for a broken one.
Sample output
{"type": "board","board": "greenhouse:gitlab","ats": "greenhouse","token": "gitlab","change_type": "board","status": "ok","open_roles": 199,"open_roles_delta": -3,"added": 1,"removed": 4,"changed": 2,"baseline": 0,"by_department": { "Engineering": 62, "Sales": 40 },"is_baseline": false,"previous_checked_at": "2026-09-27T06:00:05.000Z"}
{"type": "job","board": "greenhouse:gitlab","ats": "greenhouse","company": "gitlab","change_type": "added","previous": null,"id": "9000000001","title": "Brand New Role","department": "Engineering","team": null,"location": "Remote, Earth","remote": null,"employment_type": null,"url": "https://job-boards.greenhouse.io/gitlab/jobs/9000000001","published_at": "2026-09-28T04:00:00-04:00","compensation_summary": null,"description": null}
{"type": "job","board": "greenhouse:gitlab","change_type": "changed","id": "8556658002","title": "AI Engineer II","previous": { "title": "AI Engineer", "department": "Engineering", "location": "Remote, Bangalore" }}
Pricing
Pay per event, nothing else:
- $0.003 per board checked (a board that was fetched successfully and compared). Missing, invalid or failed boards are not charged.
- $0.003 per job change returned:
added,removedorchangedrows (andbaselinerows, only if you turn onincludeBaselineJobs). The board summary row is covered by the board check.
Worked examples:
- Starting a watch of 100 companies: the first run costs 100 × $0.003 = $0.30 (baseline roles are recorded, not returned).
- A daily run over those 100 boards: $0.30 plus $0.003 per actual change. If each company adds, removes or edits about three roles a week, that is ~43 changes a day, about $0.43 a day or ~$13 a month for 100 companies watched daily; fast-hiring companies cost more. Weekly runs cost a seventh of the board checks.
- With
includeBaselineJobson, the first run also returns every open role: 20 boards averaging 150 roles cost 20 × $0.003 + 3,000 × $0.003 = $9.06 once.
If you set a maximum total charge for the run, the actor returns only as many
rows as fit, saves state for what it returned, stops fetching further boards,
and says so in OUTPUT (stopped_reason: "max_total_charge_reached"). It
never charges for a row it did not deliver, and rows it did deliver are
remembered, so the next run does not bill them again. (Exception: if the run
is killed between delivering a board's rows and saving that board's state,
for example by a timeout or an abort, the next run reports that board's
changes again.)
How the change feed works
- State lives in the named key-value store
hiringwatch-snapshots, one record per board and snapshot key. The record holds the last checked time, the open-role count and, per job id, a SHA-256 hash of the title, department and location plus those three fields. - The first run of a snapshot key is the baseline: every open role is
remembered (and returned as a
baselinerow only withincludeBaselineJobs). - Later runs fetch the whole board again and diff job ids against the
snapshot: new ids are
added, missing ids areremoved, and a known id whose hash changed ischanged(with the old values inprevious). - A board is fetched with a single request to its official public API, so a successful fetch covers the whole board: removals are only ever reported from a complete fetch, never guessed from an error.
- Failed or missing boards get a free row with a status and never touch their snapshot — the next run compares against the last good state.
- A snapshot save applies only that run's own changes to what is currently stored, so a slower overlapping run cannot restore a job title or a job a newer run already replaced. (Overlapping runs are normally refused anyway; see the one-run-at-a-time limit below.)
Limits, stated plainly
- Job-board metadata only. The actor reads each company's public job-board API and never visits profiles, applications or anything about people. It cannot see jobs a company withholds from the public board.
- Only Greenhouse, Lever and Ashby. Workday, SmartRecruiters, Teamtailor and other ATSes are not supported.
- Boards are public pages. A company can relabel or retire its board at any
time; a retired board comes back
not_founduntil you remove it. changedmeans title, department or location. A changed job description, salary band or published date alone does not emit a row (Greenhouse'supdated_atalso moves on unrelated edits, so it is deliberately not watched).- A board that suddenly lists no roles is checked twice. If a board that had roles comes back empty, the actor waits 5 s and fetches it again before reporting every role as removed; a board that is still empty is taken at its word.
published_atis Greenhouse'sfirst_published(falling back toupdated_at), Lever'screatedAt, Ashby'spublishedAt.- Removed rows carry only remembered fields. The job is gone from the
board, so its
urlis null and only the last-seen title, department and location can be shown. - Descriptions change without notice. When
includeDescriptionsis on, the description reflects this run's fetch; description-only edits are not detected as changes. - No pagination, one request per board. A full board listing can be large (some boards list thousands of roles); each response is fetched in one request per run.
- At most 10,000 open jobs per board. A larger board is reported as an
uncharged
errorrow rather than tracked partially. - A board with an unreadable posting is skipped. If a board lists a posting
without an id (or the same id twice), the board gets a free
errorrow for that run instead of a guessed diff. - Ashby postings marked unlisted are ignored, like on the public board.
- It stays polite: at most 2 boards fetched at a time, at least 1 s between two requests to the same board, HTTP 429 honoured (Retry-After, capped at 60 s, max 3 retries; a longer Retry-After skips that board for this run), a descriptive User-Agent, 20 s timeouts.
- One run at a time per snapshot key. If a scheduled run starts while the
previous one with the same
snapshotKeyis still going, the new run fails immediately, before fetching or charging anything. A run that crashed without cleaning up blocks its key for at most 30 minutes. (The lock is best effort: two runs started within the same couple of seconds can, rarely, both proceed; their saved history is merged, not overwritten.) - If saved history can't be read, the board is skipped, with a free
errorrow, rather than re-sent as a paid baseline. If it can't be saved after 3 attempts, the run is marked failed and names the boards, because the next run would report those jobs again.
Local development
pnpm --filter @mmnm/hiringwatch test # unit tests, no networkpnpm --filter @mmnm/hiringwatch build
node dist/main.js runs the actor locally with Apify's local storage
(CRAWLEE_STORAGE_DIR=./storage by default).