ATS Job Listings API $0.99/1K — Lever Job Board, Job Openings
Pricing
from $0.99 / 1,000 job postings
ATS Job Listings API $0.99/1K — Lever Job Board, Job Openings
$0.99/1K | 59,185 job listings in one run — where others stop at 5,000. Straight from the careers page: Greenhouse, Workday, Lever, Ashby, Oracle, BrassRing, UKG +13 more. Lever job listings, Ashby job listings, Greenhouse job listings, Workday job listings, employer job listings, recruiting data.
Pricing
from $0.99 / 1,000 job postings
Rating
0.0
(0)
Developer
Snow Leo Data
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
2 days ago
Last modified
Categories
Share
🚀 ATS Jobs Scraper — jobs from the employer, not from a job board
$0.99 per 1,000 jobs. Straight from 20 applicant tracking systems — Greenhouse, Workday, Lever, Ashby, SmartRecruiters, Oracle Cloud, BrassRing, UKG and twelve more. Greenhouse data and Workday data reach you through different endpoints with different shapes; this actor hides that and returns one row format for all of them — applicant tracking data you can query without knowing which ATS it came from, ATS data behind a single careers API.
| 59,185 jobs in one run | the closest competitor stops at 5,000 |
| 1,045 company boards built in | run it with an empty input and it works |
| Salary taken from the source field | not inferred by a model, not missing |
| $0.99 / 1,000 | others in this niche charge $1.30, $2.50 and $4.00 |
Not a job board. Not a resold database. These are career page jobs: every run reads the same endpoint the company's own careers page reads, at the moment you run it — so a role closed this morning is already gone, and one posted an hour ago is already here.
Who this is for
Job boards and aggregators. Fill a board with company job listings that are
live right now, in the employer's own words, and index them straight away — the
output can come out as schema.org JobPosting.
Recruiters and sourcers. Watch a list of target companies and see only what opened since yesterday.
Market and salary research. Salary comes from the field the employer filled in, so a pay study is not built on numbers a model guessed at.
Anyone priced out by a 5,000-job cap. One large employer alone can fill a competitor's entire run: Bosch has 4,834 open roles, Walgreens 22,346.
Quick start
Run it with an empty input. No account slugs, no tenant addresses: the actor falls back to a bundled registry of 1,045 verified company boards and returns 1,000 rows, so a trial costs about a dollar.
That sample is spread out on purpose: one board per platform, no employer over 60 rows. A measured trial returns 50 companies across 19 platforms; picking by size gave 996 of 1,000 rows from Walgreens alone.
Point it at your own companies when you know them — a bare name is enough, the platform is detected for you:
{ "companies": ["stripe", "ramp", "notion"], "keywords": ["engineer"] }
Watching a market instead? Add "onlyNew": true and each run returns only the
postings that appeared since the last one.
Full field reference is further down under Input.
Which ATS platforms are supported
Twenty, each read through the same interface the employer's own careers page calls — no HTML parsing, no headless browser, no job-board middleman:
SmartRecruiters job listings, JOIN job listings, Workable job listings, Personio
job listings, Recruitee job listings, BambooHR job listings, Rippling job
listings and Breezy job listings come back in the same 28 fields as Workday
careers data — the platform only shows up in source. Greenhouse, Ashby, Lever,
Pinpoint, Oracle Cloud, Eightfold, HireHive, Comeet, BrassRing, UltiPro and
Jobsoid are read exactly the same way. The endpoint each is read
from, and how many of its boards the registry holds, are in Where each platform
is read from below.
What you get back, in plain words
Every platform above runs an employer job feed API for its own careers page, and this Actor reads it directly. What comes back is employer job listings — open positions data from the company itself, not a copy that a job board made of it.
That makes it talent acquisition data rather than a search-result page: the title, the location, the employment type, the salary field when the ATS has one, and the whole advert body. Job posting data assembled this way carries the employer's own wording, because there is no aggregator in between to rewrite it.
The awkward cases are read the same way. A Workday careers site is taken through its own CXS endpoint rather than its rendered page, and any row can be emitted as a JobPosting schema document, so a job data pipeline downstream receives structured objects instead of HTML it has to parse again.
What you can do with the file
Every run leaves a job listings dataset you can pull as JSON, CSV or Excel, or read straight from the dataset API. It is open roles data from the employer's own system: job openings data with the advert body, the apply URL and the salary field the ATS published. Used as employer hiring data or company hiring data it shows who is growing and where; used as a recruitment API it fills an internal board without a second source.
Read as a job postings feed it can be served onward unchanged; job description
data arrives as plain text and, where the ATS publishes it, as HTML. Lever job
listings, Greenhouse job listings and Workday job listings land in that same
file with only source telling them apart, so a job postings dataset built from
twenty platforms needs no per-platform mapping of your own.
Common questions
How fresh is a row? Fetched when you press Start — nothing cached, so a role closed this morning is already absent.
Do I have to supply a company list? No. An empty input reaches the bundled registry's 1,045 careers page listings; with your own list a bare company name is enough, because the ATS platform data that routes the request is detected for you.
Is this a feed or a download? Both. The dataset behind a run is an employer data API you read by URL, which makes it a jobs data feed and not only an export.
Why employer job postings and not a job board? A board is a copy. These are direct employer listings, read from the system the employer files the role in, so wording, salary and closing date are the employer's own.
What one row looks like
Every row carries the same 28 fields whatever the platform, and every key is always present — no checking whether a field exists.
{"source": "ashby","company": "ramp","company_slug": "ramp","job_id": "c63ba7d7-5290-4d9b-b002-40b2873b66f6","title": "Partner Consultant, Accounting","url": "https://jobs.ashbyhq.com/ramp/c63ba7d7-5290-4d9b-b002-40b2873b66f6","location": "Remote (US)","locations": ["Remote (US)","Remote (Canada)","San Francisco, CA","New York, NY (HQ)"],"city": "","country": "","latitude": null,"longitude": null,"department": "Sales / Solutions Consulting","employment_type": "FullTime","experience_level": "","remote": true,"hybrid": false,"has_remote_option": true,"posted_at": "2026-09-02T17:17:20.841+00:00","posted_days_ago": 8,"salary_raw": "$151K - $231K","salary_min": 151000,"salary_max": 207000,"salary_currency": "USD","salary_period": "year","description_text": "ABOUT RAMP\n\nRamp is building the smart infrastructure for finance teams, embedded in the transaction flow of every dollar a business spends. We automate how over $200B in …","description_html": "","ref_number": ""}
remote and hybrid are separate booleans; has_remote_option is the looser
flag ("remote is one of the choices") that competitors report as remote, kept
apart so you decide which one you meant. salary_period says whether the figure
is yearly or hourly, locations keeps every city a posting was listed in, and
posted_days_ago saves you parsing the date.
Where each platform is read from
Every platform is read through the same interface the employer's own careers
page calls. No HTML parsing, no headless browser, no job-board middleman — which
is why a posting closed an hour ago is already gone from the result. The row
shape does not change with the platform: Greenhouse jobs, Workday jobs and
Oracle jobs differ only in what source says.
| Platform | Also known as | Read from | Boards in the registry |
|---|---|---|---|
| Greenhouse | Greenhouse.io job board | boards-api.greenhouse.io | 295 |
| Ashby | AshbyHQ | api.ashbyhq.com | 245 |
| Personio | Personio Recruiting | <slug>.jobs.personio.de | 148 |
| Lever | Lever.co | api.lever.co | 70 |
| Recruitee | — | <slug>.recruitee.com | 68 |
| SmartRecruiters | — | api.smartrecruiters.com | 46 |
| Pinpoint | Pinpoint HQ | <slug>.pinpointhq.com | 44 |
| Workday | Workday CXS, wday cxs | <co>.wd1.myworkdayjobs.com/wday/cxs | 36 |
| BambooHR | — | <slug>.bamboohr.com | 27 |
| Breezy | Breezy HR | <slug>.breezy.hr | 16 |
| Rippling | — | api.rippling.com | 11 |
| Oracle Cloud | Oracle HCM Cloud, Oracle Fusion Recruiting, ORC | <tenant>/hcmRestApi recruitingCEJobRequisitions | 10 |
| JOIN | join.com | join.com | 10 |
| Workable | — | apply.workable.com | 6 |
| Eightfold | Eightfold AI talent platform | <slug>.eightfold.ai | 4 |
| HireHive | — | <slug>.hirehive.com | 4 |
| Comeet | — | www.comeet.co careers API | 2 |
| BrassRing | IBM Kenexa BrassRing, Kenexa Talent Suite | sjobs.brassring.com | 1 |
| UltiPro | UKG Pro Recruiting, UltiPro | recruiting.ultipro.com | 1 |
| Jobsoid | — | <slug>.jobsoid.com | 1 |
The board counts are what the shipped registry holds today, not what each platform is capable of: any board of a supported ATS can be passed in by hand whether or not it is in the registry.
How it compares
| This actor | fantastic-jobs/career-site-job-listing-api | jobo.world/ats-jobs-api | |
|---|---|---|---|
| Jobs per run | no cap — 59,185 measured in one run | 5,000 hard cap | 100 per call, 10,000 via paging |
| Data age | live, fetched when you run it | 1 h delay, up to 3 h to ingest | indexed within 24 h |
| Salary | taken from the source field | inferred by an LLM (ai_salary_min_value) | from source |
| Duplicate handling | same posting across cities merged into one row, locations kept | — | — |
| Only new jobs since last run | yes — onlyNew | — | — |
| schema.org JobPosting output | yes — outputFormat | — | — |
| Runs with no configuration | yes — 1,045 verified boards bundled | you supply every company | you supply every company |
| ATS platforms | 20 | 54 | 150+ |
Competitor figures read from their public store listings on 2026-09-10. Where they win: raw platform count. If you need an ATS this actor does not support yet, they cover more. Everything else on this table, this actor does better.
Output as schema.org JobPosting
Set outputFormat to jsonld and rows come out as schema.org JobPosting
objects — the format Google Jobs and most job-board engines read, so a board can
index this output without writing a mapper first.
{"@context": "https://schema.org/", "@type": "JobPosting","title": "Account Manager | Mid-Market","hiringOrganization": { "@type": "Organization", "name": "Ramp" },"jobLocationType": "TELECOMMUTE","baseSalary": { "@type": "MonetaryAmount", "currency": "USD","value": { "@type": "QuantitativeValue","minValue": 211400, "maxValue": 290600, "unitText": "YEAR" } }}
Fields the employer never published are left out rather than emitted empty:
invalid markup is worse than no markup. A salary without a stated period keeps
its numbers and omits unitText instead of guessing "per year".
Pay for new jobs, not for the same ones every morning
Turn on onlyNew and the actor remembers what it already returned. A daily
monitor over 40 boards then bills for the handful of openings that appeared
overnight instead of the same twenty thousand rows every day.
Measured over three consecutive runs on the same two boards:
run 1 → 221 jobs pushedrun 2 → 0 pushed, 221 recognised as already seenrun 3 → 0 pushed, 221 recognised as already seen
The memory key is source:company:job_id, so two ATS platforms that happen to
use the same internal id never cancel each other out. Neither competitor in the
table offers this.
The memory lives in a named key-value store, not the run's default one — the default store is created fresh for every run on the platform, so state kept there would silently vanish and every run would re-deliver the same postings.
A posting is remembered only after it actually reaches the dataset, and the memory is checkpointed after every batch. So if a run hits its timeout or the container moves, the next run picks up where it stopped: jobs already delivered are not billed twice, and jobs that never made it are not silently marked as delivered.
You do not need to know the company's ATS
Every competitor here expects you to supply each company yourself — and for Workday, Oracle Cloud and BrassRing that means digging a tenant address out of a careers page.
This actor ships a registry of 1,045 company boards, each verified with a live request at build time, spanning all 20 platforms. Run it with an empty input and it just works:
1,045 boards · 117,987 jobs at build time · all 20 platforms
| Company | ATS | Jobs when verified | Country |
|---|---|---|---|
walgreens | BrassRing | 22,346 | — |
BoschGroup | SmartRecruiters | 4,834 | Hungary |
sgs | SmartRecruiters | 4,468 | Spain |
micron | Workday | 2,916 | Singapore |
spacex | Greenhouse | 2,376 | — |
questdiagnostics | Oracle Cloud | 2,085 | United States |
nvidia | Workday | 2,000 | — |
jabil | Workday | 2,000 | — |
cw | Workday | 2,000 | India |
brookdale | Oracle Cloud | 1,954 | United States |
salesforce | Workday | 1,431 | — |
honeywell | Oracle Cloud | 1,362 | United States |
maersk | Workday | 1,353 | — |
speechify | Greenhouse | 1,086 | United States |
emerson | Oracle Cloud | 1,059 | India |
wppmedia | Greenhouse | 1,032 | United Kingdom |
bmo | Workday | 1,011 | United States |
deliveryhero | SmartRecruiters | 979 | — |
marksandspencer | Oracle Cloud | 884 | United Kingdom |
databricks | Greenhouse | 873 | Japan |
continental | SmartRecruiters | 861 | Germany |
ford | Oracle Cloud | 838 | United States |
openai | Ashby | 785 | Japan |
gopuff | Lever | 778 | United States |
coupang | Greenhouse | 697 | — |
chubb | Oracle Cloud | 688 | United States |
rippling | Rippling | 664 | Israel |
stripe | Greenhouse | 618 | Israel |
transperfect | Recruitee | 604 | United States |
anthropic | Greenhouse | 599 | United Kingdom |
These are the 30 largest of 1039 boards. The full list is a drop-down inside the Actor's input — Or pick companies from the registry — so you can browse every one of them before you run anything.
Measured, zero configuration, top 100 boards: 20,000 jobs in 215 s, 100 of 100 companies succeeded, 0 failures — the run stopped at the cap, not at the end of the registry.
Running it with an empty input is a trial, not an invoice: without companies
or a limit the actor takes the 25 largest boards and stops at 1,000 rows, so an
accidental click costs about a dollar rather than forty. Ask for the whole
registry explicitly with useRegistry: true and maxItems: 0.
Narrow it with registryAts (only Oracle, say), registryCountry,
registryMinJobs or registryLimit — or ignore the registry entirely and pass
your own companies.
Six entries in the registry are vendor demo boards carrying invented
postings — Jobsoid's demo lists a role called rf3, Breezy's lists
Employee #12. They prove the platform is readable, so they stay in the
registry, but they are kept out of the trial: a sample is a promise about data
quality, and fake rows break that promise.
The registry is not a frozen blob, and it does not pretend to be fresh forever. Every run reports when it was built and how old it is; past 120 days the log says so plainly, because a board that changed ATS should not be mistaken for a company that stopped hiring.
tools/build_registry.py rebuilds it: --check re-verifies every board and
drops the ones that stopped answering, --add names.txt scans new companies,
--countries recomputes countries. Last full verification: 1045 of 1045 boards
answered, 0 dropped.
Country came from the boards themselves, not from a guess: 413 of the 1,045
publish one. DE, Germany and Deutschland all select the same 91 boards.
The remaining 632 are left out of a country-filtered run rather than assigned a
country they never stated.
United States 116 · Germany 91 · United Kingdom 53 · India 21 · France 21 · Spain 11 · Israel 11 · Canada 11 · Poland 8 · Ireland 7 · Netherlands 6
Questions about specific platforms
How do I scrape a Workday careers site?
Give the actor the company slug and it brute-forces the tenant: Workday boards
live on myworkdayjobs.com behind a tenant and a site name, and the actor tries
the plausible combinations against the Workday CXS endpoint until one answers
with postings. That search is the reason you do not have to dig a tenant address
out of a careers-page link yourself.
Which Workday data centre is a board on — wd1, wd3 or wd5?
The prefix in the middle of a Workday address is the data centre the tenant was
provisioned in, and it differs per company: one board is at
<co>.wd1.myworkdayjobs.com, the next at wd3.myworkdayjobs.com, another at
wd5.myworkdayjobs.com. The detector walks wd1, wd2, wd3, wd5, wd10, wd12 and
wd103 against the cxs jobs endpoint and keeps the one that answers, so you never
have to know which myworkdayjobs wd1 or wd3 host a company happens to sit on.
The 36 Workday boards in the registry are spread across wd1, wd3, wd5, wd12,
wd103, wd108 and wd501, which is why guessing a single prefix does not work.
How do I scrape Oracle HCM Cloud, formerly Oracle Fusion Recruiting?
Oracle HCM jobs are served by the recruiting module of Oracle Fusion — Oracle
Recruiting Cloud, ORC for short — from each tenant's own Fusion host. The actor
reads the recruitingCEJobRequisitions resource of that tenant's hcmRestApi,
which needs no key: give it the host and the site number (almost always CX_1)
and it pages through the requisition list. Ten Oracle Cloud boards ship in the
registry as working examples.
How do I scrape an IBM Kenexa BrassRing board?
BrassRing boards sit on sjobs.brassring.com and are addressed by a partner id
and a site id, both visible in the careers-page link. Supply the pair and the
actor reads the board directly. One BrassRing board — Walgreens, with 22,346
postings when it was verified — is in the registry already as a working example.
How do I scrape UKG Pro Recruiting, formerly UltiPro?
UKG boards live on recruiting.ultipro.com and are addressed by a tenant code
such as PEN1024PMCO, which appears in the careers-page link. Give the actor
that code and the board GUID behind it is resolved automatically, so the only
thing you have to find is the part a human can actually see. UltiPro calls its
postings opportunities and links each one as an OpportunityDetail address; both
the search results and those links are read for you.
How do I scrape a Breezy HR board?
Breezy HR boards are at <slug>.breezy.hr, where the slug is the company's own
name in the URL of its careers page. Pass the slug and the board is read through
Breezy's public feed. The sixteen Breezy boards in the registry were verified
against small boards, so treat very large ones as unmeasured rather than proven.
Why "no cap" matters
A single large employer can exceed a competitor's entire per-run limit. Bosch alone returns 4,834 open roles; NVIDIA 2,000; Honeywell 1,362. A 5,000-job ceiling means one big employer fills your entire run.
Measured, 80 registry boards, no limit set:
59,185 jobs · 35 s · 80 of 80 companies · 0 failures
That is nearly 12× the hard cap of fantastic-jobs, in a single run that finished
in under a minute.
Verified on real Fortune 500 boards, not demo tenants: Honeywell, Emerson, Ford and Chubb together return 3,947 live roles through Oracle Cloud Recruiting.
Measured, one run, thirteen companies, no filters:
smartrecruiters 4821 eightfold 590 workable 17workday 2000 brassring 514 recruitee 12oracle 1357 rippling 347 ultipro 6greenhouse 614 ashby 146 pinpoint 5lever 75 jobsoid 4join 48 breezy 3personio 38 bamboohr 1hirehive 1------------------------------------------------------------10,599 jobs · 41.1 s · 19 platforms · 0 failed · 338 duplicates merged
Field coverage — measured, not promised
Percentage of rows where the field is non-empty, from one live board per platform, taken on 2026-09-10. A dash means the employer did not publish it, not that the actor dropped it. Nothing here is guessed or filled by a model.
| Platform | Board | Jobs | title | url | location | country | dept | posted | type | description | salary |
|---|---|---|---|---|---|---|---|---|---|---|---|
| ashby | ramp | 146 | 100 | 100 | 100 | — | 100 | 100 | 100 | 100 | 96 |
| bamboohr | apify | 1 | 100 | 100 | 100 | — | 100 | 100 | 100 | 100 | — |
| brassring | walgreens | 60 | 100 | 100 | 73 | — | — | 100 | — | 100 | — |
| breezy | breezy | 3 | 100 | 100 | 100 | 100 | 100 | 100 | 100 | — | 100 |
| eightfold | bayer | 60 | 100 | 100 | 100 | 100 | 100 | 100 | — | 22 | — |
| greenhouse | stripe | 614 | 100 | 100 | 100 | — | 100 | 100 | — | 100 | — |
| hirehive | hirehive | 1 | 100 | 100 | 100 | 100 | — | 100 | 100 | 100 | — |
| jobsoid | demo | 4 | 100 | 100 | 100 | 100 | 50 | 100 | — | 75 | — |
| join | carhartt-wip | 48 | 100 | 100 | 100 | 100 | 100 | 100 | 100 | — | — |
| lever | matchgroup | 75 | 100 | 100 | 100 | 99 | 100 | 100 | 97 | 97 | 55 |
| oracle | honeywell | 60 | 100 | 100 | 100 | 100 | — | 100 | — | 77 | — |
| personio | urbansportsclub | 38 | 100 | 100 | 87 | — | 100 | 100 | 100 | — | — |
| pinpoint | easygenerator | 5 | 100 | 100 | 100 | — | 100 | — | 100 | 100 | — |
| recruitee | channable | 12 | 100 | 100 | 100 | 100 | 100 | 100 | 100 | 100 | 75 |
| rippling | rippling | 664 | 100 | 100 | 100 | — | 100 | 100 | 100 | 100 | — |
| smartrecruiters | BoschGroup | 200 | 100 | 100 | 100 | 100 | 100 | 100 | 100 | — | — |
| ultipro | penske | 6 | 100 | 100 | 100 | — | 100 | 100 | 67 | 100 | — |
| workable | rebellion | 38 | 100 | 100 | 100 | 100 | 100 | 100 | 100 | 100 | — |
| workday | nvidia | 60 | 100 | 100 | 100 | — | — | 100 | 100 | 100 | — |
| comeet | comm-it | 148 | 100 | 100 | 100 | 90 | 100 | 100 | 72 | 100 | — |
Title and link are 100% on every platform — those are the two fields a job row is worthless without.
Salary is bold where the employer publishes it as a number. It is taken from
the source field, never inferred: fantastic-jobs fills this column with an
LLM guess (ai_salary_min_value), and memo23 has no salary field at all.
Remote means remote
Ashby's isRemote flag is set when any listed workplace option is remote —
at Ramp it was true for 135 of 146 jobs, while workplaceType said "Hybrid"
and the location said "New York, NY (HQ)". Passing that flag straight through
would make a "remote only" filter wrong 92% of the time.
This actor reads workplaceType instead. Ramp: 17 genuinely remote, 118 hybrid.
The same rule is applied to Lever, Eightfold, Oracle and Rippling.
Duplicates are merged, not dropped
Workable returns one posting once per city. Rebellion's 38 rows are 17 real jobs.
Rippling's 664 are 347. Rows sharing an id are merged into one, and every city is
kept in locations — you lose nothing and count nothing twice.
Input
{"companies": [{ "slug": "stripe" },{ "slug": "ramp" },{ "ats": "workday", "slug": "nvidia", "wd": "wd5", "site": "nvidiaExternalCareerSite" }],"keywords": ["engineer", "backend"],"remoteOnly": true,"postedWithinDays": 14,"salaryMin": 120000,"includeDescription": true}
Leave ats out and the actor finds the platform itself — it probes the
platforms in parallel and remembers the answer for a week in the key-value
store, not in the container, so a scheduled run does not pay for the same
lookup every morning. Measured on three companies: 3.1 s the first time, 0.7 s
after.
salaryMin compares annual figures. An $80/hour posting is treated as
$166,400/year, not as "80".
Speed — measured
| Company | Platform | Jobs | Time |
|---|---|---|---|
| Walgreens | BrassRing | 22,346 available | 25 s / 300 |
| Honeywell | Oracle Cloud | 1,362 | 7.3 s |
| Emerson | Oracle Cloud | 1,059 | 8.6 s |
| Ford | Oracle Cloud | 838 | 10.1 s |
| Chubb | Oracle Cloud | 688 | 5.4 s |
| stripe | Greenhouse | 618 | 0.8 s |
| BoschGroup | SmartRecruiters | 4,834 | 2.7 s |
| bayer | Eightfold | 588 | 4.8 s |
| nvidia | Workday | 2,000 | 17 s |
| Oracle tenant | Oracle Cloud | 2,220 | 18 s |
| rippling | Rippling | 664 | 19 s |
Workday caps at 20 records per request and Eightfold at 10, so those are paged in parallel. Everything else arrives in one or two calls.
Companies are fetched in parallel too, not one after another — the same 30 boards took 444 s serially and 27 s in parallel, a 16.6× difference. On Apify you pay for compute time, so this is money, not just comfort. Each platform is capped at 4 concurrent requests: a hundred Greenhouse boards are one host, and hammering it would earn a 429 rather than more data.
Rate limits are handled, and it is measured
On Apify your requests leave from addresses shared with every other user of the
platform, so an ATS can start returning 429 for reasons that have nothing to do
with you. Waiting out Retry-After in one thread is not enough — the other
threads keep hammering the same host and the failures avalanche.
This actor keeps the back-off per host, visible to every thread at once: one
429 slows the whole host down, the pause doubles on each further 429, respects
Retry-After, and decays again as requests start succeeding.
Measured against a local server that accepts 3 concurrent requests and rejects the rest, hit by 20 threads:
| Jobs retrieved | 429s received | |
|---|---|---|
| With the shared back-off | 20 of 20 | 27–48 |
| Without it | 1–3 of 20 | 117–119 |
The test asserts the success rate, not the number of 429s. Across repeated runs the success rate is steady — 20 of 20 every time, against 1 to 3 without the back-off — while the rejection count swings with whatever else the machine is doing. A threshold on the noisy number makes a suite flaky, not strict.
Hosts that asked us to slow down are listed in the run report, so a slow run is explained rather than mysterious.
Tested
tests/test_logic.py — 10 checks on the rules that were once wrong and got
fixed: remote semantics, salary periods, duplicate merging, the salary filter,
HTML entities, country normalisation.
tests/test_live.py — one real board per platform, no mocks. It checks volume,
that title and url are never empty, and that no duplicate ids survive
merging. 20 of 20 platforms green, whole suite in about 30 s.
tools/verify_readme.py — checks this page and the input schema against the
code: every field read, every platform listed, no promise the code does not
keep. With --live it re-fetches every board in the coverage table to confirm
the numbers still hold. Twice during development a figure here drifted from reality and it
was luck that caught it, so checking became a script rather than a habit.
The image is built and run before every release, not just imported: the SDK's
own dependency tree drifted twice into a state where import apify raised
before a line of this code executed, so requirements.txt pins the two
transitive packages that broke and says why.
The suites run clean on Python 3.8 through 3.13, deprecation warnings treated as errors — the actor image tracks a newer Python than this code was written for, and a call removed upstream would fail in the cloud, not on a laptop.
tests/run_all.py runs everything at once; --live adds the suites that talk
to real employer endpoints, which are kept opt-in because hammering someone
else's API on every edit is not a neighbourly habit.
tests/test_resume.py — the whole actor lifecycle against a stand-in for the
Apify SDK: a run that dies halfway, the run that resumes it, and a third that
should bill nothing. It asserts the memory of delivered jobs matches what
actually reached the dataset — the check that caught postings being marked as
delivered before they were sent.
tests/test_throttle.py — a real HTTP server on localhost that rate-limits, hit
through the real client by 20 threads. Provoking 429s against someone else's
production API is exactly the behaviour this code exists to prevent, so the
server under test is ours.
Memory, and why the default is plain text
Measured on a 15,000-job run over 60 boards with descriptions on:
| Description format | Peak memory |
|---|---|
text (default) | 688 MB |
both | 1,095 MB |
The two description fields carry nearly the same content and together account for 98% of a row — everything else, all 25 remaining fields, is the other 2%. Keeping one of them is the difference between fitting in a 1 GB actor and being killed by the OOM reaper mid-run.
Pushes to the dataset are split by payload size, not row count: a posting with a description weighs about 6 KB against 300 bytes without one, so a fixed row count would either waste requests or overshoot the platform's ~9 MB limit. Measured on Stripe's 613 postings with both description fields: two pushes of 4.00 MB and 2.82 MB.
Results are also pushed to the dataset batch by batch, not all at the end, so memory no longer grows with the size of the run — and the first jobs land in the dataset while later companies are still being fetched. The same 15,000-job run peaks at 494 MB streamed, against 688 MB when accumulated.
Suggested memory: 1 GB, and that is not a guess — the heaviest case the
actor supports was run inside a container capped at exactly 1 GB: 40 registry
boards, descriptionFormat: both, 15,000 rows delivered, exit code 0, no OOM.
Without descriptions, 30 boards and 15,000 jobs peak at 368 MB.
A broken company does not break the run
Rows that arrive with neither a title nor a link are dropped rather than
delivered — Workday occasionally returns a record holding nothing but an id, and
on a pay-per-result plan you would otherwise be charged for it. They are counted
in the report as dropped_empty_rows, because silently discarding them would
hide a broken adapter.
One unreachable company is reported and skipped; the rest still return. A page that dies halfway through a company's pagination keeps the jobs already collected instead of throwing them away — and says so, rather than passing a partial result off as a complete one.
The run report (REPORT in the key-value store) answers both questions a
scraper usually leaves open — what was thrown away, and whether what remains
can be trusted:
{"ok": [ { "company": "ramp", "ats": "ashby", "jobs": 146, "seconds": 0.5 } ],"failed": [],"filtered_out": { "keywords": 46, "remote": 84, "salary": 0 },"merged_same_id": 338,"failed_requests": { "host.example.com": { "count": 1, "last": "HTTP 404" } },"throttled_hosts": { "api.example.com": 2.0 },"result": {"jobs": 983, "with_salary": 182, "with_date": 983,"remote": 200, "hybrid": 217, "has_remote_option": 240,"coverage_percent_by_platform": {"ashby": { "jobs": 146, "fields": { "salary_min": 97, "posted_at": 100 } }}}}
Coverage is reported per platform on purpose: an empty salary column is normal for Workday and would mean a broken adapter for Ashby.
Getting the data into your own system
A run that ends on the Apify run page is half a job. Here is every route out, in the order people actually use them.
Straight download
The run page offers the dataset as JSON, CSV, XML and Excel. For a one-off list — a market map, a sourcing spreadsheet — this is the whole workflow: press Start, wait, press Export.
The API, for anything repeatable
Every run writes to a dataset that the API serves:
https://api.apify.com/v2/datasets/<datasetId>/items?format=csv&clean=true
If you always want the newest run of this Actor, you do not need to track ids at all — this address always answers with the last finished run:
https://api.apify.com/v2/acts/<actorId>/runs/last/dataset/items?format=json
Add your token as a header or as ?token=. clean=true drops rows the run
marked empty, and format also accepts csv, xlsx and xml.
No-code pipelines
Apify ships a maintained connector for the tools most buyers already run. Make, n8n, Zapier and Slack each have one, and the webhook integration covers everything else: Apify calls your URL when the run succeeds and hands you the run id and the dataset id in the payload.
In Make and n8n the pattern is always two blocks — run the Actor, then read its dataset — so the scenario stays the same whether it fires on a schedule or from a form someone filled in.
On a schedule, paying only for what is new
Schedules live in the Apify console and take a cron expression. Point one at this Actor with Only jobs not seen before switched on and you get a daily delta rather than the same board re-delivered: the Actor remembers delivered job ids in a named key-value store that survives between runs, and bills only for rows it hands over.
Name the storage if you intend to keep it. Unnamed storages on Apify are temporary by design; a named one stays put, which is exactly what a delta depends on.
Three things people build with this
A sourcing feed. Pick the ATS platforms you care about, set keywords, run it daily with the delta switch on, and push new rows into a Slack channel or an applicant sheet. The row already carries the apply URL, so nobody has to open the careers page to act on it.
A market map. Leave the keywords empty, take the whole registry, and count openings per company, per country and per platform. Because salary comes from the employer's own field rather than from a model's guess, the pay columns are safe to average.
A job board. Every row is ready to republish: title, full description in text or HTML, location, salary range, employment type and the direct apply link. The schema.org JobPosting output mode exists for exactly this, so a listing page can be indexed by Google for Jobs without a transformation step.
A run succeeded but the dataset is empty — is that a failure?
Not necessarily, and the Actor always says which case it is. The run report
carries empty_reason and the log carries a warning naming it:
nothing-changed (the incremental mode working as asked — everything had
already been delivered by an earlier run, so nothing was written and nothing
was charged), filtered-out (your filters removed everything that was
collected), source-returned-nothing (the search matched nothing at all), or
charge-limit-reached (the run's spending limit was already used up). A silent
empty dataset is treated as a defect, not as a normal outcome.
Known limits
- 20 platforms, against 54 and 150+ for the two competitors above. If your target sits on iCIMS, Taleo, Jobvite or Phenom, this actor cannot read it yet.
- Workday, Oracle Cloud, BrassRing and UKG need their tenant identifiers. BrassRing takes
partner_idandsite_id, both visible in the careers-page link; UKG takes atenantcode such asPEN1024PMCO— its board GUID is then found automatically. Comeet takes auidsuch as76.008from its board URL; its token is read off the public board. - Comeet publishes no posting date, only the time a posting was last changed,
which is what
posted_atcarries there. - Workday and Oracle Cloud need their tenant address. Workday is brute-forced automatically; Oracle's host cannot be guessed and must be supplied.
- BambooHR, Breezy, HireHive, Pinpoint and Jobsoid were verified against small boards only (1-22 jobs). Their endpoint shape is confirmed; large-board behaviour is not yet measured. The endpoint shape is confirmed; large-board behaviour is not yet measured.
- Personio publishes no description or country in its public feed.
- Some Eightfold tenants block programmatic access (Amgen and Eaton return 403 regardless of headers). Bayer and others work; coverage there is partial.
- Salary appears only where the employer published it. Roughly half of US postings carry none, and nothing here fills that gap by inference.
Where the jobs this Actor cannot see live
This Actor reads company career pages, so a job exists here only if the employer runs one of the 20 supported systems. Two kinds of vacancy never will: those from employers on an unsupported ATS, and those from markets where small employers post to a national board instead of running a careers page at all. Three sibling Actors cover the second case:
- Jobs.ch Scraper — Switzerland, where most SMEs advertise on the board rather than on an ATS.
- SEEK, JobStreet & JobsDB Scraper — Australia, New Zealand and six Asian markets.
- The Muse Jobs Scraper — one curated board across employers, with the advert text inline.
Every one of the four stamps each row with a source field naming the board or
ATS it came from, so datasets from several of them can be concatenated and still
be told apart afterwards. The column sets themselves differ, because each source
publishes different things.