ATS Jobs Feed & Scraper: Greenhouse, Lever, Ashby & more
Pricing
from $4.00 / 1,000 job exporteds
ATS Jobs Feed & Scraper: Greenhouse, Lever, Ashby & more
Get every open job of a company from Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee or Personio by company name, website or board URL. Markdown description, remote, seniority, employment type, salary in USD. Diff mode returns only new and closed jobs and flags re-posts.
Pricing
from $4.00 / 1,000 job exporteds
Rating
0.0
(0)
Developer
Vellum Kasane
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
3 days ago
Last modified
Categories
Security
Limited permissionsShare
ATS Jobs Feed (Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee, Personio)
Get every open job of a company from its careers page as clean JSON, without knowing which ATS it uses. Type company names or websites ("Datadog", "posthog.com") or paste board URLs. The Actor reads the official public job-board APIs of Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee and Personio and returns one record per job: description as Markdown, locations, remote flag and region, seniority, employment type (full-time, contract, internship...), years of experience, and the published salary converted to yearly USD. Turn on diff mode and a scheduled run returns only new, updated and closed jobs since the last run, and you pay only for those.
| Input | Company names, websites, board URLs or { "ats", "slug" } pairs, mixed freely, or the dataset of an ATS Job Board Finder run. Optional filters: keywords, location, remote, department, seniority, employment type, minimum yearly salary, posted in the last N days |
| Output | Title, department, locations, remote / hybrid, remote region, seniority, employment type, years of experience, salary (published, yearly, USD), Markdown description, job and apply URL, status |
| Price | $0.004 per job record, $0.01 per run. Unchanged and filtered-out jobs are free |
| Speed | 5 boards (one given by company name), 39 jobs in about 3 seconds after start-up on 256 MB. No browser, no proxy |
{"company": "Greenhouse","ats": "greenhouse","title": "Enterprise Sales Development Representative","department": "Sales Development","location": "Anywhere in the United States","remote": true,"remote_region": "United States","seniority": null,"employment_types": ["full-time"],"salary": {"min": 55800,"max": 64400,"currency": "USD","interval": "year","source": "description","annual_usd_max": 64400},"description_markdown": "Our [mission](https://www.greenhouse.com/mission) at Greenhouse is to make hiring work for everyone...","job_url": "https://job-boards.greenhouse.io/greenhouse/jobs/8234044?gh_jid=8234044","published_at": "2026-09-28T13:18:45.000Z","status": "listed","days_listed": 9}
Jump to the task you have:
- Get all open jobs of a company (Greenhouse, Lever, Ashby, ...) from its name
- Get only new and closed jobs since the last run (hiring alerts)
- Find senior, remote or well-paid roles across many companies
- Track competitors' or target accounts' hiring
- Feed job descriptions to an AI agent, RAG index or job board
- What this Actor cannot do
1. Get all open jobs of a company from its name or website
Put the companies in companies. You do not need to know the ATS:
{ "companies": ["Datadog", "posthog.com", "https://linear.app/careers", "https://jobs.lever.co/spotify"] }
- A board URL (
jobs.lever.co/spotify,job-boards.greenhouse.io/gitlab,jobs.ashbyhq.com/linear,apply.workable.com/blueground,careers.smartrecruiters.com/Bosch,acme.recruitee.com,acme.jobs.personio.de) is used as is. - A website is read politely (up to 5 pages: the page given, the home page,
/careers,/jobs; robots.txt honoured, no JavaScript) to find a link to one of the seven vendors. A link found this way is exact (confidence: "high"). - A name (or a website without such a link) is turned into up to 4 slug guesses (
Hugging Face->huggingface,hugging-face,hugging), and each vendor's public API is asked whether that board exists. Boards with open jobs win; a board with zero jobs only counts when the company's own site links to it (big companies often keep an empty account on a second vendor).
SUMMARY.json in the run's key-value store lists every lookup: resolved (ATS, slug, confidence, open jobs, evidence) and unresolved with a reason such as uses an unsupported ATS (workday) or no board found. Each job record carries company_query, the entry you typed. Lookups are not billed. Once a board is confirmed, pin it as { "ats": "ashby", "slug": "posthog" } to skip the lookup on later runs.
To only find out which ATS a list of companies uses (no jobs fetched, unsupported vendors named), use the companion Actor ATS Job Board Finder.
Chain it after ATS Job Board Finder (or any dataset of companies)
Put the finder run's dataset ID in companiesDatasetId and every board it found becomes a { ats, slug } entry here: no copy-paste, no second lookup. Companies the finder could not resolve are skipped (listed in SUMMARY.json → datasetSkipped), and the finder's query becomes company_query on each job.
{"companiesDatasetId": "<the finder run's dataset ID>","keywords": ["engineer"],"postedWithinDays": 14}
To run it automatically, open the finder → Integrations → Connect Actor or Task → ATS Jobs Feed, and set "companiesDatasetId": "{{resource.defaultDatasetId}}" in the input. Use a full finder run for this (no stateKey): a watch-mode run holds only the companies that changed, so the feed would fetch only those boards. For a daily jobs feed, schedule ATS Jobs Feed itself with diff: true and the dataset ID of one full finder run; unnamed run datasets are deleted after your plan's data retention period, so give that dataset a name (Storage → Datasets → Rename) to keep it. Any other dataset works too (a CRM export, another Actor's output): each row needs ats + slug, or a board_url / url / website / domain / company_name / company / name field. Reading the dataset is not billed.
2. Get only new and closed jobs since the last run
Set diff: true and schedule the Actor (daily or hourly) in Apify Schedules:
{ "companies": ["Datadog", "posthog.com", "gitlab"], "diff": true, "stateStoreName": "my-companies-daily" }
The first run returns every job as new. After that each run returns only:
status | When |
|---|---|
new | The job id was not seen before |
updated | Title, location, department, employment type or description changed (content_hash) |
closed | The job id is no longer anywhere on the board. Carries closed_at and days_listed |
Re-posts are flagged, not counted as new roles. When a job is taken down and published again under a new id in the same run window (SmartRecruiters gives a re-posted job a new id; recruiters everywhere duplicate stale postings to bump them), the new record carries reposted_from (the old id) and keeps the original first_seen and days_listed, and the closed one carries reposted_as. Pairing is one-to-one on the same title, location and department. Filter reposted_from out to count genuinely new roles; a long days_listed on a re-post is a hard-to-fill signal.
Unchanged jobs are not exported and not charged (set emitUnchanged: true to get them anyway). The state is a compact snapshot per job in the named key-value store stateStoreName; use one store per schedule or filter set.
closed is only reported when it can be proven: the Actor compares with every job id on the board (before your filters and maxJobs), so tightening keywords or seniority on an existing schedule does not produce false closures. A board that fails to load is left untouched, and a board that suddenly lists zero jobs is held for one run before its jobs are closed (SmartRecruiters answers HTTP 200 with an empty list for a company it does not know, and a short outage looks the same). days_listed on a closed job is a time-to-fill proxy.
3. Find senior, remote or well-paid roles across many companies
The seven vendors describe the same thing seven ways. Every record uses one schema, so one filter works across all of them:
{"companies": ["Datadog", "gitlab", "posthog.com", "linear.app"],"keywords": ["engineer"],"remoteOnly": true,"seniority": ["senior", "staff", "principal"],"employmentTypes": ["full-time", "unknown"],"minAnnualSalary": 150000,"salaryCurrency": "USD","postedWithinDays": 7}
seniority: from the title, one ofintern,junior,mid,senior,staff,principal,lead,manager,director,vp,executiveornull. Rank words win (Senior Directorisdirector), then level words (Senior Product Managerissenior);Engineer IIismid,IIIissenior. Personio's own level code fills it when the title has none.employment_types: one comparable list, a subset offull-time,part-time,contract,temporary,internship. Read from the vendor's own field first (LeverFull-time/Regular Full Time (Salary), AshbyFullTime, Recruiteefulltime_fixed_term, Personiointern+full-or-part-time), else from the title (Software Engineering Intern,Recruiter (11-month contract),Specialist, Contractor; a bareContract Manageris not a contract job), else from an explicit sentence in the description (This role is a full time position,Employment type: Contract). A fixed-term contract istemporaryonly.employmentTypes: ["internship"]gives an internship feed; addunknownto keep jobs that state no type.salary: the vendor's structured range (Greenhouse pay transparency, LeversalaryRange, Ashby compensation, Recruitee, Personio;source: "ats"), otherwise the first range stated in the description ($120,000 - $160,000 CAD,€60.000–€80.000,$45 - $60 per hour;source: "description").annual_min/annual_maxturn hourly (× 2,080) and monthly (× 12) pay into yearly pay;annual_usd_min/annual_usd_maxconvert it at the day's European Central Bank rate.minAnnualSalarycompares with the top of the yearly range and drops jobs without a published salary. Set it to1to keep only jobs that disclose pay.postedWithinDays: keep only jobs the company published in the last N days (1= last 24 hours,7= last week), by the vendor's publish datepublished_at. Use it for a first run or a one-off "what is fresh" pull; for a daily schedule, diff mode (section 2) is more exact because it compares with what you already have. Filtered jobs are not billed.remote_region: where a remote job can be worked from (Remote - EMEA->EMEA,Anywhere in the United States->United States, bareRemote->Worldwide).
4. Track competitors' or target accounts' hiring
Put 20 to 100 companies in companies, diff: true, schedule daily, and connect the dataset to Slack, email, Google Sheets or a webhook with Apify integrations. Each morning you get the roles that opened and closed:
{"companies": ["Datadog", "gitlab", "posthog.com", "https://linear.app/careers"],"diff": true,"departments": ["Engineering", "Sales"]}
Useful fields for sales and recruiting intelligence: department, team, locations (new offices show up here first), seniority (a new director or vp role often signals a new team), days_listed on closed jobs (how long a role took to fill), and the salary band.
5. Feed job descriptions to an AI agent, RAG index or job board
description_markdown is the vendor's HTML converted to GitHub-flavoured Markdown (headings, lists, links; Lever's sections and Recruitee's requirements merged into one document; entities decoded). Set includeHtml: true to also get the original HTML. AI agents can call the Actor through the Apify MCP server and read the dataset; job boards and aggregators get one normalised schema instead of seven parsers. job_id is stable per vendor, and content_hash changes only when the job's text changes, so you can upsert by ats + company_slug + job_id.
Hacker News "Who is hiring?" posts are available in the same columns (seniority, remote_region, salary.annual_usd_*) from the companion Actor Hacker News Threads & Who is Hiring Exporter, so both sources fit in one table.
What this Actor cannot do
Read this before your first run. None of these fail the whole run: a broken board or lookup is reported in SUMMARY.json and the other boards continue. The run fails only when every board fails.
| Limit | What happens | What to do |
|---|---|---|
| Other ATS vendors (Workday, iCIMS, Taleo, SuccessFactors, BambooHR, Teamtailor, Jobvite...) | Not supported. The company is listed in unresolved, with the vendor named when its website reveals it | None in this Actor. It only uses vendors with a public job-board API |
Board slug unrelated to the name (BoschGroup, acme-corp-2019) | The name-based guess misses it; found only through a link on the company's website | Give the website or the board URL instead of the bare name |
| Namesakes | A guessed slug can belong to another company with the same name | Check confidence and company; pin the confirmed board with { ats, slug } |
| Careers pages rendered by JavaScript | The website step finds no link (no browser is used); the slug guesses still run | Give the board URL |
Custom careers domains on Recruitee or Personio (careers.example.com) | The vendor cannot be detected from that URL | Pass { "ats": "recruitee", "slug": "<subdomain>" } or the company name |
| SmartRecruiters companies with zero jobs | SmartRecruiters returns an empty list for every unknown id, so an empty board cannot be told from a wrong id | Check the id on careers.smartrecruiters.com/<id> |
| Publish date differs by vendor | published_at is Greenhouse first_published, Lever createdAt (can predate the posting), Ashby publishedAt, Workable published_on, SmartRecruiters releasedDate, Recruitee published_at, Personio createdAt. A reposted job may carry a new date. Jobs without a date are dropped while postedWithinDays is set | Use diff mode to catch truly new jobs regardless of the vendor's date |
| SmartRecruiters boards above 5,000 postings | Only the first 5,000 are listed and closures are not reported for that board | Use keywords / departments to narrow it |
| Salary not published | salary is null; jobs are dropped when minAnnualSalary is set. Workable and SmartRecruiters have no salary field, only description text | Leave minAnnualSalary at 0 to keep them |
| Employment type not published | Greenhouse has no employment field: on 4 Greenhouse boards (927 jobs, 2026-10-22) only 22 jobs stated a type in the title or an explicit sentence, the rest have employment_types: []. On the other six vendors 99% of jobs carry one. Benefit text ("full-time employees get...") is ignored on purpose, so a full-time job is not recognised from it | Add unknown to employmentTypes to keep untyped jobs; filter internships, which are almost always named in the title |
| Seniority not in the title | A "Software Engineer" whose description says "senior" stays null; years_experience_min takes the first "N years of experience" statement, sometimes a nice-to-have | Combine with keywords |
| Hidden or internal jobs | Only published jobs are returned (Ashby isListed: false is skipped) | — |
| Re-post detection is same-run only | A re-post is recognised when the old id closes and the new id appears in the same run with an identical title, location and department. A re-post with an edited title, or one whose new id is filtered out, shows as a plain closed + new. Both records are still billed as usual | Schedule daily so both sides land in one run |
| A company moves to another ATS | Every job id changes: one wave of closed plus new in diff mode | Update the entry to the new board |
| An emptied board in diff mode | Its jobs are reported closed one run later than they disappeared (the empty-board hold) | — |
companiesDatasetId above 5,000 rows | Only the first 5,000 rows are read; rows without a usable field are skipped and listed in datasetSkipped. A dataset of another Apify account is readable only if it is shared or public | Split the list, or share the dataset |
| Rate limits | The vendors do not publish limits for these endpoints. Boards are fetched one after another, 30 s timeout, 2 retries with back-off on 429 and 5xx; SmartRecruiters details 4 at a time; lookups 3 at a time, at most 100 per run | Split lists above 100 names into several runs, or pass board references (no lookup) |
| USD conversion unavailable | When the ECB rate service is unreachable, annual_usd_* are null and a USD minAnnualSalary only matches USD ranges | — |
| Budget cap | With Max total charge per run or maxJobs, the run stops cleanly. In diff mode, jobs not exported stay pending and come in the next run | Raise the cap |
| Run aborted or migrated | Since 0.7 the diff state is saved for the board in progress, so jobs already exported are not exported (or charged) again by the next run | — |
Input
| Field | Type | Default | Notes |
|---|---|---|---|
companies | array | [] | Required unless companiesDatasetId is set. Company names, websites, board URLs and/or { ats, slug } objects. ats is greenhouse, lever, ashby, workable, smartrecruiters, recruitee or personio. Lever EU boards: "region": "eu" or the jobs.eu.lever.co URL; Personio on .jobs.personio.com: "region": "com" or the URL |
companiesDatasetId | string | — | ID of a dataset whose rows are added to companies: ATS Job Board Finder rows via feed_input (unresolved rows skipped), other rows via ats + slug or a URL / website / name field. {{resource.defaultDatasetId}} in an integration. Up to 5,000 rows, not billed |
keywords | string[] | [] | Keep jobs whose title or description contains any of these (case-insensitive) |
locationContains | string[] | [] | Keep jobs whose location contains any of these |
remoteOnly | boolean | false | Keep only remote: true (vendor flag, else inferred from location and title) |
departments | string[] | [] | Keep jobs whose department or team contains any of these |
seniority | string[] | [] | Keep these levels (none = titles without a level word) |
employmentTypes | string[] | [] | Keep jobs with any of full-time, part-time, contract, temporary, internship (unknown = no type stated) |
postedWithinDays | integer | 0 | Keep jobs published in the last N days (0 = all open jobs) |
minAnnualSalary | integer | 0 | Keep jobs whose yearly pay (top of range) reaches this amount |
salaryCurrency | string | USD | Currency of minAnnualSalary. With USD, other currencies are converted; with another code only ranges in that currency match |
maxJobs | integer | 500 | Hard cap on exported records per run (all boards). Caps your cost |
diff | boolean | false | Return only new / updated / closed versus the previous run |
emitUnchanged | boolean | false | Diff mode: also export unchanged jobs |
stateStoreName | string | ats-jobs-feed-state | Named key-value store holding the diff state |
includeHtml | boolean | false | Add the original description HTML (description_html) |
Output fields
One dataset item per job, downloadable as JSON, CSV, Excel or HTML. Besides the fields in the example above: company_slug, company_query, board_url, job_id, team, locations, workplace_type (remote / hybrid / onsite / null), employment_type (the vendor's label as published), years_experience_min, apply_url, updated_at (Greenhouse only), first_seen, closed_at, reposted_from, reposted_as, content_hash, fetched_at. salary also carries bound (range / exact / up_to / from), summary, annual_min, annual_max and annual_usd_min.
SUMMARY.json (run key-value store) lists per board the API URL, jobs fetched / matched / exported, new / updated / unchanged / closed counts, emptyHeld, any error, plus resolved, unresolved, fxDate and the charged events.
Pricing
Pay-per-event. Platform usage (compute, storage) is included.
| Event | Price | When |
|---|---|---|
job-exported | $0.004 per record ($4 per 1,000) | Once per record written to the dataset, including closed records in diff mode. Filtered-out, unchanged (unless emitUnchanged) and duplicate jobs, lookups and failed boards are never charged |
| Actor start | $0.01 per run | Apify's standard start event |
Examples: a one-off pull of a 300-job board costs $1.21. A daily diff schedule over 50 companies that together open or close 40 jobs a day costs about $0.17 a day. Set Max total charge per run to hard-cap spend.
Data sources
| ATS | Endpoint | Documentation |
|---|---|---|
| Greenhouse | boards-api.greenhouse.io/v1/boards/{slug}/jobs?content=true&pay_transparency=true | https://docs.greenhouse.io/job-board.html |
| Lever | api.lever.co/v0/postings/{slug}?mode=json (EU: api.eu.lever.co) | https://github.com/lever/postings-api |
| Ashby | api.ashbyhq.com/posting-api/job-board/{slug}?includeCompensation=true | https://developers.ashbyhq.com/docs/public-job-posting-api |
| Workable | apply.workable.com/api/v1/widget/accounts/{subdomain}?details=true | https://workable.readme.io/reference/jobs-1 |
| SmartRecruiters | api.smartrecruiters.com/v1/companies/{id}/postings + one detail call per job | https://developers.smartrecruiters.com/docs/endpoints |
| Recruitee | {company}.recruitee.com/api/offers/ | https://docs.recruitee.com/reference/offers |
| Personio | {company}.jobs.personio.de/xml (or .jobs.personio.com) | https://developer.personio.de/docs/retrieving-open-job-positions |
All are public, documented, key-free read endpoints for published jobs. Job data comes only from these APIs; company websites are read only to find board links and nothing from them is stored.
Verified
- 0.9 (2026-10-14): platform run with
companiesDatasetId= an ATS Job Board Finder run of 8 companies: 7 boards read fromfeed_input,SmartRecruiters(not found) skipped intodatasetSkipped,keywords: ["engineer"],maxJobs: 5: 5 records withcompany_query: "PostHog", 5job-exportedevents. Unit tests: 43 (npm test). - 0.8 (2026-10-13):
postedWithinDays: 14on 4 boards (Greenhouse,posthog.com, SmartRecruiters, Personio): 5 of 29 open jobs kept (dates 2026-10-05 to 10-09), only those exported. Platform run (Greenhouse,posthog.com, Workable, Personio): 6 records, oldest 2026-10-05. Unit tests: 41 (npm test). - 0.7 (2026-10-08): prefill input (5 boards, one given as
posthog.com,maxJobs: 60): 39 records (Greenhouse 20, Ashby 10, Workable 8, Personio 1), 0 errors;salaryon 15,seniorityon 22,remoteon 19. Abort test on the platform: see the changelog. Unit tests: 39 (npm test). - 0.6 (2026-10-07): diff mode, three runs on one state store: 3
new-> board emptied, held (0closed) -> still empty, 3closed. - 0.5 (2026-10-03):
ashby:linear30new, thenkeywords: ["engineer"]on the same state: 25 of 30 matched and 0closed(0.4 would have billed 5 false closures).smartrecruiters:ServiceNow(705 postings) likewise 0closed. - 0.3 (2026-09-30):
PostHogandposthog.com->ashby:posthog(fetched once),https://linear.app/careers->ashby:Linearvia a page link (high),Datadog->greenhouse:datadogby slug guess (high, 438 jobs);Boschunresolved (real boardsmartrecruiters:BoschGroup, not guessable).
The platform runs the prefill input daily as an automated check.
Changelog
- 0.12.2 (2026-10-27): salary parsing reads "$165k + 20% bonus" / "+ commission" / "+ benefits" as a fixed amount, not "or more" (shared with Vellum Labs' HN Who-is-Hiring Actor). 5,659 Greenhouse jobs on 25 boards re-parsed: no change.
- 0.11 (build 2026-10-20): shared salary parser: a pay interval is only read from the words next to the amount ("$148K – $255K — hybrid, 2x/week in office" is yearly, it was dropped), and a lone amount after a spaced dash or a colon ("Salary: $150,000", "Senior Engineer - $178,500 USD + equity") is read. Output fields unchanged.
- 0.11 (build 2026-10-19): shared salary parser with the HN Who is Hiring Actor: description ranges written
$180,000--$250,000are read; equity, RSU, PSU and bonus amounts ("up to $48,402 RSUs") are never read as pay. Output fields unchanged. - 0.11 (2026-10-18):
salary.boundsays how to read the figures:range,exact(one figure),up_to("up to X",minnull) orfrom("from X", "X+",maxnull). The description parser also reads "$180k+", "up to ~ $X" and rates written "/hr" ("$34 USD/hour"). Same normaliser as the Hacker News Who is Hiring Actor. - 0.10 (2026-10-15): diff mode flags re-posts (
reposted_from/reposted_as, originalfirst_seenkept). SmartRecruiters: duplicate ids from shifted offset pages are dropped (they were exported and charged twice), and closures are only reported when the listing holdstotalFounddistinct ids. - 0.9 (2026-10-14):
companiesDatasetIdreads the companies from a dataset, e.g. an ATS Job Board Finder run (feed_input), so the two Actors chain through an integration.companiesis no longer required when a dataset is given. - 0.8 (2026-10-13):
postedWithinDaysfilter (jobs published in the last N days; filtered jobs are not billed). Error messages and the input description now list all seven vendors. - 0.7 (2026-10-08): diff state is also saved when a run is migrated or aborted in the middle of a board (previously jobs already exported from that board could be exported and charged again by the next run). README rewritten by task, with a limits table. Prices change on 2026-10-22 (see Pricing).
- 0.6 (2026-10-07): a board that suddenly lists zero jobs is held for one run before its jobs are closed.
- 0.5 (2026-10-03):
closedis decided against every job id on the board, not the filtered subset; newdays_listed. - 0.4 (2026-10-02): Recruitee and Personio added (seven vendors).
- 0.3 (2026-09-30): company names and websites accepted directly in
companies. - 0.2 (2026-09-29):
seniority,years_experience_min,remote_region, structuredsalarywith USD conversion, and their filters. - 0.1 (2026-09-22): initial release, five vendors, diff mode, pay-per-event billing.
Support
Open an issue on the Actor's Issues tab. Issues are answered within one business day; bug fixes ship as new builds without changing the input schema.
Made by Vellum Labs.