If you have ever scraped a careers page with a headless browser, this will hurt a little. The four big applicant tracking systems, the software behind most tech company job boards, all expose public, keyless JSON APIs that return every open job. Same data as the careers page, zero HTML parsing.
The four endpoints
Greenhouse (Stripe, Figma, Airbnb):
GET https://boards-api.greenhouse.io/v1/boards/{board}/jobs?content=false
Lever:
GET https://api.lever.co/v0/postings/{company}?mode=json
Ashby (Ramp, Plaid, many newer startups):
GET https://api.ashbyhq.com/posting-api/job-board/{org}
SmartRecruiters (Visa and other enterprises), with offset paging:
GET https://api.smartrecruiters.com/v1/companies/{company}/postings?limit=100&offset=0
No key, no login, no rate limit drama for reasonable use. Stripe's Greenhouse board returns 490 open jobs in one request.
The quirks that cost me an afternoon
Greenhouse hides departments. The jobs list has title, location, and dates, but no department. Fetch /v1/boards/{board}/departments once and build a jobId to department map; it comes back with jobs nested per department.
Lever cannot say "not found". An unknown company and a company with zero openings both return an empty array. If you are probing names, treat empty as a miss and keep trying other providers.
Ashby returns full descriptions whether you want them or not, including HTML. Strip what you do not need before storing, and filter on isListed.
Board tokens are not company names. Usually stripe works for Stripe, but brands, abbreviations, and renames break the guess. Try a few slug variants (acme-corp, acmecorp, acme) across all four providers and take the first hit. This is also how you survive companies migrating: Plaid quietly moved from Lever to Ashby, and probing both finds them either way.
Normalizing across providers
Each API names things differently, so map to one row shape early:
| Field | Greenhouse | Lever | Ashby | SmartRecruiters |
|---|---|---|---|---|
| title | title |
text |
title |
name |
| department | via /departments
|
categories.department |
department |
department.label |
| location | location.name |
categories.location |
location |
location.city + region |
| remote | infer from location | workplaceType |
isRemote |
location.remote |
| posted | first_published |
createdAt (ms) |
publishedAt |
releasedDate |
| link | absolute_url |
hostedUrl |
jobUrl |
build from id |
Once normalized, a keyword filter, a location filter, and a remote flag get you a personal job alert, a niche job board, or a hiring tracker for sales signals: a company hiring three SDRs is a company about to buy sales tooling.
What it costs to run
Almost nothing. Five companies, 163 job rows, under a cent of compute, no browser in sight. Run it daily and diff against yesterday to catch openings the hour they post.
If you would rather skip the probing and normalizing, I packaged all four providers into one pay per use actor: Company Job Openings Scraper. Company names in, normalized job rows out, and companies it cannot find are free. The first 25 rows of every run are free.
Top comments (0)