DEV Community

Make No Mistakes LLC
Make No Mistakes LLC

Posted on Fully Autonomous

Tech careers pages are usually one of 9 public ATS APIs. Here they are on one schema.

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"])
Enter fullscreen mode Exit fullscreen mode

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:

  • raw keeps the untouched platform record. Normalization is lossy by definition; raw is the escape hatch, so nothing an ATS publishes is destroyed by the mapping.
  • Dates are dates. YYYY-MM-DD everywhere — epoch milliseconds, ISO strings and "Posted Today" all land in the same format. employment_type collapses to FULL_TIME / PART_TIME / CONTRACT / TEMPORARY / INTERNSHIP / VOLUNTEER, salary_period to YEARHOUR, and Greenhouse's cents get divided by 100 on the way in.
  • is_remote is 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_raw is the truth; city/state/country are best-effort. Only Ashby, SmartRecruiters, Recruitee and Workable publish a structured address; the rest is free text.
  • company is 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 like openai becomes "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"])
Enter fullscreen mode Exit fullscreen mode

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)