Most "job scraping" tutorials teach you to render a careers page in a headless
browser and pick apart the HTML. For the majority of company career pages, that
is unnecessary. The page you are trying to scrape is itself a client-side app
calling a public JSON API — and you can call that API directly.
No key. No proxy. No anti-bot. Just JSON.
Below are the six applicant tracking systems that cover most of the startup and
mid-market hiring world, the exact endpoint each one exposes, and the traps I
hit building against all six.
Every endpoint here was run live while writing this, in August 2026. The job
counts are from that run and will have drifted by the time you read it — they
are there to show the shape of the response, not as figures to check against.
Why the APIs are open in the first place
When a company buys Greenhouse or Lever, they get a hosted job board, and they
usually want the openings to appear on their own marketing site under their own
design. So the ATS ships a public read API for exactly that purpose.
That is the important bit: this is not an oversight to be exploited. The
endpoint exists to be read by third parties. It is documented, versioned, and
stable — the same properties that make it pleasant to build on.
The practical consequence is that reading job data is one of the few remaining
corners of the web where you do not need a proxy budget.
The six endpoints
Greenhouse
GET https://boards-api.greenhouse.io/v1/boards/{token}/jobs
The token is the slug in job-boards.greenhouse.io/{token}. stripe returns
around 550 openings.
The trap: the /jobs response has no department on it at all — the keys are
absolute_url, data_compliance, internal_job_id, location, metadata, id,, and that is the whole list. If you want departments you
updated_at, requisition_id, title, company_name, first_published, language,
application_deadline
have to call a second endpoint and join:
GET https://boards-api.greenhouse.io/v1/boards/{token}/departments
Each department carries its own jobs array, so you build an id → department
map and merge. Miss this and every record silently comes back with an empty
department, which looks like the companies just didn't fill it in.
Lever
GET https://api.lever.co/v0/postings/{token}?mode=json
leverdemo is Lever's own sandbox board and returns 388 postings, which makes
it a good fixture.
The trap: Lever returns createdAt as epoch milliseconds, while
Greenhouse and Ashby return ISO 8601 strings. If you normalise the others and
forget this one, a "posted in the last 7 days" filter doesn't error — it just
quietly returns nothing for every Lever board, because an epoch-millisecond
integer parsed as a year is not a date anywhere near today. Silent wrong answers
are worse than crashes.
Ashby
GET https://api.ashbyhq.com/posting-api/job-board/{token}
Add ?includeCompensation=true and you get published salary ranges, which
Ashby exposes far more consistently than the others — on Ramp's board, 115 of
121 postings came back with a real figure, formatted like
$211.4K – $290.6K • Offers Equity. On the other five platforms salary is the
exception rather than the rule, so if compensation data is what you are after,
Ashby boards are where it actually lives.
Workable
GET https://apply.workable.com/api/v1/widget/accounts/{token}
This is the endpoint powering Workable's embeddable widget. It gives you
department, employment type and a remote flag in one call.
Recruitee
GET https://{token}.recruitee.com/api/offers/
The trap: the response contains options_cover_letter. It is a boolean —
whether the application form asks for a cover letter. I mapped it to
employmentType at one point on nothing but name-shaped optimism, and produced
a column full of true/false where job types should have been. Read the
values, not the field names.
SmartRecruiters
GET https://api.smartrecruiters.com/v1/companies/{token}/postings?limit=100&offset=0
Two traps here, and they cost me the most time.
Pagination. The response includes totalFound, and it will happily tell you
4753 while handing you 100 records. If you don't page on offset, you silently
truncate every large employer to their first 100 openings. No error, no warning.
This is the kind of bug that survives a full test suite, because 100 records
looks like success.
Case sensitivity, and identifiers that are not the company name. The
identifier is case sensitive, and it is frequently not what you would guess:
| You would guess | Result | Live identifier | Openings |
|---|---|---|---|
Bosch |
empty | BoschGroup |
4,753 |
Ubisoft |
empty | Ubisoft2 |
282 |
Visa |
works | Visa |
2 |
Note the failure mode: a wrong identifier returns HTTP 200 with an empty
list, not a 404. So "this company isn't on SmartRecruiters" and "you spelled
it wrong" are indistinguishable from the response alone. I removed Bosch from
a set of examples once, having concluded it wasn't available, when the real
answer was that I had the name wrong.
The thing nobody tells you: people don't have board tokens
Every one of these endpoints wants an ATS board slug. Nobody has a list of ATS
board slugs. What people actually have — sales teams, recruiters, job hunters —
is a list of company domains.
So the useful move is to accept whatever someone pastes and work it out:
def normalize_token(raw: str) -> str:
"""stripe.com, www.stripe.com and https://stripe.com/careers
all resolve to `stripe`."""
text = raw.strip()
text = re.sub(r"^\w+://", "", text)
text = re.sub(r"^www\.", "", text)
text = text.split("/")[0]
return text.rsplit(".", 1)[0] if "." in text else text
Then, because dropping the TLD is a guess and not a rule, keep a fallback:
shield.ai becomes shield, but the actual Lever board is shieldai (433
openings). So generate both spellings and try the second only after the first
has missed everywhere.
The cost of the fallback is one extra request on a miss. The benefit is that
users stop having to look anything up. That trade is worth it every time.
One malformed record should not cost you the other 499
Every one of these APIs will eventually hand you something you didn't expect:
jobs: null instead of an empty list, a string where a dict belongs, a null
inside an otherwise fine array. If you build each record inside a loop with no
isolation, one bad posting takes down the whole board.
def collect(items, build):
out = []
for raw in as_list(items):
try:
record = build(raw)
except Exception:
continue # one bad posting, not a dead run
if record.get("title") and str(record.get("jobUrl", "")).startswith("http"):
out.append(record)
return out
The title/URL check matters as much as the try block. A record with no title and
no link is not a job anyone can act on, and passing it downstream just moves the
problem somewhere it is harder to see.
Detection across all six
Given a bare company name, you can find the board by trying platforms in
sequence and stopping at the first hit. Order matters for speed — Greenhouse and
Lever cover the most companies, so put them first.
One subtlety worth getting right: a valid board with zero openings is a real
answer, not a miss. A company that genuinely isn't hiring should return an
empty list, not fall through to the next platform and eventually report "not
found". Those are different facts and users need to tell them apart.
Limits, honestly
- Only these six. Companies on Taleo, iCIMS, SuccessFactors or a hand-rolled careers page are not covered by any of this.
- Private or password-protected boards are not accessible, and shouldn't be.
-
departmentandteamare only as good as what the company filled in. - Salary shows up only where the company published it.
The bit I'd add if you are polling this on a schedule
If you run any of this daily, diff against the previous run rather than
re-reading the whole board. Keep a snapshot of the ids you saw, and emit only
the ones that appeared and the ones that vanished. Two details that turned out
to matter:
- Key the snapshot to the exact query. Change your company list or a filter and you should start a fresh baseline. Otherwise every job you simply stopped asking about gets reported as newly closed, which is a wrong answer rather than a noisy one.
- A job that disappeared cannot be re-fetched. Report it from the snapshot — company, title, link — and leave the rest of the fields out instead of showing a stale copy.
If you'd rather not build it
I packaged all six adapters — normalisation, the timestamp fix, the pagination,
the domain-to-token fallback, the change detection above — as Apify Actors. You
paste company domains and get one flat schema back, exportable as
CSV/Excel/JSON or callable as an API.
| Platform | Actor |
|---|---|
| All six, auto-detected | ATS Jobs & Hiring Signals |
| Greenhouse | Greenhouse Jobs Scraper |
| Lever | Lever Jobs Scraper |
| Ashby | Ashby Jobs Scraper |
| Workable | Workable Jobs Scraper |
| Recruitee | Recruitee Jobs Scraper |
| SmartRecruiters | SmartRecruiters Jobs Scraper |
Source for the adapters is on
GitHub if you just
want to read how a particular platform was handled.
But the endpoints above are the whole trick. If you only need one platform, a
40-line script will do it, and now you know which four bugs to write tests for.
Top comments (0)