Why this exists

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.

Greenhouse

Verified live — stripe, 200 OK, 550 jobs

Endpoint

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).

Finding the board token

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.

Example request

curl "https://boards-api.greenhouse.io/v1/boards/stripe/jobs?content=true"

Trimmed real response

{
  "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..."
    }
  ]
}
Quirk: 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 &lt;h2&gt;. 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 &lt; and &gt; junk in the text.

Pagination

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.

Rate limits

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.

Other notes

Lever

Verified live — palantir, 200 OK, 301 jobs

Endpoint

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.

Finding the site slug

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.

Example request

curl "https://api.lever.co/v0/postings/palantir?mode=json"

Trimmed real response

[
  {
    "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"
  }
]
Quirk: response is a bare array, not an object Unlike every other platform here, the top-level response is a JSON array, not an object with a jobs/content/result key. Code that assumes an object wrapper and does data.jobs will silently get undefined here.

Pagination

None on the public postings endpoint — one call returns every open posting.

Rate limits

Undocumented; no rate-limit headers observed. Space out calls, especially across a large company list.

Other notes

Ashby

Verified live — ramp, 200 OK

Endpoint

GET https://api.ashbyhq.com/posting-api/job-board/{slug}

No authentication.

Finding the slug

From a company's public Ashby board, e.g. jobs.ashbyhq.com/ramp → slug ramp.

Example request

curl "https://api.ashbyhq.com/posting-api/job-board/ramp"

Trimmed real response

{
  "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

..." } ] }
Quirk: leading/trailing whitespace in titles The live response above has "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.

Pagination

None — one call returns the full open job list.

Rate limits

No rate-limit headers observed on our test call. Undocumented publicly; be conservative.

Other notes

Workday

Verified live — intel.wd1.myworkdayjobs.com/External, 200 OK, total 681

Endpoint

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": "" }

Why there's no guessable slug

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.

Example request

curl -X POST "https://intel.wd1.myworkdayjobs.com/wday/cxs/intel/External/jobs" \
  -H "Content-Type: application/json" \
  -d '{"limit":5,"offset":0,"searchText":""}'

Trimmed real response

{
  "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"
    }
  ]
}
Quirk: multi-location postings report a placeholder, not the cities We saw this directly in the response above: the first posting's 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.
Quirk: 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.

Pagination

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.

Rate limits

Undocumented; no rate-limit headers observed. Workday tenants are enterprise infrastructure shared across many career sites — be especially conservative here.

Other notes

SmartRecruiters

Verified live — Visa, 200 OK, totalFound 2

Endpoint

GET https://api.smartrecruiters.com/v1/companies/{identifier}/postings

No authentication. Supports ?limit= and ?offset=.

Finding the identifier

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.

Example request

curl "https://api.smartrecruiters.com/v1/companies/Visa/postings?limit=10&offset=0"

Trimmed real response

{
  "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" }
    }
  ]
}
Quirk: list endpoint never includes a description There is no description field anywhere in the postings list response, confirmed on the live payload above. The full description only exists behind a separate per-posting detail call (GET /v1/companies/{identifier}/postings/{id}), which multiplies your request count by job count if you want every description.

Pagination

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.

Rate limits

Undocumented; no rate-limit headers on our test responses.

Other notes

Workable

Verified live — huggingface, 200 OK, 7 jobs on page

Endpoint

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).

Finding the slug

From a public board URL, e.g. apply.workable.com/huggingface → slug huggingface.

Example request

curl "https://apply.workable.com/api/v1/widget/accounts/huggingface?details=false"

Trimmed real response

{
  "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"
    }
  ]
}
Quirk: locations arrive as three separate fields, not one string 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.

Pagination

None observed on the widget endpoint — one call returned the company's full open job list.

Rate limits

Undocumented; no rate-limit headers observed.

Other notes

Recruitee

Verified live — helloprint, 200 OK

Endpoint

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.

Finding the slug

The subdomain of a company's public Recruitee board, e.g. helloprint.recruitee.com → slug helloprint.

Example request

curl "https://helloprint.recruitee.com/api/offers/"

Trimmed real response

{
  "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/..."
    }
  ]
}
Quirk: two separate location representations, sometimes disagreeing Recruitee gives you both a flat 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.

Pagination

None observed — the endpoint returns the full open offer list in one call.

Rate limits

Undocumented; no rate-limit headers observed.

Other notes

Personio

Verified live — personio, 200 OK, XML

Endpoint

GET https://{slug}.jobs.personio.de/xml

No authentication.

Finding the slug

The subdomain of a company's public Personio careers XML feed, e.g. personio.jobs.personio.de → slug personio.

Example request

curl "https://personio.jobs.personio.de/xml"

Trimmed real response

<?xml version="1.0" encoding="UTF-8"?>
<workzag-jobs>
  <position>
    <id>1834171</id>
    <subcompany>Personio SE &amp; 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>
Quirk: this is the one platform in this list that isn't JSON at all The response is plain XML (Personio calls the schema 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.
Quirk: 307-redirects for a non-customer instead of 404ing We confirmed this directly: requesting a made-up slug returns HTTP 307 with 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.

Pagination

None — the full XML document contains every open position in one response.

Rate limits

Undocumented; no rate-limit headers observed.

Other notes

BambooHR

Verified live — flyio, 200 OK, totalCount 10

Endpoint

GET https://{slug}.bamboohr.com/careers/list

No authentication. Despite the URL not looking like an API path, this returns JSON, not HTML.

Finding the slug

The subdomain of a company's public BambooHR careers page, e.g. flyio.bamboohr.com → slug flyio.

Example request

curl "https://flyio.bamboohr.com/careers/list"

Trimmed real response

{
  "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"
    }
  ]
}
Quirk: descriptions are never in the list response Confirmed on the live payload above — there is no description field in result at all. It only exists behind a per-job detail call (GET /careers/{id}/detail), same trade-off as SmartRecruiters and Workday.
Quirk: location fields are frequently null even when the job has a real location Every job in the flyio response above has 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.
Quirk: 302-redirects for a non-customer instead of 404ing Same behavior as Personio: a made-up subdomain returns HTTP 302 to 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.

Pagination

None — meta.totalCount matches the length of result in one call; no offset/limit parameters.

Rate limits

Undocumented; no rate-limit headers observed.

Other notes


Comparison table

PlatformMethodFormatPagination Description in list?Company name?Not-found signal
GreenhouseGETJSONNoneYes (double-encoded)Yes404
LeverGETJSON (bare array)NoneYesNo404
AshbyGETJSONNoneYesNo404 / empty
WorkdayPOSTJSONoffset/limitNo (per-job call)No404/422
SmartRecruitersGETJSONoffset/limitNo (per-job call)Yes (nested)404 / empty
WorkableGETJSONNoneYes (details=true)Yes404
RecruiteeGETJSONNoneYesNo404
PersonioGETXMLNoneYes (CDATA, often empty)No307 redirect
BambooHRGETJSONNoneNo (per-job call)No302 redirect

How to normalize across platforms

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
}

If you'd rather not run this yourself

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:

Other verified API references

Same approach — every endpoint called live, every quirk documented rather than assumed: