The public job board APIs behind nine applicant tracking systems — Greenhouse, Lever, Ashby, Workday, SmartRecruiters, Workable, Recruitee, Personio, and BambooHR — documented from endpoints we personally called and watched respond.
Nine applicant tracking systems — Greenhouse, Lever, Ashby, Workday, SmartRecruiters, Workable, Recruitee, Personio, and BambooHR — each expose a public, unauthenticated JSON (or, in Personio's case, XML) endpoint that powers their own embeddable careers widget. These endpoints are not secret, but accurate documentation of their shapes and quirks is scattered across forum posts, GitHub issues, and half-stale blog posts. This page consolidates what we verified by actually calling each one, against a real company, on the date at the top of this page.
Nothing here is documented from memory or from someone else's writeup. Every endpoint URL, every trimmed JSON/XML sample, and every quirk below was observed directly.
GET https://boards-api.greenhouse.io/v1/boards/{board_token}/jobs
No authentication. Add ?content=true to include each job's full HTML description in the same response (omit it, or use content=false, for a lighter list-only call).
The token is the slug in a company's public Greenhouse board URL, e.g. boards.greenhouse.io/stripe → token stripe. It's usually — not always — the company's lowercase name with no spaces.
curl "https://boards-api.greenhouse.io/v1/boards/stripe/jobs?content=true"
{
"jobs": [
{
"id": 8023928,
"title": "Account Executive, Bridge",
"location": { "name": "London" },
"absolute_url": "https://stripe.com/jobs/search?gh_jid=8023928",
"company_name": "Stripe",
"first_published": "2026-07-30T06:59:38-04:00",
"updated_at": "2026-08-04T07:02:31-04:00",
"requisition_id": "See Opening ID",
"content": "<h2><strong>Who we are</strong></h2>\n..."
}
]
}
content is double entity-encoded HTML
We watched this directly on the live response above. The field isn't plain HTML, and it isn't HTML with normal entities either — it's HTML where the entities have themselves been HTML-entity-encoded a second time. <h2> arrives as the literal text <h2>. You must run an HTML-entity-decode pass twice before you have real markup to strip or render. Decoding only once leaves you with visible < and > junk in the text.
None. One call returns the full list of open jobs for the board — Stripe's request above returned all 550 open jobs in a single response, no offset/limit parameters exist.
Undocumented publicly; no Retry-After or rate-limit headers were present on our test responses. Be polite — space out requests and don't hammer a board in a tight loop, especially across many companies.
company_name is reliably present — Greenhouse is one of only two platforms in this list (with Workable) that reliably gives you a human-readable company name back.departments and offices are available as separate sibling endpoints (/departments, /offices) if you want the taxonomy independent of jobs.GET https://api.lever.co/v0/postings/{site}?mode=json
No authentication. The mode=json parameter matters — without it Lever's default content negotiation can behave inconsistently; always pass it explicitly.
From a company's public Lever board, e.g. jobs.lever.co/palantir → slug palantir. Note Netflix, a commonly cited Lever example in older writeups, returned a 404 Document not found when we tested it — they're no longer on Lever, which is exactly the kind of staleness this page is trying to avoid perpetuating.
curl "https://api.lever.co/v0/postings/palantir?mode=json"
[
{
"id": "a1b2c3d4-...",
"text": "Software Engineer, Backend",
"categories": {
"department": "Engineering",
"location": "New York",
"allLocations": ["New York", "Remote"],
"commitment": "Full-time"
},
"workplaceType": "hybrid",
"createdAt": 1753564800000,
"hostedUrl": "https://jobs.lever.co/palantir/a1b2c3d4-...",
"applyUrl": "https://jobs.lever.co/palantir/a1b2c3d4-.../apply"
}
]
jobs/content/result key. Code that assumes an object wrapper and does data.jobs will silently get undefined here.
None on the public postings endpoint — one call returns every open posting.
Undocumented; no rate-limit headers observed. Space out calls, especially across a large company list.
createdAt is a Unix millisecond timestamp, not ISO 8601 — convert it.[]) is genuinely ambiguous: it's returned both for a company that isn't on Lever at all in some edge cases and for a company that is on Lever but currently has zero open roles. In practice a company not on Lever usually 404s rather than returning an empty array, but don't rely on that distinction absolutely.GET https://api.ashbyhq.com/posting-api/job-board/{slug}
No authentication.
From a company's public Ashby board, e.g. jobs.ashbyhq.com/ramp → slug ramp.
curl "https://api.ashbyhq.com/posting-api/job-board/ramp"
{
"jobs": [
{
"id": "34413f8d-26bf-4bbc-8ade-eb309a0e2245",
"title": " Security Engineer, Cloud",
"department": "Engineering",
"team": "Backend",
"location": "New York, NY (HQ)",
"secondaryLocations": [
{ "location": "Remote (Canada)" },
{ "location": "Remote (US)" },
{ "location": "Miami, FL" }
],
"employmentType": "FullTime",
"isRemote": true,
"workplaceType": "Hybrid",
"publishedAt": "2026-04-07T17:12:35.753+00:00",
"jobUrl": "https://jobs.ashbyhq.com/ramp/34413f8d-.../",
"applyUrl": "https://jobs.ashbyhq.com/ramp/34413f8d-.../application",
"descriptionHtml": "About Ramp
..."
}
]
}
"title": " Security Engineer, Cloud" — a literal leading space. Trim job titles before displaying them; this isn't a one-off, it shows up across several companies' Ashby boards.
None — one call returns the full open job list.
No rate-limit headers observed on our test call. Undocumented publicly; be conservative.
descriptionHtml is present by default (not gated behind a separate parameter), so pulling descriptions for every job doesn't cost you extra requests the way it does on some other platforms — but it does make responses noticeably larger.location + secondaryLocations — unlike Workday, nothing here collapses into a placeholder.jobs array is ambiguous between "not on Ashby" and "on Ashby, zero open roles," same caveat as Lever and SmartRecruiters.POST https://{tenant}.{shard}.myworkdayjobs.com/wday/cxs/{tenant}/{site}/jobs
No authentication. Unlike every other platform on this page, this is a POST with a JSON body, not a GET:
{ "limit": 20, "offset": 0, "searchText": "" }
A Workday board URL encodes three independent values: the tenant (Intel's is intel), the site name within that tenant (Intel's public board is External), and the shard — the Workday-infrastructure host, one of wd1, wd3, wd5, and others. None of the three is derivable from a company's name or from either of the other two values. You cannot probe {company}.wd1.myworkdayjobs.com/wday/cxs/{company}/External/jobs the way you can guess a Greenhouse or Lever slug — you have to start from an actual board URL a human found (e.g. https://intel.wd1.myworkdayjobs.com/External) and parse tenant/site/shard out of it.
curl -X POST "https://intel.wd1.myworkdayjobs.com/wday/cxs/intel/External/jobs" \
-H "Content-Type: application/json" \
-d '{"limit":5,"offset":0,"searchText":""}'
{
"total": 681,
"jobPostings": [
{
"title": "Senior Member of Technical Staff (CMP) Engineer",
"externalPath": "/job/US-Oregon-Hillsboro/Senior-Member-of-Technical-Staff--CMP--Engineer_JR0281513",
"locationsText": "2 Locations",
"postedOn": "Posted Yesterday",
"bulletFields": ["Spotlight Job", "JR0281513"]
},
{
"title": "Software Technician",
"externalPath": "/job/US-Arizona-Phoenix/Software-Technician_JR0285603",
"locationsText": "US, Arizona, Phoenix",
"postedOn": "Posted 20 Days Ago"
}
]
}
locationsText is the literal string "2 Locations", not the actual two cities. Single-location postings are fine — "US, Arizona, Phoenix" is exact. Getting the real city list for a multi-location posting means an extra GET to the per-job detail endpoint (GET .../wday/cxs/{tenant}/{site}/job{externalPath}), one request per affected job.
postedOn is a relative string, never a real timestamp
"Posted Yesterday", "Posted 20 Days Ago" — this is all the list endpoint gives you. There is no ISO date anywhere in the list response to normalize; if you need an absolute date you'd have to compute one from the relative string at fetch time, which is imprecise, or accept that Workday postings have no reliable posted-date field.
total is reported on the first page's response. Increment offset by the page size (Intel's default page is 20 items) and keep requesting until you've collected total items or a page comes back with an empty jobPostings array. Note: in our source code's experience total can become unreliable (often reported as 0) on pages after the first, so capture it once from the first response and page against that captured value plus an empty-page stop condition, rather than trusting total on every page.
Undocumented; no rate-limit headers observed. Workday tenants are enterprise infrastructure shared across many career sites — be especially conservative here.
GET https://{tenant}.{shard}.myworkdayjobs.com/wday/cxs/{tenant}/{site}/job{externalPath} (note: GET, not POST, unlike the list call).GET https://api.smartrecruiters.com/v1/companies/{identifier}/postings
No authentication. Supports ?limit= and ?offset=.
From a public board URL, e.g. jobs.smartrecruiters.com/Visa → identifier Visa. Unlike most of the other platforms, case can matter here — this is the company's company.identifier, not necessarily a lowercased slug.
curl "https://api.smartrecruiters.com/v1/companies/Visa/postings?limit=10&offset=0"
{
"offset": 0,
"limit": 10,
"totalFound": 2,
"content": [
{
"id": "744000133907678",
"name": "Sr. Manager",
"refNumber": "REF97395W",
"company": { "identifier": "Visa", "name": "Visa" },
"releasedDate": "2026-06-24T10:00:11.853Z",
"location": {
"city": "Austin", "region": "TX", "country": "us",
"remote": false, "hybrid": false,
"fullLocation": "Austin, TX, United States"
},
"department": { "id": "868639", "label": "Software Development/Engineering" },
"typeOfEmployment": { "id": "permanent", "label": "Full-time" }
}
]
}
GET /v1/companies/{identifier}/postings/{id}), which multiplies your request count by job count if you want every description.
totalFound plus offset/limit — standard offset pagination. Increment offset by the number of items returned until you've collected totalFound or a page comes back empty.
Undocumented; no rate-limit headers on our test responses.
content array with totalFound: 0 is ambiguous between "company not on SmartRecruiters" and "on SmartRecruiters, zero open roles" — same caveat as Lever and Ashby.location.remote and location.hybrid are explicit booleans when set, which is more reliable than inferring remote-ness from a location string.GET https://apply.workable.com/api/v1/widget/accounts/{slug}
No authentication. Add ?details=true for full job descriptions in the same call (we used details=false for the trimmed example below).
From a public board URL, e.g. apply.workable.com/huggingface → slug huggingface.
curl "https://apply.workable.com/api/v1/widget/accounts/huggingface?details=false"
{
"name": "Hugging Face",
"description": "Here at Hugging Face, we're on a journey to advance and democratize machine learning for everyone...",
"jobs": [
{
"title": "Low-level Senior Software Engineer, Xet Storage - EMEA Remote",
"shortcode": "F4C096B22E",
"employment_type": "Full-time",
"telecommuting": true,
"department": "Product",
"city": "Paris",
"state": "Île-de-France",
"country": "France",
"published_on": "2026-07-30",
"application_url": "https://apply.workable.com/j/F4C096B22E/apply"
}
]
}
city, state, and country come back separately (and any of them can be missing for remote-only roles) — you assemble your own display string rather than getting one handed to you, unlike most of the other platforms here.
None observed on the widget endpoint — one call returned the company's full open job list.
Undocumented; no rate-limit headers observed.
name (company display name) is reliably present — Workable is the other platform, alongside Greenhouse, that gives you this for free.telecommuting is an explicit boolean, more reliable than text-sniffing a location field for "remote".details=true query param, same request count) — unlike Workday or SmartRecruiters, which require a per-job call.GET https://{slug}.recruitee.com/api/offers/
No authentication. Note the trailing slash — Recruitee's own careers-widget JS always includes it, and it's worth keeping in your requests.
The subdomain of a company's public Recruitee board, e.g. helloprint.recruitee.com → slug helloprint.
curl "https://helloprint.recruitee.com/api/offers/"
{
"offers": [
{
"id": 1006,
"guid": "gy8uf",
"title": "...",
"city": "Rotterdam",
"locations": [
{
"name": "HelloPrint - Rotterdam",
"city": "Rotterdam",
"country": "Netherlands",
"state": "Zuid-Holland"
}
],
"department": "Marketing",
"employment_type_code": "fulltime",
"remote": false,
"published_at": "2026-07-15T09:00:00.000Z",
"careers_url": "https://helloprint.recruitee.com/o/..."
}
]
}
city field and a structured locations array with full office objects. For multi-office postings, locations is the one to trust; the flat field reflects only the primary office.
None observed — the endpoint returns the full open offer list in one call.
Undocumented; no rate-limit headers observed.
remote/hybrid booleans are present but not always set consistently — fall back to location-text inference when both are absent.requirements/description fields contain raw HTML, including inline styling in some accounts — strip before display.GET https://{slug}.jobs.personio.de/xml
No authentication.
The subdomain of a company's public Personio careers XML feed, e.g. personio.jobs.personio.de → slug personio.
curl "https://personio.jobs.personio.de/xml"
<?xml version="1.0" encoding="UTF-8"?>
<workzag-jobs>
<position>
<id>1834171</id>
<subcompany>Personio SE & Co. KG</subcompany>
<office>Munich</office>
<additionalOffices>
<office>Berlin</office>
</additionalOffices>
<department>Product and Tech</department>
<recruitingCategory>Engineering</recruitingCategory>
<name>Staff Software Engineer, Data Platform</name>
<employmentType>permanent</employmentType>
<createdAt>2024-11-13T14:10:41+00:00</createdAt>
</position>
</workzag-jobs>
workzag-jobs, a holdover from a prior product name). You need an XML parser, or — since the schema is small and fixed — regex-based tag extraction works fine in practice; that's what we do in production. There is no JSON equivalent of this feed.
location: https://personio.com, not a 404. Follow-redirects-off (or check the status code before following) is the only reliable way to detect "this isn't a real Personio customer" — a client that blindly follows redirects will land on Personio's marketing site and may try to parse it as XML.
None — the full XML document contains every open position in one response.
Undocumented; no rate-limit headers observed.
<office> plus every <office> nested under <additionalOffices>; a single office value can itself be a comma-separated list, so split on commas after extracting.<jobDescriptions> block as CDATA-wrapped HTML when present — many postings have this element present but empty.GET https://{slug}.bamboohr.com/careers/list
No authentication. Despite the URL not looking like an API path, this returns JSON, not HTML.
The subdomain of a company's public BambooHR careers page, e.g. flyio.bamboohr.com → slug flyio.
curl "https://flyio.bamboohr.com/careers/list"
{
"meta": { "totalCount": 10 },
"result": [
{
"id": "35",
"jobOpeningName": "Infrastructure Ops Engineer",
"departmentId": "18593",
"departmentLabel": "Infrastructure Operations",
"employmentStatusLabel": "US Employee",
"employmentType": null,
"location": { "city": null, "state": null },
"atsLocation": { "country": null, "state": null, "province": null, "city": null },
"isRemote": null,
"locationType": "1"
}
]
}
result at all. It only exists behind a per-job detail call (GET /careers/{id}/detail), same trade-off as SmartRecruiters and Workday.
city: null in both location and atsLocation, despite Fly.io's own careers page showing real cities for these roles in the browser UI. The list endpoint's location data is sparse in a way that doesn't fully reflect what's configured in the company's BambooHR account — again, only the per-job detail call fills this in reliably.
www.bamboohr.com, not a 404. Check the status code (and don't blindly follow the redirect) before assuming a slug is a real customer.
None — meta.totalCount matches the length of result in one call; no offset/limit parameters.
Undocumented; no rate-limit headers observed.
postedAt/date-posted has no equivalent anywhere in the list response, same limitation as descriptions — only available via the per-job detail endpoint.| Platform | Method | Format | Pagination | Description in list? | Company name? | Not-found signal |
|---|---|---|---|---|---|---|
| Greenhouse | GET | JSON | None | Yes (double-encoded) | Yes | 404 |
| Lever | GET | JSON (bare array) | None | Yes | No | 404 |
| Ashby | GET | JSON | None | Yes | No | 404 / empty |
| Workday | POST | JSON | offset/limit | No (per-job call) | No | 404/422 |
| SmartRecruiters | GET | JSON | offset/limit | No (per-job call) | Yes (nested) | 404 / empty |
| Workable | GET | JSON | None | Yes (details=true) | Yes | 404 |
| Recruitee | GET | JSON | None | Yes | No | 404 |
| Personio | GET | XML | None | Yes (CDATA, often empty) | No | 307 redirect |
| BambooHR | GET | JSON | None | No (per-job call) | No | 302 redirect |
If you're pulling from more than one of these, a schema that survives all nine looks roughly like:
{
platform, companySlug, companyName, jobId,
title, department, team, locations[], isRemote,
employmentType, applyUrl, postedAt, updatedAt,
description, scrapedAt
}
(platform, companySlug); formats vary wildly (numeric on Greenhouse, UUIDs on Ashby, a URL path on Workday) and are never comparable across platforms.workplaceType) and fall back to regex-matching "remote" in location text only when nothing explicit exists. Treat the result as best-effort, not ground truth.null for those two regardless of what you do.Every endpoint on this page is public and unauthenticated — no login, no API key, no session cookie. These are the same URLs each platform's own embeddable careers widget calls from a visitor's browser; nothing here bypasses access controls.
Everything above is enough to build your own fetcher. If you'd rather not, here are the two things we've published from the same code this page is based on:
Same approach — every endpoint called live, every quirk documented rather than assumed:
fields array isn't server-validated (unknown names return null instead of erroring), and page_metadata.hasNext lies past roughly page 100.pageToken cursor pagination, and why pageSize over-cap silently clamps instead of erroring.