Live Greenhouse Jobs — Change Tracking & Ghost-Job Detection avatar

Live Greenhouse Jobs — Change Tracking & Ghost-Job Detection

Pricing

from $0.80 / 1,000 job results

Go to Apify Store
Live Greenhouse Jobs — Change Tracking & Ghost-Job Detection

Live Greenhouse Jobs — Change Tracking & Ghost-Job Detection

Pull every open role from a company's live Greenhouse board with a per-run change feed: each job tagged new, changed, open or closed (ghost-job detection) by diffing the prior run. Authoritative current-open truth a resold database can't give. Domain or slug, no keys. Pay per result.

Pricing

from $0.80 / 1,000 job results

Rating

0.0

(0)

Developer

Rez Archer

Rez Archer

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

an hour ago

Last modified

Categories

Share

Greenhouse Jobs — Live Feed with New / Changed / Closed Change Tracking

Pull every open role straight from a company's live Greenhouse board and get back a per-run change-feed: each job is tagged new, changed, open, or closed by diffing this run against the last one. You get the authoritative current open set and what moved since you last looked — not a stale snapshot of a third-party database.

Input: ["stripe.com", "greenhouse:gitlab"]
Output: one clean row per role, each tagged:
new — opened since your last run
changed — still open, but a tracked field moved (title, location, …)
open — unchanged since last run
closed — was open last run, gone now → ghost-job signal

Why this instead of a resold jobs database

Most Greenhouse feeds on the market resell a cached database that refreshes on a schedule and retains postings for months. That cannot authoritatively answer the two questions buyers actually have:

  • "Is this company's role still open right now?" — This actor reads the company's live Greenhouse board at run time, so an open role is open now, not "seen within the last N hours".
  • "What changed since I last checked?" — A cached database has no memory of your last run. This actor keeps a per-board snapshot between runs and diffs it, so you get new / changed / closed deltas for free — including ghost jobs (listings that quietly disappeared), the #1 complaint about job data.

What you get per role

