Open a tech company's careers page with devtools on. More often than not the page is a thin shell and
every job on it arrives in one JSON response from a third-party applicant tracking system —
Greenhouse, Ashby, Lever, Workday, SmartRecruiters, Recruitee, Rippling, Workable or Personio.
Those responses come from public, unauthenticated endpoints the ATS vendors publish so aggregators
can consume them. No key, no login, no CAPTCHA, no headless browser. If you have been writing HTML
parsers against careers pages, you have been parsing the render of a document you could have fetched
directly.
One request, one whole board
Greenhouse is the simplest of the nine. One GET returns every open posting for a company, with
descriptions, and there is no pagination at all:
import json
import urllib.request
# No API key, no auth header. This is the documented public board endpoint.
URL = "https://boards-api.greenhouse.io/v1/boards/stripe/jobs?content=true&pay_transparency=true"
req = urllib.request.Request(URL, headers={"User-Agent": "job-board-reader/1.0"})
with urllib.request.urlopen(req, timeout=30) as resp:
board = json.load(resp)
print(len(board["jobs"]), "open roles")
for job in board["jobs"][:5]:
dept = (job.get("departments") or [{}])[0].get("name")
print(job["id"], "|", job["title"], "|", job["location"]["name"], "|", dept)
print(" ", job["absolute_url"])
Swap stripe for any Greenhouse board token — often visible in the careers page URL, or in that
network request you just watched. The rest of this article is the eight variations on it.
The nine endpoints
Every endpoint below is one this project calls in production, verified live before shipping. The
query parameters shown are the ones that matter, not the full surface.
| Platform | Endpoint | Shape |
|---|---|---|
| Greenhouse | GET boards-api.greenhouse.io/v1/boards/{token}/jobs?content=true&pay_transparency=true |
Whole board, one response, descriptions included. Documented at docs.greenhouse.io/job-board.html. |
| Ashby | GET api.ashbyhq.com/posting-api/job-board/{org}?includeCompensation=true |
Whole board, one response. |
| Lever | GET api.lever.co/v0/postings/{org}?mode=json |
Whole board, returned as a bare JSON array, not an object. |
| SmartRecruiters | GET api.smartrecruiters.com/v1/companies/{co}/postings?limit=100&offset=N |
Offset-paginated, 100/page. List carries no description — that needs …/postings/{id} per job. |
| Recruitee | GET {co}.recruitee.com/api/offers/ |
Whole board, one response. |
| Rippling | GET api.rippling.com/platform/api/ats/v1/board/{co}/jobs |
Thin list — uuid, name, department, url, work location. Everything else needs …/jobs/{uuid}. |
| Personio |
GET {co}.jobs.personio.de/xml (also .personio.com) |
The only XML feed of the nine. |
| Workday | POST {tenant}.wd{N}.myworkdayjobs.com/wday/cxs/{tenant}/{site}/jobs |
POST, 20 per page, and the list rows are near-useless. See below. |
| Workable |
GET apply.workable.com/api/v1/widget/accounts/{co}?details=true + POST apply.workable.com/api/v3/accounts/{co}/jobs
|
Two endpoints together: the widget has descriptions, v3 is authoritative on the total and fills in employment type and the remote flag. |
Six of the nine hand you a usable board in one request. Rippling returns the same uuid once per
work location, so sixteen board rows legitimately collapsing to seven jobs is normal. And Workday is
a category of its own.
Workday is the one that will cost you a weekend
Every Workday employer lives on its own host — nvidia.wd5.myworkdayjobs.com,
salesforce.wd12.myworkdayjobs.com — where the pod (wd1 through wd12 cover most tenants, with a few higher-numbered pods) is
assigned, not chosen, under a site name the employer picked: External, NVIDIAExternalCareerSite,
Salesforce_Careers. You need both before you can make a single request, and Workday publishes a
directory of neither.
The trick that works is GET https://{tenant}.wd{N}.myworkdayjobs.com/robots.txt — Workday serves
the real site name there, in the Sitemap: line and the Allow: rules, so one cheap request per
pod resolves almost every tenant. Failing that, POST the jobs endpoint with common site names and
read the status codes: in our testing a wrong pod answers 422 while the right pod with a wrong
site answers 404, so a 404 confirms the host. None of that is contract. Treat Workday as the part
of your pipeline most likely to break, and note that its public feed carries no department, no team
and no salary at all.
Where the nine actually disagree
Salary
| Platform | Structured pay range? |
|---|---|
| Ashby | ✅ Full block — min, max, currency, interval |
| Greenhouse | ✅ When the employer opts into pay transparency; values arrive in cents |
| Lever | ✅ salaryRange when the employer fills it in |
| Recruitee / Rippling | ⚠️ Fields exist, rarely populated |
| SmartRecruiters, Workable, Personio, Workday | ❌ Nothing public |
Having the field is not the same as the field being filled. On a live sample of 40 jobs per board
taken 2026-09-02, Ashby boards published a range on 100% of Ramp's postings and 90% of OpenAI's;
Greenhouse was 22% (GitLab) and 0% (Stripe); Lever was 50% (Match Group) and 0% (Palantir). If you
need compensation data, that is the whole answer: Ashby, then hope. And do not regex the
description for a dollar figure — a guessed number in a salary_min column is worse than an honest
null, because it looks like data.
Departments and dates
Greenhouse gives department only; Ashby, Lever and Rippling give department and team; Workday
gives neither.
Only three of the nine publish an updated timestamp — Greenhouse, SmartRecruiters and Recruitee.
That matters more than it sounds: if you want "what changed on these boards this week," on the other
six you can detect new postings and nothing else. Formats vary too; Lever's createdAt is epoch
milliseconds.
"Not found" is not always 404
This is where scrapers silently report empty boards:
-
SmartRecruiters: an unknown company answers 200 with
totalFound: 0. - Workable: an unknown account answers 404, but a real account with no live jobs answers 200 with an empty list.
- Workday: wrong pod → 422; wrong site on the right pod → 404 (observed, not documented).
- Personio and Workable both rate-limit by IP and answer 429 under even light parallel probing.
That last one is dangerous. An unhandled 429 looks exactly like "this company has zero open jobs,"
which downstream reads as "every role was closed." Pace those two, honour Retry-After, and never
let a non-200 collapse into an empty result.
The lookup nobody ships
Knowing the endpoints is half of it. The other half: given "Match Group," which of the nine is it
on, under what slug? There is no registry. What works is generating plausible slugs — the domain
label, the name lowercased and de-spaced, the hyphenated variant, the variant with Inc / GmbH /
Group stripped — and probing them cheapest-first: the six single-request platforms in parallel,
then Personio and Workable paced because of the 429s, then Workday last.
On a mixed, deliberately tech-skewed set of 20 companies we tested (Stripe, OpenAI, Anthropic,
Databricks, Ramp, Notion, Linear, Datadog, NVIDIA, Vercel, Figma, Airtable, Brex, Cloudflare, Match
Group, Vandebron, Adverity and scale.com among them) this resolved 18. The two misses, DoorDash
and Retool, run career sites that are not on any of the nine public APIs — there is nothing to find.
Two failure modes to know before you build it yourself. A slug can belong to someone else:
scale.com resolves to a six-job Personio board owned by a different company called Scale, while
Scale AI's real board is on Greenhouse under scaleai, and nothing in either response distinguishes
them. And companies mid-migration are live on two platforms at once with the same roles on both,
so de-duplication cannot key on the platform's job id — it has to fall back to title and location.
If you only need a handful of known companies, hardcode the board URLs: a board URL short-circuits
discovery entirely and can never re-resolve onto someone else's board.
One schema across all nine
24 flat fields, one item per posting, and anything a platform does not publish is null rather than
absent — so the output exports to CSV or a database without ragged columns:
company, company_slug, ats, job_id, title, department, team, location_raw, city,
state, country, is_remote, employment_type, salary_min, salary_max, salary_currency,
salary_period, posted_at, updated_at, apply_url, description_html, description_text,
source_url, raw.
The rules that made that work across nine wildly different payloads:
-
rawkeeps the untouched platform record. Normalization is lossy by definition;rawis the escape hatch, so nothing an ATS publishes is destroyed by the mapping. -
Dates are dates.
YYYY-MM-DDeverywhere — epoch milliseconds, ISO strings and "Posted Today" all land in the same format.employment_typecollapses toFULL_TIME/PART_TIME/CONTRACT/TEMPORARY/INTERNSHIP/VOLUNTEER,salary_periodtoYEAR…HOUR, and Greenhouse's cents get divided by 100 on the way in. -
is_remoteis true when the platform sets a flag, or the location string says remote / anywhere / distributed / work-from-home. Titles and descriptions are never consulted — they say "remote" for reasons that have nothing to do with the role. -
location_rawis the truth;city/state/countryare best-effort. Only Ashby, SmartRecruiters, Recruitee and Workable publish a structured address; the rest is free text. -
companyis exact only where the platform publishes it — six of the nine do. Ashby, Lever and Workday do not, so there it is derived from your input:"Match Group"stays"Match Group", but a bare slug likeopenaibecomes"Openai".
The limitation that shapes everything
ATS endpoints expose only currently-open roles. None of the nine carries history. A job that
closed yesterday is simply gone — no tombstone, no closed-at date, no way to backfill it from any of
these endpoints, ever.
That decides the architecture of anything built on this data. If you care about what changed, the
only way to get it is to snapshot boards on a schedule and diff, starting the day you decide you
care — every day not snapshotted is history permanently lost. It is also why the 429 handling
matters: a rate-limited fetch that reads as "zero jobs" makes a naive differ publish every role at
that company closed. A board that verified at N jobs and returns 0 today is a suspect fetch, not a
mass layoff, and that guard belongs in the pipeline before the first "closed" event is ever written.
Internally we run exactly this — a daily full snapshot of a 200-company watchlist, diffed into
opened/closed events. The fetching is easy; the guards are the work.
If you don't want to maintain this
Nine adapters, a discovery layer and a normalizer are not hard to write, but they are an ongoing
obligation to keep alive — Workday's robots.txt behaviour in particular owes you nothing. It is
packaged as an Apify Actor:
https://apify.com/make_no_mistakes/multi-ats-job-board-api
import requests
ACTOR = "make_no_mistakes~multi-ats-job-board-api"
URL = f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items"
jobs = requests.post(
URL,
params={"token": "YOUR_TOKEN"},
json={"companies": ["stripe", "openai.com", "Match Group"],
"maxItems": 300, "titleIncludes": ["engineer"], "postedWithinDays": 7},
timeout=600,
).json()
for j in jobs:
print(j["posted_at"], j["company"], j["title"], j["city"], j["salary_min"], j["apply_url"])
Pay-per-event: $0.005 to start a run, $0.004 per job written to the dataset, or $0.001
per company in discoverOnly mode, which answers "which ATS, which board, how many roles open"
without paying for the jobs. Resolving a 200-company watchlist that way costs $0.205; pulling 500
jobs costs $2.005. Filtered-out jobs are never written and never billed.
The limitations, in one place: salary is thin outside Ashby; Personio and Workable rate-limit by IP,
so a watchlist leaning on those two can see individual companies come back unresolved; Workday
discovery is undocumented behaviour that can stop working without notice (the other eight are
unaffected and the run does not fail); there is no history, only open roles; and employers that
aren't on any of the nine — DoorDash and Retool, for two — can't be found, because there is nothing
to fetch.
One more, because it bites in production: every pay-per-event run carries a charge ceiling
(maxTotalChargeUsd, set from your remaining credit unless you set it yourself), and at $0.004 a
job that is a maximum item count. Pack too many companies into one run and it stops there — and
says so: the companies it never reached are named in RUN_SUMMARY.companies_not_fetched, given an
entry each in RUN_SUMMARY.errors, and deliberately left out of the per-company counts rather than
written as 0, because 0 means "this board is empty" and that is a different fact.
Or skip all of it and go straight to the nine endpoints in the table above. They are public, the
vendors publish them for exactly this purpose, and for a fixed list of companies you already know,
hardcoding the adapters is quick work. The only thing being sold here is the discovery layer, the
normalization, and the obligation to keep both alive. Either way the employer is the system of
record — these endpoints return what companies published about themselves, and their terms, and the
ATS vendor's, are worth reading before you redistribute any of it.
Top comments (0)