Job posting data from company career sites: the public Greenhouse, Lever and Ashby endpoints
Most applicant tracking systems publish an anonymous JSON or XML feed of each customer’s open jobs because the career page itself needs it; reading it takes one request per company and no scraping of HTML. Source: Arcadium Data, data as of .
Behind the careers page of most companies sits a hosted job board of an applicant tracking system (ATS). The page is a thin skin: it loads the jobs from an endpoint that anyone can call, and several vendors document that endpoint as a public job board API. This is the cleanest source of job posting data there is: it comes straight from the employer, is current to the hour, and contains no aggregator copies.
The work is not in the request, it is in everything around it: finding the board token for a company, paging, pay fields that differ per vendor, being polite, and not storing recruiter names. This guide lists the endpoints as vendors expose them and the habits that keep you out of trouble.
The public endpoints
All of these answer a plain GET without a key (Workday uses a POST that the career site itself sends). The token is the company slug in the board URL, for example boards.greenhouse.io/stripe.
| System | List endpoint | Notes |
|---|---|---|
| Greenhouse | https://boards-api.greenhouse.io/v1/boards/{token}/jobs?content=true | The whole board in one response. pay_transparency=true adds structured pay ranges. Departments only come with content=true or from /departments. Descriptions are HTML-escaped. |
| Lever | https://api.lever.co/v0/postings/{token}?mode=json | EU accounts use api.eu.lever.co. Paging with skip and limit. Robots.txt asks for a crawl delay of one second. |
| Ashby | https://api.ashbyhq.com/posting-api/job-board/{org}?includeCompensation=true | Everything in one response, including compensation. workplaceType is more reliable than the remote flag. |
| SmartRecruiters | https://api.smartrecruiters.com/v1/companies/{id}/postings | Paged list; each posting has its own detail call for the description. |
| Workable | https://apply.workable.com/api/v1/widget/accounts/{token} | The widget feed behind the hosted board. |
| Recruitee | https://{token}.recruitee.com/api/offers/ | Includes the company name. |
| Personio | https://{token}.jobs.personio.de/xml?language=en | XML feed; some accounts use .jobs.personio.com. |
| Teamtailor | https://{token}.teamtailor.com/jobs.rss | RSS; custom domains work the same way. |
| Workday | https://{tenant}.wd{n}.myworkdayjobs.com/wday/cxs/{tenant}/{site}/jobs | No vendor API: this is the JSON call the career site sends (POST, 20 jobs per page, a query cap of about 2,000 results, so split large boards by filter). Undocumented, can change without notice. |
curl
# Greenhouse: the whole Stripe board, with pay ranges where stated
curl -s "https://boards-api.greenhouse.io/v1/boards/stripe/jobs?content=true&pay_transparency=true" | head -c 600
# Lever: first page of postings
curl -s "https://api.lever.co/v0/postings/{token}?mode=json&limit=50&skip=0"
# Ashby: board with compensation
curl -s "https://api.ashbyhq.com/posting-api/job-board/{org}?includeCompensation=true"
Python
import html
import time
import requests
def greenhouse_jobs(token: str):
r = requests.get(
"https://boards-api.greenhouse.io/v1/boards/" + token + "/jobs",
params={"content": "true", "pay_transparency": "true"},
timeout=60,
)
if r.status_code == 404:
return [] # no such board: the token is wrong, or the company left Greenhouse
r.raise_for_status()
return [
{
"id": j["id"],
"title": j["title"],
"location": (j.get("location") or {}).get("name"),
"url": j["absolute_url"],
"updated_at": j.get("updated_at"),
"pay": j.get("pay_input_ranges"),
"description": html.unescape(j.get("content") or ""), # entity-escaped HTML: decode once
}
for j in r.json()["jobs"]
]
def lever_jobs(token: str, host: str = "api.lever.co"):
skip, out = 0, []
while True:
r = requests.get("https://" + host + "/v0/postings/" + token, params={"mode": "json", "limit": 100, "skip": skip}, timeout=60)
r.raise_for_status()
page = r.json()
out += page
if len(page) < 100:
return out
skip += 100
time.sleep(1) # robots.txt crawl delay
TypeScript
type GhJob = { id: number; title: string; absolute_url: string; location?: { name?: string }; updated_at?: string };
export async function greenhouseJobs(token: string): Promise<GhJob[]> {
const res = await fetch('https://boards-api.greenhouse.io/v1/boards/' + token + '/jobs');
if (res.status === 404) return [];
if (!res.ok) throw new Error('greenhouse ' + res.status);
return ((await res.json()) as { jobs: GhJob[] }).jobs;
}
export async function ashbyJobs(org: string): Promise<unknown[]> {
const res = await fetch('https://api.ashbyhq.com/posting-api/job-board/' + org + '?includeCompensation=true');
if (!res.ok) throw new Error('ashby ' + res.status);
return ((await res.json()) as { jobs: unknown[] }).jobs;
}
Rules that keep it sustainable
- One request per board is usually enough. Greenhouse and Ashby return the whole board at once (Stripe's Greenhouse board was 730 jobs in one response in our test). Do not poll more than a few times a day; boards rarely change faster than that.
- Pace per host. Lever's robots.txt states a crawl delay of one second; Workday returns 20 jobs per request, so a board of 1,400 jobs takes about 70 requests and should be read slowly. Honour
Retry-Afterand back off on 429. - Fetch deltas. Keep the job ids you have seen and the update time; fetch a description only for ids you have not seen. A job that disappears from the list is closed.
- Identify yourself in the user agent and respect each vendor's terms and robots.txt. The endpoints above serve job-seekers; do not use them to harvest candidate data (none is in them) or to apply on someone's behalf.
- Leave out personal data. Posting text sometimes names a recruiter, a hiring manager or a contact email. Strip them before you store or publish.
The pitfalls
- Finding the token is the hard part. There is no directory. Sources: the careers link on the company site, the apply-link host of a job ad, Common Crawl's index of ATS hostnames, or guessing from the domain. Guessing collides: a Greenhouse board called
nurois Nuro (US), notnuro.jp. Confirm the board name against the company before you attach it. The ATS detection guide goes through it. - No country field on Greenhouse lists: the location is a free string ("London, UK"). Parse it, with a gazetteer, if you need a country.
- Pay is optional and uneven. Greenhouse and Ashby return structured ranges only when the employer filled them; Lever has
salaryRange; other systems carry pay as free text in the description. - Staffing agencies post on behalf of clients. Keep them out if you want "jobs at the employer".
- Duplicates. The same role appears once per location on some boards and with several ids on others.
- Cost. Descriptions dominate the bytes: Databricks' Greenhouse list was 1.5 MB without content and 10.6 MB with it.
The fast route: one jobs schema over all of them
Arcadium Data reads these boards daily for the companies it tracks and serves one normalised job record: title, company and company domain, ATS, countries, locations, remote flag, employment type, ISCO occupation group, pay range with currency and period where stated, posting and first-seen times, and the original job URL. One record per job, no aggregator copies.
The remote jobs feed behind the free remote jobs search works without a key and pages with a cursor:
Calls on this page work without a key from one IP (10 requests a minute, 100 a day, short answers, point lookups). A free key lifts the limits: sign up. Routes, parameters and fields: OpenAPI.
curl
curl -s "https://api.arcadiumdata.io/v1/jobs/remote?region=europe&limit=1"
Response (abridged)
{
"count": 1,
"nextCursor": "WyIyMDI2LTEwLTI1IDA2OjU2OjIzLjc2MyswMCIs...",
"data": [{
"id": "workday___JR000667",
"title": "Patient Engagement & Advocacy Engagement Manager",
"company": "Alira Health",
"companyDomain": "alirahealth.com",
"ats": "workday",
"countries": ["IT"],
"remote": true,
"employmentType": "Full time",
"salary": { "min": 70000, "max": 90000, "currency": "EUR", "period": "year" },
"iscoGroup": "12",
"postedAt": "2026-10-23T00:00:00.000Z",
"url": "https://alirahealth.wd3.myworkdayjobs.com/AliraHealth/job/Milan/Engagement-Manager_JR000667"
}]
}
For one company, GET /v1/companies/{domain} gives its primary board (hiring.ats, hiring.boardUrl, hiring.openJobs, hiring.payShare, top departments and countries) and every board we track. Search across all boards, with filters on ATS, country, ISCO group, remote and dates, needs a free key:
curl
curl -s "https://api.arcadiumdata.io/v1/jobs?ats=greenhouse,ashby&country=DE&isco=25&postedSince=2026-10-01&limit=50" \
-H "Authorization: Bearer $ARCADIUM_API_KEY"
Python
import os
import requests
headers = {"Authorization": "Bearer " + os.environ["ARCADIUM_API_KEY"]}
url = "https://api.arcadiumdata.io/v1/jobs?q=%22data+engineer%22&remote=true&limit=50"
cursor = None
while True:
page = requests.get(url + ("&cursor=" + cursor if cursor else ""), headers=headers, timeout=60).json()
for job in page["data"]:
print(job["companyDomain"], job["title"], job.get("salary"))
cursor = page.get("nextCursor")
if not cursor:
break
TypeScript
const res = await fetch('https://api.arcadiumdata.io/v1/companies/stripe.com');
const company = (await res.json()) as {
hiring: { ats: string | null; boardUrl: string | null; openJobs: number | null; payShare: number | null } | null;
};
console.log(company.hiring?.ats, company.hiring?.openJobs, company.hiring?.boardUrl);
GET /v1/jobs/{id} returns one job with its description. The coverage is the boards we track, not every company: employers with their own career site and no public ATS feed (Shopify, for example) are not in it. To check a list of domains before you buy anything, POST /v1/coverage tells you which of up to 50 domains have a known public job board.
Comparison
| Vendor endpoints directly | Arcadium Data jobs API | |
|---|---|---|
| Finding the board token | Your job, per company | By company domain |
| Formats | JSON, XML and RSS, nine schemas | One schema |
| Pay fields | Differ per vendor, often free text | Normalised range, currency, period |
| Occupation and country | Free-text location | Country codes, ISCO occupation group |
| Closed jobs and history | Diff yourself | First-seen and closed timestamps |
| Politeness and rate limits | Yours to implement per host | Handled in our crawl; API limits per plan |
| Freshness | Live | Daily (live boards re-read on a schedule) |
| Coverage | Any company on a public ATS | The boards we track |
Get the data without the plumbing
Arcadium Data serves these datasets from one REST API and one MCP server, with sources, licences and dates on every answer. No key is needed to try the routes in this guide.
Related
- Which ATS does a company use
- Companies using Greenhouse, Workday and other ATS
- SmartRecruiters and Personio endpoints
- Workday career site JSON
- Posted salary data from job ads
- Remote jobs search
- Hiring Signals API
- ATS vendors
Sources
- Greenhouse Job Board API
- Lever Postings API
- Ashby public job posting API
- SmartRecruiters Posting API
- Personio: job postings XML feed
Last updated 2026-10-25. This guide is ours and independent of the organisations it describes. Found a mistake? Tell us and we correct it.
Questions
Is the Greenhouse job board API free and public?
Yes. The GET endpoints under boards-api.greenhouse.io/v1/boards/{token} return a company’s published jobs without a key. Posting applications is a separate, authenticated API.
How do I find a company’s Greenhouse or Lever board token?
It is the slug in the board URL (boards.greenhouse.io/stripe, jobs.lever.co/netflix). Look at the careers link and the apply links of a job. Guessed tokens can belong to another company, so confirm the name.
Does the Workday career site have a public API?
Not as a documented vendor API. The career site calls a JSON endpoint under /wday/cxs/{tenant}/{site}/jobs, which can change without notice. Use it politely or read the data from a provider.
How often should I poll a job board?
A few times a day is plenty for most boards. Greenhouse and Ashby return the whole board in one request; Lever asks for a one-second crawl delay between requests. Fetch descriptions only for new job ids.
Do these feeds contain candidate or recruiter data?
They contain no candidate data. Posting text can name a recruiter or hiring manager, so remove names and contact details before you store or publish.
Cite as: Arcadium Data, "Job posting data from company career sites: the public Greenhouse, Lever and Ashby endpoints", https://arcadiumdata.io/guides/job-board-api-greenhouse-lever-ashby/, data as of 2026-10-25.