Ask Workday's public API for 100 jobs and you get zero - with no error
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 โ slugdatadog on Greenhouse - joinhandshake.com โ slughandshake on 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)
Comments
No comments yet. Start the discussion.