Most companies running Greenhouse, Lever, Ashby or Workable publish their open roles on a public JSON endpoint. No API key, no OAuth, no browser.
Here they are, with the counts I measured on 2026-08-28. Every number below came back from a live request while writing this — and job counts move daily, so yours will differ.
The four endpoints
# Greenhouse
curl -s "https://boards-api.greenhouse.io/v1/boards/stripe/jobs"
# → 200, jobs[] with 578 entries
curl -s "https://boards-api.greenhouse.io/v1/boards/monzo/jobs"
# → 200, jobs[] with 66 entries
# Lever
curl -s "https://api.lever.co/v0/postings/matchgroup?mode=json"
# → 200, a bare array with 71 entries
# Ashby
curl -s "https://api.ashbyhq.com/posting-api/job-board/linear"
# → 200, jobs[] with 29 entries
# Workable
curl -s "https://apply.workable.com/api/v1/widget/accounts/lyst?details=true"
# → 200, jobs[] with 8 entries
Enter fullscreen mode Exit fullscreen mode
Three of the four are documented: Greenhouse, Lever, Ashby. The Workable one is different — that is the endpoint their embeddable careers widget calls, not the documented Workable API (which needs a Bearer token). It works today and it is public, but treat it as something that can change without a changelog.
Two container shapes, four vocabularies. Greenhouse, Ashby and Workable all wrap the list in jobs; Lever returns a bare array. Inside, nothing lines up — the job title is title in three of them and text in Lever. Greenhouse gives you absolute_url and internal_job_id, Ashby gives employmentType and secondaryLocations, Lever gives categories and hostedUrl.
The part that silently breaks
Here is what a naive integration does:
const res = await fetch(url);
const data = await res.json().catch(() => ({}));
return data.jobs ?? []; // ← this is the bug
Enter fullscreen mode Exit fullscreen mode
Run that against a company that does not use Greenhouse and you get []. Run it against a company that has zero open roles right now and you also get []. Run it while the endpoint is throwing a 502 and you get [] again. Point it at a Lever board and you get [] every single time, forever, because Lever’s response has no jobs key at all.
Four completely different situations, one identical output. Downstream, a week later, someone asks why a client “stopped hiring” — and the answer is that nobody was ever hiring there on that ATS.
I measured this while checking companies for this post:
Request Resultboards-api.greenhouse.io/v1/boards/stripe/jobs
200, 578 jobs
boards-api.greenhouse.io/v1/boards/ramp/jobs
404 {"status":404,"error":"Job not found"}
api.lever.co/v0/postings/netlify?mode=json
404 {"ok":false,"error":"Document not found"}
api.lever.co/v0/postings/plaid?mode=json
404
Those 404s do not mean those companies are not hiring. They mean the guess about which ATS they use, or the guess about their board slug, was wrong.
So the minimum honest shape is four outcomes, not one:
let res;
try {
res = await fetch(url, { signal: AbortSignal.timeout(20000) });
} catch (e) {
return { state: 'fetch-failed', reason: e.message };
}
if (res.status === 404) return { state: 'no-board-on-this-ats' };
if (!res.ok) return { state: 'fetch-failed', status: res.status };
let list;
try {
list = pickList(await res.json()); // vendor-specific: jobs[] or bare array
} catch (e) {
return { state: 'not-json', reason: e.message }; // 200 + an HTML error page
}
return list.length
? { state: 'ok', jobs: list }
: { state: 'board-exists-but-zero-open-roles' };
Enter fullscreen mode Exit fullscreen mode
state costs you one string per row and removes an entire class of “the data looked fine” incidents.
The harder problem: which board belongs to whom
Guessing a slug from a domain name works often enough to be dangerous. Here is a real collision, both measured today:
curl -s "https://api.ashbyhq.com/posting-api/job-board/notion"
# → 200, 134 jobs, first one: "Software Engineer, Developer Platform"
curl -s "https://apply.workable.com/api/v1/widget/accounts/notion?details=true"
# → 200, "name": "Notion", 0 jobs
# description: "We're a luxury lifestyle agency based in Shoreditch..."
Enter fullscreen mode Exit fullscreen mode
Same slug, two entirely unrelated organisations, both returning HTTP 200. If your resolver tries Workable first and stops at the first 200, you have just labelled a London lifestyle agency as the productivity company.
It gets sillier. apply.workable.com/api/v1/widget/accounts/a — a single letter — returns 200 with "name": "a". A slug existing proves nothing about who owns it. (For contrast, zzqqxxnotarealcompany returns 404 on both Workable and Greenhouse, so the 200s above are real registrations, not a catch-all.)
The only thing that settles ownership is the company’s own site. If notion.so links to that board, it is theirs. If it does not, what you have is a lead, not a fact — and it should be labelled that way rather than merged into the same column as verified results.
There is a second trap in the other direction. Investor portfolio pages, accelerator sites and job aggregators link to boards that belong to other companies. Crawl an investor’s site, follow every board link, and you will end up attributing their portfolio companies’ job boards to the investor. Ask “does this page list other companies’ jobs?” before you attribute anything.
Normalising the shapes into one
A usable row needs at minimum:
source greenhouse | lever | ashby | workable
company the domain you started from
boardSlug the slug that actually worked
jobId vendor id, kept as a string
title Lever calls this `text`
location null when the vendor does not give one — not ""
remote true | false | null (null when unknown, not false)
department
url the public apply URL
postedAt ISO 8601, not the vendor's raw format
Enter fullscreen mode Exit fullscreen mode
The null rule matters more than it looks. If “unknown” and “false” collapse into the same value, every downstream filter for on-site roles quietly includes the ones you simply had no data for.
The rule, if you build your own
Whatever you wrap these endpoints in, keep the three outcomes separate:
- the board does not exist on this ATS (404)
- the board exists and has zero open roles right now
- the request failed (timeout, 5xx, HTML where JSON was expected)
Collapsing them into one empty array is the bug that costs you a week later, not today. The endpoints at the top of this post are the whole integration otherwise — no key, no proxy, no browser.
Written with AI assistance. Every endpoint, count, status code and error body in this post was executed live before publishing, on 2026-08-28.