ATS Jobs Scraper: Greenhouse, Lever, Workday + 6 More, $1.50/1K
Pricing
from $1.50 / 1,000 job scrapeds
ATS Jobs Scraper: Greenhouse, Lever, Workday + 6 More, $1.50/1K
Get live job openings from any company, whatever its ATS: Greenhouse, Ashby, Lever, Workday, SmartRecruiters, Workable, Personio, Rippling, Recruitee. One unified schema, cross-ATS dedup, new-jobs-only delta mode.
Pricing
from $1.50 / 1,000 job scrapeds
Rating
5.0
(1)
Developer
Alex Morain
Maintained by CommunityActor stats
1
Bookmarked
1
Total users
1
Monthly active users
8 days ago
Last modified
Categories
Share
ATS Jobs Scraper: one job listings API for 9 applicant tracking systems
This is a career site scraper that reads job openings straight from the applicant tracking system each company publishes with: Greenhouse, Ashby, Lever, Workday, SmartRecruiters, Workable, Personio, Rippling and Recruitee. You give it a list of companies, it returns their live jobs in one schema, whatever board each of them happens to run. No browser, no HTML parsing, no anti-bot games: every source here is a public JSON or XML endpoint that the ATS itself serves to the company's own careers page. Pricing is $0.01 per run plus $0.004 per job on the free plan, going down to $0.0015 per job on higher plans.
Three things a single-ATS scraper cannot do, and this one does.
A built-in directory of 17,177 job boards. You do not need to know which ATS a company uses.
Pass stripe.com and the embedded index resolves it. If the company is not in the index, the Actor
probes the eight probeable ATS APIs live with the domain name. Board URLs and explicit ats:slug
pairs are accepted too, for the cases where you want no guessing at all.
Cross-ATS deduplication, because migrations leave ghosts. When a company moves from one ATS to another, the old board often keeps serving every job. Measured case, August 2026: one well-known database company served the exact same 94 openings on both its Greenhouse and its Ashby board. An aggregator that does not deduplicate returns them twice and bills you twice. This Actor drops the duplicate before pushing, and a job you are not shown twice is a job you are not charged for twice.
A delta mode that turns scraping into monitoring. In new-only mode the Actor keeps a
persistent registry of every job it has already returned and outputs only what is new since the last
run. Schedule it daily over your target list and a quiet day costs one cent. Add a webhookUrl and
your endpoint gets called the moment a company opens a role.
How to scrape jobs from any company career site
From the Apify console
- Open the Input tab. Three companies are prefilled, one in each accepted format, so you can press Start immediately and see the shape of the output before deciding anything.
- Replace them with your own list, one company per line. A bare domain (
stripe.com) is the simplest form and covers most companies. - If a company fails to resolve, the run log names it explicitly. Open that company's careers page,
copy the job board URL it redirects to (
https://jobs.lever.co/acme,https://acme.wd5.myworkdayjobs.com/External), and paste that instead. A company that fails does not stop the others. - Optionally narrow the result with title keywords, location keywords or remote only. Filtering happens inside the Actor before anything is returned, so a filtered-out job is never charged. Turning a 3,000-job board into the 40 roles you care about costs you 40 jobs.
- Press Start, then export the dataset as JSON, CSV or Excel. Or switch the mode to New jobs only and put the Actor on a daily schedule.
From the API
Every run is one POST to the Apify API with the same JSON input you would fill in the console. The
Actor ID is alexmorain/ats-jobs-scraper. Full working snippets are in the
Call it from your code section below.
Which fields should I fill?
| If you want to... | Fill in | Example | Leave alone |
|---|---|---|---|
| Export every opening of a company list | companies | ["stripe.com", "figma.com"] | everything else |
| Track a single company's board | companies with one entry | ["greenhouse:duolingo"] | everything else |
| Monitor new openings daily | companies, mode, a schedule | mode: "new-only" | everything else |
| Get pushed a notification when a role opens | add webhookUrl to the above | https://hooks.example/new-jobs | everything else |
| Build a targeted list of roles | titleKeywords, locationKeywords | ["engineer"], ["Berlin"] | includeDescription |
| Keep only remote roles | remoteOnly | true | — |
| Classify jobs downstream with an LLM | includeDescription | true | filters |
| Stop one giant company from dominating the bill | maxJobsPerCompany | 200 | — |
| Share one delta memory between two schedules | registryKey | "eu-fintech-watch" | — |
companies is the only required field. Everything else is optional and off by default, so a first
run needs nothing but the list.
Input in ten seconds:
{"companies": ["stripe.com", "https://jobs.lever.co/acme", "greenhouse:duolingo"],"mode": "new-only","titleKeywords": ["engineer", "developer"],"locationKeywords": ["Paris", "Remote"],"remoteOnly": false,"includeDescription": false,"maxJobsPerCompany": 0,"webhookUrl": "https://your-endpoint.example/new-jobs"}
Supported ATS platforms, one by one
Nine systems, nine different payloads, one output schema. Each section below says how to identify a company on that platform, what identifier the input accepts, what that ATS gives you that the others do not, and where it falls short. The counts are how many boards of that platform sit in the built-in directory.
Greenhouse jobs API
Greenhouse is the most common board in the directory: 4,558 entries. A company on Greenhouse has a
board token, usually its name in lowercase with no spaces. You will see it in the URL of its job
list: https://job-boards.greenhouse.io/duolingo, https://boards.greenhouse.io/duolingo, or
https://job-boards.eu.greenhouse.io/acme for EU-hosted boards. Some companies proxy the board under
their own domain (careers.duolingo.com), in which case the apply links still carry a gh_jid
parameter, and the token is what appears after /boards/ in the page's network calls.
Accepted input: duolingo.com, https://job-boards.greenhouse.io/duolingo, or greenhouse:duolingo.
The whole board comes back in a single request, which makes Greenhouse the cheapest and fastest of
the nine. You get title, location, department, apply URL and a real publication timestamp
(first_published, falling back to updated_at). Greenhouse publishes no salary range and no
employment type in its board API, so those columns stay null. The remote flag is inferred from the
location and title text, since Greenhouse has no remote field.
Limit worth knowing: asking for descriptions switches the request to content=true, which multiplies
the payload size by roughly 12. It is one request either way, so the run stays fast, but the dataset
gets heavy. Departments are only served on some boards.
Ashby jobs API
3,001 boards in the directory. Ashby boards live at https://jobs.ashbyhq.com/<slug>, and the slug
is almost always the company name in lowercase. Ashby is popular with recent startups, so if a
company was founded after 2020 and is not on Greenhouse, try Ashby first.
Accepted input: linear.app, https://jobs.ashbyhq.com/linear, or ashby:linear.
Ashby has the richest payload of the nine. One request returns the full board with compensation
included, so you get salary_min, salary_max, salary_currency and salary_period on every job
that publishes a range. It also serves a real isRemote boolean rather than a guess, an
employmentType label (FullTime, Contract, Intern), a department or team, a published
timestamp, and secondary locations, which the Actor joins into the location field with ; .
Limit worth knowing: a company can hide compensation on its public board even when it stores it in Ashby, so an empty salary means "not published", not "no salary". Slugs with unusual characters are URL-encoded automatically.
Lever jobs scraper
598 boards in the directory, which understates Lever's real footprint: it is an older platform and
its boards are less well covered by public web indexes than the newer ones. Lever boards are at
https://jobs.lever.co/<slug> or https://jobs.eu.lever.co/<slug> for EU tenants.
Accepted input: acme.com, https://jobs.lever.co/acme, or lever:acme.
Lever gives a salary range, a workplace type that maps cleanly onto the remote boolean
(remote → true, on-site and hybrid → false), a team, a commitment label used as
employment_type, a creation timestamp, and a list of all locations for multi-city postings. Like
Greenhouse and Ashby, the whole board arrives in one request.
Limit worth knowing: Lever answers 200 with a JSON object instead of an array when the slug is
wrong in certain ways, rather than a clean 404. The Actor treats that as a fetch failure for that
company and logs it, instead of silently returning nothing.
Workday jobs scraper
2,886 tenants in the directory, and the platform of choice for large enterprises. Workday is the one
platform where a bare domain is not enough. Its boards are addressed by three parts, all visible in
the public URL https://guardianpharmacy.wd5.myworkdayjobs.com/Guardian_Pharmacy: the tenant
(guardianpharmacy), the instance (wd5, sometimes wd1, wd3, wd103), and the site name
(Guardian_Pharmacy, often External or Careers).
Accepted input: the board URL, which is by far the easiest, or the explicit triplet
workday:guardianpharmacy|wd5|Guardian_Pharmacy. A bare domain resolves only if that tenant is
already in the built-in directory; live probing skips Workday because the three parts cannot be
guessed from a domain name.
Workday paginates 20 jobs at a time over a POST endpoint, so a 2,000-job tenant is 100 requests and takes a while. You get title, location text and a stable apply URL built from the external path.
Two limits worth knowing, both of them things you will actually see in the output. First,
posted_at is null on Workday in list mode. Workday only serves a relative label there, like
"Posted 30+ Days Ago", and the Actor refuses to convert a label into a fake timestamp. Turn
includeDescription on and the per-job detail call brings back a real start date, which does fill
posted_at. Otherwise use new-only mode, where first-seen date is your reliable dating. Second,
Workday only reports the total number of jobs on the first page of pagination; later pages report
zero. The Actor reads the total once and keeps paging until a page comes back empty, so you get the
whole board, but a progress estimate from the run log will look odd.
One more Workday quirk: a 422 response means the site was renamed, not that you are blocked. Open
the company's careers page again and copy the current board URL.
SmartRecruiters API
829 boards in the directory. SmartRecruiters boards are at
https://jobs.smartrecruiters.com/<Slug> or https://careers.smartrecruiters.com/<Slug>. The slug
is case-sensitive, which trips people up: FocusReactive works, focusreactive does not.
Accepted input: the board URL, smartrecruiters:FocusReactive, or a domain if the directory knows
it.
SmartRecruiters paginates 100 jobs per request and returns a well-structured location (city, region,
country, plus a real remote flag), a department, an employment type label and a released date.
Limit worth knowing, and it explains a confusing behaviour: SmartRecruiters never returns 404. An
unknown company answers 200 with totalFound: 0, exactly like a real company that currently has no
openings. Because of that, the live probe only accepts a SmartRecruiters board when it has at least
one posting. If you are tracking a company whose SmartRecruiters board is genuinely empty right now,
pass it as smartrecruiters:<Slug> explicitly and the Actor will read it directly. Descriptions cost
one extra HTTP request per job here, which is why includeDescription is off by default.
Workable jobs
3,153 boards in the directory, the second largest group. Workable boards are at
https://apply.workable.com/<slug>/, and the slug is usually the company name in lowercase with
hyphens (proactive-technology-management).
Accepted input: acme.com, https://apply.workable.com/acme, or workable:acme.
One request returns the entire board, descriptions included when you ask for them via details=true,
so Workable is the cheapest of the nine when you do want descriptions. You get a real telecommuting
boolean mapped to remote, a department, an employment type, a publication date and a structured
location list.
Limit worth knowing: Workable publishes dates as plain calendar days, so posted_at comes out as
midnight on that day rather than an exact time. No salary range is exposed on the public widget API.
Personio jobs
668 boards in the directory, heavily concentrated in Germany, Austria and Switzerland. Personio
boards are at https://<slug>.jobs.personio.de or .jobs.personio.com. The slug is the subdomain.
Accepted input: saeki.com, https://saeki.jobs.personio.de, or personio:saeki.
Personio is the only source here that is XML rather than JSON: the Actor reads the tenant's public
/xml feed and flattens it. You get the office as the location, a department, an employment type
(permanent, intern), a creation timestamp, and the job description split into named sections
which the Actor concatenates. The apply URL is rebuilt from the job ID.
Limits worth knowing: Personio has no remote field, so remote is inferred from the office name and
the keywords text. Many German-language boards write locations and titles in German, so your title
and location keyword filters need German words to match them. No salary range.
Rippling jobs
776 boards in the directory. Rippling boards are at https://ats.rippling.com/<slug>, where the slug
is often the company name with careers appended (rightcrowdcareers).
Accepted input: acme.com, https://ats.rippling.com/acme-careers, or rippling:acme-careers.
One request returns the board. Be aware that Rippling has the poorest payload of the nine: five
usable fields, and that is all it publishes. You get the title, the apply URL, a job UUID and a
department label. Rippling serves no description, no posting date, no salary and no employment
type, so those columns are null on every Rippling row, and turning includeDescription on changes
nothing for Rippling companies.
One more thing you will see in the data: the workLocation.label field is free text set by the
company, and some of them put things in there that are not locations at all, including email
addresses. The Actor passes it through as-is rather than dropping it, so treat Rippling locations as
lower-confidence than the rest.
Recruitee jobs
708 boards in the directory, mostly European. Recruitee boards are at
https://<slug>.recruitee.com, and the slug is the subdomain.
Accepted input: accso.de, https://accso.recruitee.com, or recruitee:accso.
Recruitee is one of the three platforms that publish salary. You get salary_min, salary_max,
currency and period, plus real remote and hybrid flags (the Actor sets remote: true when either
is set), a department, an employment type code such as fulltime_permanent, a location and a
description split into body and requirements.
Limit worth knowing, and it affects run time: Recruitee rate-limits by calling IP across all
tenants, not per company. Measured behaviour is a 429 from about ten parallel requests. The Actor
therefore applies a dedicated global throttle of 1.3 seconds between Recruitee calls. A list of 50
Recruitee companies takes about a minute longer than the same list on any other platform.
Recruitee also serves its salary bounds as strings rather than numbers, sometimes with trailing
decimals ("90000.00"). The Actor casts them, so salary_min and salary_max come out as numbers
on all three salary platforms and you can compare them without casting anything yourself. A bound
that does not read as a number comes out null rather than as a guess, and the original string stays
in raw.
Output fields: the unified job schema
One row per unique live job. Every field exists on every row; a field an ATS does not publish is
null rather than missing, so your downstream code never has to test for key presence. The last
column is the honest part: which of the nine platforms actually fill that field.
| Field | Type | Description | Example | Filled by |
|---|---|---|---|---|
ats | string | Which system served this job. One of the nine names. | "greenhouse" | all 9 |
company | string | The company exactly as you wrote it in the input. A domain stays a domain, a board URL stays a URL, an ats:slug pair becomes the slug. | "duolingo" | all 9 |
board_slug | string | The board identifier on that ATS. For Workday, the tenant|instance|site triplet. | "duolingo" | all 9 |
job_id | string | Job identifier, unique inside its board. Combined with ats and board_slug it is the deduplication key. | "8653419002" | all 9 |
title | string | Job title as published. Never modified. | "Ad Sales Lead, Central" | all 9 |
location | string | Location text as the ATS serves it. Multiple locations are joined with ; . | "Paris, France; Remote" | all 9 |
remote | boolean | True when the ATS flags the job remote, or when the location or title says so. Null when the source gives no signal at all. | true | flag: Ashby, Lever, SmartRecruiters, Workable, Recruitee. Inferred from text: Greenhouse, Workday, Personio, Rippling |
department | string | Department or team. | "Engineering" | Greenhouse (partial), Ashby, Lever, SmartRecruiters, Workable, Personio, Rippling, Recruitee |
employment_type | string | Employment type label, in each ATS's own vocabulary. | "Full-time", "FullTime", "fulltime_permanent" | Ashby, Lever, SmartRecruiters, Workable, Personio, Recruitee |
posted_at | string | Publication date, ISO 8601, only when the source serves a real date. Never a guess, never a converted relative label. | "2026-07-28T09:47:45-04:00" | Greenhouse, Ashby, Lever, SmartRecruiters, Workable, Personio, Recruitee. Workday only with descriptions on. Never Rippling |
apply_url | string | Public URL of the posting or the application form. | "https://job-boards.greenhouse.io/acme/jobs/4051234567" | all 9 |
salary_min | number | Lower bound of the published range. Always a number: Recruitee serves its bounds as strings and they are cast, anything unparseable is null. | 140000 | Ashby, Lever, Recruitee |
salary_max | number | Upper bound of the published range. | 160000 | Ashby, Lever, Recruitee |
salary_currency | string | Currency code of the range. | "USD" | Ashby, Lever, Recruitee |
salary_period | string | Interval, in the ATS's own wording. | "1 YEAR", "per-year-salary", "year" | Ashby, Lever, Recruitee |
description | string | Full job description, HTML stripped to plain text. Only filled when includeDescription is true. | "About Stepful: ..." | Greenhouse, Ashby, Lever, Workday, SmartRecruiters, Workable, Personio, Recruitee. Never Rippling |
raw | object | The untouched original payload from the ATS. Anything platform-specific that the unified schema drops lives here. | {"id": 8653419002, "requisition_id": "R-01182", ...} | all 9 |
raw is the escape hatch, and it is worth knowing what is in it. Greenhouse puts requisition IDs,
office lists and custom metadata there. Lever keeps its formatted description sections and salary
description text. Recruitee carries around forty extra fields including tags, education codes and
open questions. SmartRecruiters includes experience level, industry and its own reference numbers. If
the column you want is not in the unified schema, look in raw before assuming it is not there.
Example output
A real row, exactly as it lands in the dataset (Greenhouse, no description requested, raw trimmed
here for readability):
{"ats": "greenhouse","company": "duolingo","board_slug": "duolingo","job_id": "8653419002","title": "Ad Sales Lead, Central","location": "Remote - Illinois","remote": true,"department": null,"employment_type": null,"posted_at": "2026-07-28T09:47:45-04:00","apply_url": "https://careers.duolingo.com/jobs/8653419002?gh_jid=8653419002","salary_min": null,"salary_max": null,"salary_currency": null,"salary_period": null,"description": null,"raw": {"id": 8653419002,"internal_job_id": 6455838002,"requisition_id": "R-01182","title": "Ad Sales Lead, Central","company_name": "Duolingo","absolute_url": "https://careers.duolingo.com/jobs/8653419002?gh_jid=8653419002","location": { "name": "Remote - Illinois" },"first_published": "2026-07-28T09:47:45-04:00","updated_at": "2026-07-28T11:26:11-04:00","language": "en","application_deadline": null}}
And an Ashby row, which shows what a salary-publishing board looks like:
{"ats": "ashby","company": "stepful","board_slug": "stepful","job_id": "4bfd71f0-f7a6-480e-a844-a37910baf8cb","title": "Chief of Staff","location": "New York City","remote": true,"department": "Executive","employment_type": "FullTime","posted_at": "2026-07-15T18:26:15.440000+00:00","apply_url": "https://jobs.ashbyhq.com/stepful/4bfd71f0-f7a6-480e-a844-a37910baf8cb/application","salary_min": 140000,"salary_max": 160000,"salary_currency": "USD","salary_period": "1 YEAR","description": null,"raw": { "isRemote": true, "workplaceType": "Remote", "team": "Executive" }}
Call it from your code
Python
from apify_client import ApifyClientclient = ApifyClient("<YOUR_APIFY_TOKEN>")run_input = {"companies": ["stripe.com","greenhouse:duolingo","https://jobs.lever.co/acme","workday:guardianpharmacy|wd5|Guardian_Pharmacy",],"mode": "all","titleKeywords": ["engineer", "developer"],"includeDescription": False,}run = client.actor("alexmorain/ats-jobs-scraper").call(run_input=run_input)for job in client.dataset(run["defaultDatasetId"]).iterate_items():print(job["ats"], "|", job["company"], "|", job["title"], "|", job["location"])print(" ", job["apply_url"])
JavaScript
import { ApifyClient } from 'apify-client';const client = new ApifyClient({ token: '<YOUR_APIFY_TOKEN>' });const input = {companies: ['stripe.com', 'ashby:linear', 'https://apply.workable.com/acme'],mode: 'new-only',registryKey: 'eu-fintech-watch',webhookUrl: 'https://your-endpoint.example/new-jobs',};const run = await client.actor('alexmorain/ats-jobs-scraper').call(input);const { items } = await client.dataset(run.defaultDatasetId).listItems();for (const job of items) {console.log(`${job.title} — ${job.company} — ${job.location} — ${job.apply_url}`);}
cURL
Run it synchronously and get the dataset back in the same response:
curl -X POST \"https://api.apify.com/v2/acts/alexmorain~ats-jobs-scraper/run-sync-get-dataset-items?token=<YOUR_APIFY_TOKEN>" \-H "Content-Type: application/json" \-d '{"companies": ["stripe.com", "greenhouse:duolingo"],"mode": "all","titleKeywords": ["engineer"],"remoteOnly": true}'
For a long list, start the run asynchronously and poll instead:
curl -X POST \"https://api.apify.com/v2/acts/alexmorain~ats-jobs-scraper/runs?token=<YOUR_APIFY_TOKEN>" \-H "Content-Type: application/json" \-d '{"companies": ["stripe.com", "figma.com", "notion.so"]}'
What people use this ATS jobs scraper for
Hiring intelligence on a portfolio of companies. If you follow a set of companies for investment,
partnership or competitive reasons, their job board is the cheapest public signal about what they are
actually doing. A company that opens four infrastructure roles in a month is building something; one
that stops posting entirely is in a freeze. Put your portfolio in companies, run in new-only mode
on a daily schedule, and you get a clean event stream of every role each company opens, with the
department attached wherever the ATS publishes it. The cost of a quiet day is the one-cent start fee.
Feeding a vertical job board. Aggregators that scrape other aggregators inherit their errors and
their staleness. Reading the ATS directly means your listing is the same record the company's own
careers page renders, with a working apply URL rather than a redirect chain. Combine a company list
with titleKeywords and locationKeywords and you get exactly the slice your board covers, without
paying for the rest. The cross-ATS deduplication matters here more than anywhere else: a migrated
company would otherwise put every one of its jobs on your board twice.
Intent signals for B2B prospecting. Job posts are the most reliable public statement of what a
company is buying next. A posting for a data engineer with a named warehouse in the description tells
you which stack they run; a first sales hire in a new country tells you they are opening a market; a
sudden run of recruiter postings tells you a hiring wave is coming. Run with includeDescription: true
over your target account list, push the descriptions through your own classifier, and you have a
scoring signal that updates itself daily and that your competitors are not buying from the same
vendor as you.
Compensation and competitive HR research. Ashby, Lever and Recruitee publish structured salary
ranges, and pay transparency law keeps pushing more companies into that group. Scraping a hundred
companies in one sector gives you a real range per role and per location, sourced from the offers
themselves rather than from self-reported survey data. The salary_min, salary_max,
salary_currency and salary_period fields come straight from the ATS with no interpretation, so
you can filter out the rows where the company published nothing rather than guessing.
Sourcing and recruitment operations. Recruiters working a niche need to know which companies are
hiring the profile they place, and they need it before the role hits the aggregators. Pointing this
Actor at a few hundred companies in a vertical with a title filter produces a live map of open roles,
including the ones that never get advertised anywhere but the company's own board. Wire the
webhookUrl to your ATS or CRM and new roles arrive as they open.
Building a labour-market dataset. Nine platforms, one schema and a stable job_id per board make
this usable as a longitudinal data source rather than a one-off export. Run the same company list on
a schedule in all mode and diff the snapshots: jobs that disappear are jobs that closed, and the
time between posted_at and disappearance is a time-to-fill estimate you can compute per company,
per department and per location.
Why this scraper
One schema over nine systems. The nine ATS APIs disagree about everything: Lever calls the title
text, SmartRecruiters calls it name, Workday buries the job ID at the end of a URL path.
Normalisation happens inside the Actor, tested field by field against captured payloads from all nine
platforms, so the row you get from a Personio XML feed has the same keys as the row you get from a
Workday POST endpoint. That is the whole point: your downstream code is written once.
Official APIs instead of a headless browser. Every endpoint here is the same public JSON or XML the company's own careers page calls to render itself. No Chromium, no proxy, no anti-bot layer to defeat, which is why the price per job can be what it is. The proxy option exists in the input for corporate-network edge cases and you should leave it off.
Deduplication that actually costs you less. Jobs are deduplicated twice before anything is
pushed: once by ats|board_slug|job_id, which catches the same board listed twice in your input, and
once by company plus normalised title plus normalised location, which catches the migration ghosts.
Deduplication happens before the billing call, not after, so a duplicate is a job you never pay for.
Delta mode with a persistent registry. The new-only mode stores what it has already returned in
a named key-value store that survives between runs, keyed by a hash of your company list, so two
different lists never pollute each other's memory and the same list always finds its own. Set
registryKey by hand only when you want several schedules to share one memory.
Failures you can see. A company that cannot be resolved is named in the log with the reason and the fix. A board that 404s is reported, not hidden. The run only exits FAILED when every single company failed, so partial success stays a success your automation can consume, and an empty run that should not have been empty never comes back green.
Pricing
Pay per event, no monthly commitment, no platform usage costs on top: the two events below are the whole bill.
| Event | Free | Bronze | Silver | Gold, Platinum, Diamond |
|---|---|---|---|---|
| Actor start (once per run) | $0.01 | $0.01 | $0.01 | $0.01 |
| Job returned | $0.004 | $0.003 | $0.002 | $0.0015 |
Duplicates, filtered-out jobs and jobs already seen in delta mode are never charged. Turning
includeDescription on does not change the price.
Three worked examples, at the free-plan rate first and the Gold rate second.
A one-off export of 50 companies returning 2,000 jobs costs $0.01 + 2,000 × $0.004 = $8.01, or $3.01 on Gold. That is the whole job market of fifty companies, with apply links, for the price of a sandwich.
Daily monitoring of 100 companies for a month. Thirty runs of the start fee is $0.30. If those hundred companies open 400 new roles across the month, that is 400 × $0.004 = $1.60. Total for the month: $1.90, or $0.90 on Gold. The first run of the schedule is the expensive one, because it has to see everything once, and it costs the same as a normal export of that list.
A targeted search: 200 companies, filtered down to engineering roles in Germany. Say the 200 boards hold 18,000 jobs and 300 match your filters. You pay for the 300: $0.01 + 300 × $0.004 = $1.21, or $0.46 on Gold. The 17,700 jobs that did not match were fetched, filtered and thrown away inside the Actor at no cost to you.
Limits, and what this Actor does not do
It reads nine ATS platforms. That is a lot of the market but not all of it: a company on Teamtailor, BambooHR, Taleo, iCIMS, SuccessFactors or a hand-built careers page will not resolve, and the log will say so. It does not scrape LinkedIn, Indeed or any aggregator, and it does not read careers pages as HTML, so a company that renders its jobs in a custom CMS is out of scope by design.
Resolution is not magic. The built-in directory is a snapshot rebuilt from public web indexes, so
a very recent board may be missing from it. When a domain is not in the directory, the Actor probes
eight ATS APIs live using the domain's first label, which works when the board slug matches the domain
name and fails when it does not. Workday is never probed, because its tenant|instance|site triplet
cannot be derived from a domain. The fix in every one of these cases is the same and takes ten
seconds: open the company's careers page and paste the board URL it redirects to.
Slugs do not identify companies across platforms. Measured during the directory build: when the
same slug exists on two different ATS, nine times out of eleven it belongs to two unrelated companies.
So when a bare name matches several boards, the Actor scrapes all of them, deduplicates, and logs a
warning telling you to switch to the explicit ats:slug form if the matches are unrelated. It does
not silently pick one.
Cross-ATS deduplication is best-effort. It matches on company, title and location, and boards
write locations differently ("Remote" on one, "Remote, US" on the other). On the worst real migration
case we measured, 98 of 174 duplicates were caught by the exact key and the rest differed only in
location wording. Note also that the key uses the company label as you typed it, so passing both
stripe.com and greenhouse:stripe in the same list gives two different labels and no
deduplication between them. Pick one form per company.
Workday and SmartRecruiters behave in ways that look like bugs and are not. Workday gives no
posting date in list mode, so posted_at is null unless you turn descriptions on. Workday also
reports its job total only on the first page, so the run log's early counts look wrong even though
the pagination is complete. SmartRecruiters never answers 404: an unknown company and a company with
zero openings both come back as 200 with totalFound: 0, which is why a SmartRecruiters board that
is genuinely empty today has to be passed in explicit smartrecruiters:<Slug> form to be read at
all. And the SmartRecruiters slug is case-sensitive.
Rippling rows are thin. No description, no date, no salary, no employment type, and a location field that some companies fill with something other than a location. If your pipeline depends on posting dates, exclude Rippling companies or rely on delta mode's first-seen date.
Descriptions cost time, not money. On SmartRecruiters and Workday, includeDescription triggers
one extra HTTP request per job, which can multiply the run duration by an order of magnitude on a
large board. On Greenhouse it multiplies the payload by about 12. Leave it off unless you are going
to read the text.
The delta registry never forgets. A job that closes and reopens later with the same ID on the same
board will not be returned again in new-only mode, because the registry still knows it. If you want
a clean slate, change the registryKey.
No login-gated content. Everything here is public. Internal-only boards, roles behind an employee login and jobs a company has unlisted are not accessible and never will be.
FAQ
Which ATS platforms are supported?
Nine: Greenhouse, Ashby, Lever, Workday, SmartRecruiters, Workable, Personio, Rippling and Recruitee.
The ats field on every row tells you which one served that job.
How do I find a company's Greenhouse board token?
Open the company's job list. If the URL is job-boards.greenhouse.io/acme or
boards.greenhouse.io/acme, acme is the token. If the board is proxied under the company's own
domain, click any job: the apply URL will carry a gh_jid parameter, which confirms Greenhouse, and
the token is the path segment in the underlying board URL. In most cases you do not need it at all,
because the built-in directory resolves the domain for you.
Can I scrape Workday job listings without login?
Yes. Workday's public career sites expose a JSON endpoint that this Actor reads directly, no account
and no browser required. What you do need is the board URL, because Workday addresses boards by a
tenant, an instance and a site name rather than by company name. Paste
https://acme.wd5.myworkdayjobs.com/External into companies and the Actor extracts the three parts.
How do I scrape a company careers page when I do not know its ATS?
Just pass the domain. That is the normal case and the reason the directory exists. stripe.com
resolves through 17,177 indexed boards, and if the company is not indexed the Actor probes the eight
probeable ATS APIs with the domain name before giving up. Only if both fail do you need the board URL.
Does it return job descriptions?
Yes, when you set includeDescription to true, on eight of the nine platforms. Rippling publishes no
description at all. Descriptions are returned as plain text with HTML stripped. They cost extra run
time on SmartRecruiters and Workday, and no extra money anywhere.
Does it return salaries?
On Ashby, Lever and Recruitee, which are the three platforms that expose a structured range on their
public API, and only for jobs where the company chose to publish one. The other six do not serve
salary data publicly, so those columns stay null. Some salary text may still appear inside
description or raw.
How do I only get new jobs since my last run?
Set mode to new-only and schedule the Actor. It keeps a registry of everything already returned
and outputs only what is new. The first run in that mode returns everything, because nothing has been
seen yet. Add a webhookUrl and your endpoint gets a JSON summary whenever the run finds something.
How much does it cost to monitor 100 companies daily? About $2 a month on the free plan, under $1 on Gold. The arithmetic is in the pricing section: a run that finds nothing costs the one-cent start fee, and you pay per new job on top.
Do I pay for duplicates and filtered-out jobs? No. Filtering, board-level deduplication, cross-ATS deduplication and the delta check all run before the billing call. You are charged for rows that reach your dataset, and for nothing else.
What happens if one company in my list fails? It is logged with the reason and the run continues with the others. At the end the summary names the failures. The run only exits FAILED when every single company failed, which is the case where an empty dataset would otherwise look like a successful run.
Why is posted_at empty on some rows?
Because the source did not publish a real date. Workday in list mode only serves relative labels like
"Posted 30+ Days Ago", and Rippling serves nothing at all. Rather than invent a timestamp, the Actor
leaves the field null. Delta mode's first-seen date is the reliable alternative.
Can I limit how many jobs I pull from one company?
Yes, maxJobsPerCompany. It exists for lists that contain a giant which would otherwise dominate both
the dataset and the bill. 0, the default, means no cap.
Does it work with an EU-hosted board?
Yes for Greenhouse (job-boards.eu.greenhouse.io) and Lever (jobs.eu.lever.co), both recognised
from the board URL. Personio's .jobs.personio.de and .jobs.personio.com hosts are both accepted.
Do I need a proxy? No. Every endpoint is a public API with no anti-bot layer, so a proxy adds latency and buys nothing. The input keeps a proxy option for corporate-network edge cases only.
Is scraping public job listings legal? This Actor reads publicly published job advertisements from the endpoints companies use to display them on their own careers pages, and it collects no personal data about candidates. Compliance with the terms of the sites you target, and with the law where you operate, remains yours to assess.
Integrations
The output is a standard Apify dataset, so everything the platform offers works out of the box.
Export as JSON, CSV, Excel, XML or RSS from the run's Storage tab, or pull it from
https://api.apify.com/v2/datasets/<datasetId>/items?format=csv. The dataset ships with a table view
that shows the ten columns most people actually read, so a run is readable in the console without
exporting anything.
Apify webhooks fire on run status, and the Actor's own webhookUrl fires on content: it POSTs a
small JSON summary (run ID, mode, number of new jobs, number of companies) to your endpoint at the
end of a run, and only when at least one job came back. That distinction matters for monitoring,
because it means silence is a signal. An unreachable webhook is logged as a warning and never fails
the run.
Scheduling is native. Point a daily or hourly schedule at the Actor with mode: "new-only" and it
becomes a job-alert service over your own company list. Two schedules over the same companies can
share one memory by setting the same registryKey.
Zapier, Make and n8n all speak the Apify API, so a run and its dataset drop into an existing
automation without custom code. From code, use apify-client for Python or JavaScript, or call the
REST endpoint directly with cURL, as shown above.
MCP and AI agents. The Actor is callable through Apify's MCP server, which lets an agent run it as
a tool and read the dataset back. Every field of the unified schema except raw is a scalar, the key
set is identical on every row whatever the source ATS, and a field the platform does not publish comes
back as an explicit null rather than a missing key. That is what makes the output safe to hand to a
model without a defensive parsing layer in between.