Every major applicant tracking system publishes its job board as a public JSON
endpoint. That part is already well documented, and I'm not going to rewrite it.
What isn't documented is what happens once you actually try to read all of them
and put the results in one table. I built a normalizer across seven ATS —
Greenhouse, Lever, Ashby, Workday, Workable, SmartRecruiters and Recruitee — and
every single interesting bug came from an API that answered 200 OK while
telling me something false.
These are the five that cost me real time. All of them are verifiable with
curl, no authentication, no account.
1. Workday silently caps limit at 20 — and returns an empty array if you exceed it
Workday's job search is a POST, not a GET:
POST https://${host}/wday/cxs/${tenant}/${site}/jobs
Content-Type: application/json
{ "appliedFacets": {}, "limit": 20, "offset": 0, "searchText": "" }
The obvious optimization is to raise limit to cut the number of round trips.
Try it:
{ "appliedFacets": {}, "limit": 100, "offset": 0, "searchText": "" }
You get 200 OK. You get a well-formed response body. And jobPostings is an
empty array.
No error. No warning. No 400. Nothing that tells you the request was invalid.
This is the worst possible failure mode, because a naive implementation doesn't
crash — it concludes "this company has no open roles" and moves on. If you're
running across hundreds of tenants, you will silently emit zero rows for every
Workday employer and the run will look perfectly healthy.
The real ceiling is 20. Hardcode it and paginate:
const PAGE = 20; // Workday's real ceiling. Higher values return an empty array.
I lost an afternoon to this before I thought to compare limit: 20 against
limit: 100 on the same tenant.
2. Workday stops paginating at 10,000 postings
On large tenants, pagination stops returning new results once offset passes
10,000. There's no flag in the response telling you that you've hit a wall
rather than the end of the list.
If you don't cap explicitly, your loop either terminates on an empty page and
under-reports, or spins. Cap it and record that you capped it:
const MAX_POSTINGS = 10_000;
The general principle, and the reason both of these bit me: an ATS telling you
"no more results" and an ATS refusing to give you more results look identical
over HTTP. You have to decide which one you're looking at, and log it.
3. There is no public directory of Workday tenants
Greenhouse, Lever and Ashby all take a single slug you can guess from the
company's domain. Workday needs three values — host, tenant and site:
https://nvidia.wd5.myworkdayjobs.com/en-US/NVIDIAExternalCareerSite
└─ host ──────────────────┘ └─ site ──────────────┘
└ tenant
wd5 isn't guessable (it's whichever Workday cluster the customer landed on),
and the site name is free text the customer chose. There's no registry, no
lookup API, nothing to enumerate.
Practical consequence: for six of the seven ATS you can auto-discover a board
from a company domain. For Workday you cannot — the only reliable source of
those three values is the careers URL itself. So I let users paste the URL and I
parse it, and I left Workday out of automatic discovery entirely rather than
pretend it works.
Being explicit about what can't be automated turned out to be more useful than
a discovery function that quietly misses every Workday employer.
4. Ashby has salary data, but only if you ask for it by name
Ashby's public board endpoint:
curl "https://api.ashbyhq.com/posting-api/job-board/ramp"
Postings come back with title, location, employmentType, descriptionHtml
and friends. No compensation field anywhere — not null, absent. Easy to
conclude Ashby doesn't expose salary.
It does. You have to opt in:
curl "https://api.ashbyhq.com/posting-api/job-board/ramp?includeCompensation=true"
Now every posting carries a compensation object with tier summaries, currency
codes and min/max values. On the board I tested, 131 of 137 postings had
compensation data that the default response omits entirely.
An absent field reads exactly like "this data doesn't exist." Here it meant "you
didn't ask."
5. Three of the seven never tell you the company's name
This one I only caught by auditing my own output. I noticed 30% of my rows had
companyName: null, and the nulls weren't random — they were 100% of the rows
from specific sources.
Where the name comes from, per ATS:
| ATS | Company display name |
|---|---|
| Greenhouse |
GET /v1/boards/{slug} → name
|
| Workable | account endpoint → name
|
| SmartRecruiters | on each posting → company.name
|
| Recruitee | on each offer → company_name
|
| Ashby | not exposed |
| Lever | not exposed |
| Workday | not exposed |
Ashby's board response has exactly two top-level keys — jobs and apiVersion.
Lever returns a bare array of postings with no envelope at all. Neither carries
the employer's display name in any field, at any level, including inside
individual postings.
Worth noting because my own code had a comment claiming Ashby exposed
organizationName "on some boards." It doesn't, on any board I could find. The
comment was a guess someone (me) wrote once and never checked, and it survived
because the field silently resolved to null instead of throwing.
If you need a name for those three, you derive it from the slug and you say so
in your schema. Don't let a derived value sit in the same column as an
authoritative one without marking the difference.
Bonus trap: the company's domain is not the company's slug
If you're auto-discovering boards from domains, the obvious approach — strip
www. and the TLD, use what's left — fails on a whole category of companies:
-
datadoghq.com→ slugdatadogon Greenhouse -
joinhandshake.com→ slughandshakeon Ashby
Companies register a padded domain because the clean one was taken, then use the
clean name on their ATS. datadoghq returns 404 on Greenhouse; datadog returns
424 open roles. Stripping a short list of common paddings (hq, inc, app,
labs, hr) and prefixes (join, try) recovered a board I'd been silently
missing for months.
The finding I didn't expect: salary coverage is a market fact, not a parsing problem
I built currency and period normalization so an hourly US rate and a monthly
Colombian salary sort in the same column. I was proud of it. Then I measured
what fraction of postings actually carry any salary at all.
On a sample of 566 postings open to candidates in Latin America: 8.7%
had a parseable salary.
By source it's sharper still:
| ATS | Share of rows | Rows with salary |
|---|---|---|
| Greenhouse | 70% | 3.5% |
| Ashby | 22% | 16.7% |
| Lever | 7% | 35.9% |
The source carrying 70% of the volume is the one that almost never has a number
in it.
The reason has nothing to do with the parser. US pay-transparency laws force
employers to publish ranges; most Latin American postings simply don't include
one. A parser can't extract a number that was never written.
I'm including this because "salary parsed and annualized" is the kind of feature
that sounds like a guarantee and isn't. If you build something similar, measure
your fill rate per region before you put it in a headline. Mine is in the
documentation now, with the number.
Why every run should report per-source health
The through-line of all of the above: these APIs fail by returning less, not by
returning errors. A capped Workday page, a 404 on a renamed slug, and a genuinely
quiet hiring week are indistinguishable if all you look at is the row count.
So every run emits a status block before anything else:
{
"checkedAt": "2026-08-18T23:02:22Z",
"sources": [
{ "ats": "greenhouse", "status": "ok", "boards": 20, "boardsOk": 20,
"jobsFound": 2817, "jobsEmitted": 373, "errors": [] },
{ "ats": "ashby", "status": "failed", "boards": 1, "boardsOk": 0,
"jobsFound": 0, "jobsEmitted": 0,
"errors": ["ashby:ramp: HTTP 404 (board not found)"] }
]
}
jobsFound versus jobsEmitted matters as much as the status: it separates
"the source is broken" from "your filters are aggressive." A scheduled run
against a fixed set of boards works as a canary — if an ATS changes its shape, I
find out before a user does.
Everything here is public, and that's the point
All seven endpoints answer without a session, a cookie, or an account. That
constraint isn't just convenience — it's the entire legal footing. Public data
reachable without logging in sits on very different ground from data behind an
authentication wall, and the difference has been expensive for companies that
got it wrong.
If a source needs a login to reach, it's out. There's plenty here without it.
A daily run of this publishes a free open dataset of LATAM-eligible postings —
schema, caveats and both endpoints are documented at
github.com/JuanCarlosGuti/latam-tech-jobs.
No token, no signup. The normalizer itself I maintain as an
Apify actor.
Happy to answer questions about any of these endpoints.
Top comments (0)