Developer guides

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.

SystemList endpointNotes
Greenhousehttps://boards-api.greenhouse.io/v1/boards/{token}/jobs?content=trueThe 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.
Leverhttps://api.lever.co/v0/postings/{token}?mode=jsonEU accounts use api.eu.lever.co. Paging with skip and limit. Robots.txt asks for a crawl delay of one second.
Ashbyhttps://api.ashbyhq.com/posting-api/job-board/{org}?includeCompensation=trueEverything in one response, including compensation. workplaceType is more reliable than the remote flag.
SmartRecruitershttps://api.smartrecruiters.com/v1/companies/{id}/postingsPaged list; each posting has its own detail call for the description.
Workablehttps://apply.workable.com/api/v1/widget/accounts/{token}The widget feed behind the hosted board.
Recruiteehttps://{token}.recruitee.com/api/offers/Includes the company name.
Personiohttps://{token}.jobs.personio.de/xml?language=enXML feed; some accounts use .jobs.personio.com.
Teamtailorhttps://{token}.teamtailor.com/jobs.rssRSS; custom domains work the same way.
Workdayhttps://{tenant}.wd{n}.myworkdayjobs.com/wday/cxs/{tenant}/{site}/jobsNo 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

The pitfalls

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 directlyArcadium Data jobs API
Finding the board tokenYour job, per companyBy company domain
FormatsJSON, XML and RSS, nine schemasOne schema
Pay fieldsDiffer per vendor, often free textNormalised range, currency, period
Occupation and countryFree-text locationCountry codes, ISCO occupation group
Closed jobs and historyDiff yourselfFirst-seen and closed timestamps
Politeness and rate limitsYours to implement per hostHandled in our crawl; API limits per plan
FreshnessLiveDaily (live boards re-read on a schedule)
CoverageAny company on a public ATSThe 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.

Sources

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.