FieldNotes
statusnew / changed / open / closed — the change-feed verdict for this run
changed_fieldson a changed row, exactly which tracked fields moved
first_seen / last_seenwhen the role first appeared in your feed and was last confirmed open
days_openhow long the role has been open (tenure)
is_ghosttrue for a closed row — the role vanished from the board
ghost_candidatetrue when a still-open role has been live long enough to be a likely ghost job — an evergreen listing the employer keeps posted without actively filling (the #1 job-seeker and recruiting-intel complaint). Distinct from is_ghost (vanished): this flags roles that stay up and go stale. Threshold via ghostDays (default 60).
ghost_signalswhy ghost_candidate fired: open_60d (we have observed it continuously open ≥ threshold — needs our maintained cross-run state), posted_60d (board's own posted_at is ≥ threshold old), stale_update_60d (board has not edited it in ≥ threshold — corroboration). Empty when not a candidate.
age_daysdays since the board's posted_at — the first-run age signal that powers ghost_candidate before cross-run history exists
title, company, location, remote, department, url, posted_at, updated_atnormalized core fields
location_parsedthe free-text location normalized into structured geography — {city, state, state_code, region, country, country_code, countries, workplace, is_remote, multi_location} — so you can filter on country_code / state_code / workplace without parsing strings yourself
workplaceremote / hybrid / onsite / null — the work mode, classified across the many phrasings boards use (Remote - US, WFH, Hybrid — Austin, TX)
salarynormalized pay-transparency range — {min, max, currency, period, annualized:{min,max}, source} — parsed from the employer's own live posting, or null when no range is disclosed
salary_disclosedtrue when a range was parsed for this role (quick filter / coverage metric)
employment_typefull_time / part_time / contract / temporary / internship, or null when undisclosed — resolved from the board's metadata type field (authoritative), the title, or the description boilerplate; a tracked field, so a role flipping contract→full-time comes back changed
seniorityintern / junior / mid / senior / staff / principal / lead / manager / director / vp / executive, mapped from the title (most-senior signal wins), or null — filter a feed to just senior+ / exec roles without regexing titles yourself
employment_type_source / seniority_sourcewhere each was resolved (metadata / title / description) so you can trust-weight them
requirementsstructured hiring requirements parsed from the description: {skills:[{name,category}], skill_names, skills_count, categories, years_experience_min, education_level, source}. Skills come from a maintained, boundary-anchored taxonomy (languages, frameworks, cloud, data, devops, ML, security, tools); years_experience_min is the lowest explicit experience floor in the posting; education_level is the minimum degree required. null when descriptions are off (never guessed from the title). Filter and rank a feed by tech stack / experience without running your own extractor.
requisition_id, internal_job_id, officesGreenhouse-specific identifiers for dedup / joins
description_text or description_htmloptional full description (plain text or raw HTML)

salary is on by default and a first-class tracked field: when an employer moves a role's posted range, that row comes back tagged changed with salary in changed_fields — a live pay signal a resold snapshot database cannot give you. Because Greenhouse publishes no structured salary field, the range is read and normalized from each posting at run time (period-annualized to a comparable yearly figure), so you get a uniform range even though every employer phrases it differently. It is returned without the heavy description payload unless you also ask for that.

location_parsed is on by default and needs no network — Greenhouse exposes location only as one free-text string ("San Francisco, CA", "Remote, Canada; Remote, United States",

"Remote from the US"
), so to filter by geography you would otherwise have to parse it yourself. This field splits it into city / state / country, recognizes US state abbreviations and full names, international cities, and the many ways a board writes remote/hybrid/onsite. For a role open in several regions it sets multi_location: true and lists every country_code in countries, so a country_code = "US" filter still matches a globally-open posting.

Input

FieldTypeDefaultDescription
companiesstring[]—Domain (stripe.com), board slug (stripe), or greenhouse:token. The board slug is auto-detected from a domain — no lookup needed. Optional when useRoster is on.
useRosterbooleanfalsePull from the maintained, live-verified roster of Greenhouse boards — a broad current feed without supplying a company list. Combine with companies to run both.
rosterLimitinteger0 (all)Cap how many roster boards to pull, richest-first (most open roles). Applies only to roster boards.
rosterFilterstring—Keep only roster boards whose company name or token contains this text (case-insensitive).
includeClosedbooleantrueEmit closed / ghost rows (roles present last run, gone now).
includeSalarybooleantrueParse + return the normalized salary range for each role.
includeDescriptionbooleanfalseAlso return the job description.
descriptionTypetext | htmltextDescription format when includeDescription is on.
maxItemsPerCompanyinteger0 (all)Cap live rows per company. The full board is always fetched so the diff is complete; the cap only trims output.
firstRunNewAsOpenbooleanfalseOn a board's first run (no prior snapshot) label everything open instead of flooding you with new.
ghostDaysinteger60Age (days) at which a still-open role is flagged ghost_candidate — a likely evergreen/unfilled listing. Uses the longest of days_open, posted_at age, and a stale updated_at. 0 = default (60).

The change-feed needs state between runs. On the Apify platform that state is kept in a named key-value store that persists across runs automatically — just run the actor on a schedule against the same companies and each run reports the delta since the previous one.

No company list? Use the maintained roster

Most Greenhouse feeds make you supply the companies. A resold "all of Greenhouse" database skips that — but at the cost of stale, cached data. This actor gives you the broad feed and the live truth: turn on useRoster and it pulls from a built-in roster of Greenhouse boards that is continuously re-verified and pruned — dead boards (companies that left Greenhouse, renamed their token, or went private) are dropped and live ones kept. That upkeep is the point: when we first built the roster, more than half of a naive hand-made list of well-known companies was already pointing at a dead or moved board. Maintaining a live roster of hundreds of board tokens is exactly the work you don't want to do — so we do it, and you get a current cross-company feed from one call.

node cli.js --roster-limit=25 # 25 richest boards, no company list
node cli.js --roster-filter=fintech # roster boards matching "fintech"
node cli.js stripe.com --roster # your own company PLUS the roster

On the Apify platform set useRoster: true (optionally with rosterLimit / rosterFilter). The roster is refreshed by scripts/refresh_roster.mjs, which live-probes every board, writes the verified set, and flags a structural break so the feed never silently decays.

Reliability SLA — not a promise, a signal

Greenhouse's public board JSON can silently drift (a renamed field, a moved endpoint). A plain "it ran" check misses that — the run succeeds and the data is quietly junk. Every run stamps a freshness/schema verdict (OK / EMPTY / SCHEMA_DRIFT / DOWN) onto its run metadata by probing a large known-good board and grading reachability, population, and key-field presence (id / title / url). The same check runs standalone (npm run freshness) so breakage is caught and patched upstream — usually before it reaches your runs.

Use without an account (CLI)

The exact same verified core runs as a zero-dependency CLI, with the change-feed snapshot kept in a local file:

node cli.js stripe.com --max=25
node cli.js greenhouse:gitlab --html --pretty
node cli.js stripe gitlab --state=/tmp/feed.json # run twice to see new → open deltas

Pricing

Pay per result — $0.80 per 1,000 rows (see .actor/PRICING.md). Each emitted row — live or ghost — is one billable result, and the run stops the instant your charge limit is reached.

Guarantees

  • Keyless & free upstream — reads only Greenhouse's public board API. No API keys, no proxies.
  • Bounded — per-request timeout + response-size ceiling; a stalled or oversized board can never hang a run or exhaust memory.
  • Safe — board tokens are validated and redirects are re-checked on every hop (see SECURITY.md).