ATS Jobs API — Greenhouse, Lever, Ashby & more avatar

ATS Jobs API — Greenhouse, Lever, Ashby & more

Pricing

from $1.50 / 1,000 job records

Go to Apify Store
ATS Jobs API — Greenhouse, Lever, Ashby & more

ATS Jobs API — Greenhouse, Lever, Ashby & more

Open jobs from Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Recruitee and Personio as one JSON schema: title, location, remote flag, salary when published, apply URL. Diff mode flags new and closed roles. Official public job-board APIs only, no login.

Pricing

from $1.50 / 1,000 job records

Rating

0.0

(0)

Developer

Jack Sheward

Jack Sheward

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

19 hours ago

Last modified

Share

ATS Jobs API — Greenhouse, Lever, Ashby & more

Give it a list of companies and get back every open role on their public career boards as one clean, flat JSON schema: title, department, location, remote flag, salary when the company publishes it (from the ATS's pay field, or else a pay range stated in the posting text; salary_source says which), posted date, and the apply link. It works with Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Recruitee and Personio. You can pass a board token (gitlab), a prefixed token (lever:palantir), a careers URL (https://jobs.ashbyhq.com/openai) or a plain company name (Scale AI). The ATS is detected automatically, and a plain name is checked against the company name each ATS reports before you're charged for it.

Turn on diff mode and each run tells you which roles are new and which closed since the last run. That makes it a hiring-signal monitor for a watchlist of companies. The new/closed flags are free.

It reads each ATS's own documented, public job-board API. No login, no API key, no browser, no scraping of HTML pages.

Who it's for: recruiters and sourcers tracking target companies, sales teams using hiring as a buying signal, job-board and aggregator builders, VC/market researchers, job seekers watching a shortlist, and AI agents that need "what is company X hiring for?" in one call.

Supported job boards

ATSPass any of theseSalaryNotes
Greenhousegitlab, greenhouse:gitlab, boards.greenhouse.io/gitlab, job-boards.greenhouse.io/gitlabpay-transparency ranges (placeholder ranges such as USD 1-2 are ignored); pay written only in the description is read with includeDescription (the plain job list has no text)department from the board's departments list (on rare jobs this differs from the job page's own department, an upstream inconsistency)
Leverlever:palantir, jobs.lever.co/palantirsalaryRange, else a range stated in the posting text (Palantir: "$60,000 - $97,000/year")EU-hosted boards (jobs.eu.lever.co) handled, with a fallback to the global host
Ashbyashby:openai, jobs.ashbyhq.com/openaicompensation tiers, else a range stated in the posting text (Notion)
SmartRecruiterssr:Equinox, jobs.smartrecruiters.com/Equinoxa salary custom field on the posting list (Wise's "Job Ad Salary Range"); with includeDescription, also each posting's structured compensation, else a range in its textdescriptions (and that pay) cost 1 extra request per job
Workableworkable:blueground, apply.workable.com/bluegroundonly a range stated in the posting text, with includeDescription
Recruiteerecruitee:bunq, bunq.recruitee.comsalary field, else a range stated in the posting text
Personiopersonio:1komma5grad, 1komma5grad.jobs.personio.deonly a range stated in the posting text (the feed has no pay field)XML feed; some companies disable it. Descriptions missing from the English feed come from the board's default-language (often German) feed

How auto-detect picks a board

A token or name without an ATS prefix is tried on all seven ATSs. The actor keeps the board whose company name (as the ATS reports it) matches what you typed, and among equally good matches the one with the most open roles. For example, Wise resolves to SmartRecruiters Wise (415 roles), not to the Greenhouse board wise, which belongs to "Wise Worksite Field Sales".

Some boards exist but can't be trusted to be the company you meant. Workable, Recruitee, Personio and SmartRecruiters let anyone open an account, so big-brand names there are often empty or hold sample postings. In a check on 2026-09-24, microsoft, apple, nike and 15 other large employers were empty Workable accounts, Recruitee google held one "Senior Marketer (Sample)" posting, and Recruitee meta belonged to a university. A board like that comes back as a free unconfirmed row, with no job rows. There are three cases:

  • the board has no open roles,
  • its postings look like placeholders (sample/test titles or lorem-ipsum text),
  • the ATS's company name is a different company.

If the ATS's name only partly matches (for example xAI finds the Greenhouse board named "SpaceXAI"), the jobs are delivered and the company row's message carries a Caution note.

To skip the guessing, use a prefix (greenhouse:wise) or the careers URL. Those always use exactly that board. A careers page on the company's own domain (https://careers.airbnb.com/) is turned into a token guess (airbnb) and auto-detected.

Input

FieldTypeDefaultWhat it does
companiesarray of stringsdemo: gitlab, lever:palantir, https://jobs.ashbyhq.com/openaiBoard tokens, ats:token prefixes, careers URLs or company names, one per item. Up to 500 per run (the input schema rejects longer lists; split them across runs or diff keys). A comma inside an item is part of the name (Stripe, Inc.). Newlines or ; inside an item separate entries. Leave it out for the demo. An empty list, or only blank entries, gives one free explanatory row and no demo run.
atsauto / greenhouse / lever / ashby / smartrecruiters / workable / recruitee / personioautoWhich ATS plain tokens belong to. Prefixes and URLs always win.
maxJobsPerCompanyinteger20Newest roles returned per company. 0 = all.
maxRecordsinteger0 (no limit)Cap on job records across the whole run, which caps the cost.
titleKeywordsarray—Keep jobs whose title contains any keyword (engineer, sales, data).
locationKeywordsarray—Keep jobs whose location contains any keyword (London, UK, Remote). Common place spellings count as one: UK = United Kingdom = Great Britain (plus England, Scotland, Wales, Northern Ireland), US = USA = United States plus US-state places written City, ST (Austin, TX), California = CA, NYC = New York, Canada plus Toronto, ON, and about 40 countries' names and codes (Germany = Deutschland). Keywords of 3 letters or fewer must match a whole word. A location written only as a city (San Francisco) matches only the city name, not US.
remoteOnlybooleanfalseKeep only roles that can be done remotely (see remote below).
includeDescriptionbooleanfalseAdd the plain-text description (HTML stripped). On SmartRecruiters it also fills pay from each posting's published compensation (1 extra request per job).
descriptionMaxCharsinteger3000Trim descriptions to this length (0 = full text).
diffModebooleanfalseRemember open roles between runs and flag new / unchanged, plus a free closed row per role that disappeared.
onlyChangesbooleantrueIn diff mode, return only new roles (plus closed rows) instead of every open role.
diffKeystringhash of companies + filtersName for the saved state. Set it (e.g. my-watchlist) to keep history while you edit the company list.

The platform checks input types before the run starts, so companies must be a JSON array and the number fields must be numbers. Within that, the actor is lenient. Keys are case-insensitive. snake_case spellings such as max_jobs_per_company also work, and they take priority over a default the platform fills in. Entries are trimmed and case-insensitive. An unknown or broken entry, even a placeholder like https://[company].greenhouse.io, becomes a free explanatory row instead of failing the run.

Example: a daily watchlist that returns only new engineering roles:

{
"companies": ["gitlab", "lever:palantir", "https://jobs.ashbyhq.com/openai", "sr:Equinox"],
"titleKeywords": ["engineer"],
"diffMode": true,
"diffKey": "eng-watchlist",
"maxJobsPerCompany": 0
}

Output

Every row has the same 29 keys, so CSV/Excel exports and agents always see one schema. A key is null when it doesn't apply. record_type says what the row is:

  • job with status: "open": an open role (billed as job-record).
  • job with status: "closed": diff mode only, a role that was open last run and is gone now (free).
  • company: one free summary row per company. message explains it in plain English. status is one of:
    • ok: board found, and its jobs were delivered.
    • no_open_jobs: the board you named exists but has 0 roles.
    • unconfirmed: auto-detect found a board it can't trust (see above).
    • not_found: no board anywhere.
    • error: the ATS was unreachable, or its board is over the size cap (the message says which; retrying won't help for the size cap).
    • duplicate: the same board as an earlier entry.
    • not_processed: charge limit, maxRecords or timeout reached.
    • invalid_input: the entry couldn't be used.
FieldExampleNotes
record_typejobjob or company
companyGitLabdisplay name when the ATS gives one, else the board token. Personio: the hiring legal entity (<subcompany>)
company_sluggitlabthe board token that answered
atsgreenhousegreenhouse, lever, ashby, smartrecruiters, workable, recruitee, personio
job_id8821526002the ATS's own posting id (string)
titleIntermediate Security Analyst…
departmentProduct Securitydepartment, or team where that's all the ATS has
locationRemote, Canada; Remote, United Statesprimary location as the company wrote it
all_locationsIstanbul; Sofiaevery listed location, ; -separated. Greenhouse adds its office entries only with includeDescription, because the plain job list doesn't carry them
remotetruetrue when the role can be done remotely, false on-site/hybrid only, null unknown. true when the board's own workplace field says remote (the ATS remote flag, or a Greenhouse custom field such as "Workplace Type" or "Location Type"), or when one of the role's listed locations is explicitly remote. For example, an Ashby or Lever Hybrid role with a Remote (US) location is remote: true with workplace_type: "hybrid". On Greenhouse the board's own field wins over the location text, so an Anthropic role whose Location Type is On-Site stays false even when its location reads "Remote-Friendly". Boards with no workplace field fall back to the location text
workplace_typehybridremote, hybrid, onsite or null, as the ATS's own workplace field states it
employment_typeFull-timeas the ATS words it. Personio combines contract type and schedule (Internship, full-time, Working student, part-time; permanent roles show only the schedule, Full-time)
salary_min / salary_max115000 / 150000numbers in salary_currency; null if not published. A single stated figure fills both ($10,000/month); From SGD 245,000 fills only salary_min. When a Greenhouse job lists several regional ranges, these come from the range whose title names the job's (first-listed) location, including sentence titles such as "...in the locations of Hawaii, Washington DC, Texas, Colorado is:"; else the first range. For an Ashby sales role that publishes on-target earnings, these are the OTE (base + commission) figures and salary_text starts with OTE
salary_currencyUSDISO code. In posting text a currency code or symbol is required. A code next to the figure wins ($8,500 SGD/month is SGD). A bare $ is read from the job's location: the local currency when every listed location is in one of Canada, Australia, New Zealand, Singapore, Hong Kong or Mexico ($17.95/hour in Toronto, ON, Canada is CAD). Canada is recognised by its name, a province, a major city (Toronto, Montreal, Calgary, Ottawa, Edmonton, Winnipeg, or Vancouver unless it's in Washington state) or a CAN: prefix. Otherwise it's USD (US, mixed, remote-only or other unrecognised locations)
salary_intervalyearyear, month, week, day, hour. When the source states no interval, a figure of 20,000 or more in USD, EUR, GBP, CAD, AUD, NZD or CHF is taken as year (GitLab's Greenhouse ranges); otherwise null. salary_text keeps the source wording and doesn't add "per year" in that case
salary_textUSD 115,000-150,000 (United States Salary Range)human-readable range(s), all regional ranges included, each labelled with its place list. Starts with OTE when an Ashby role publishes on-target earnings (base + commission). SmartRecruiters custom-field pay keeps the company's own wording (118500 - 164000 GBP Annual), even when no figure in it can be read. Pay read from the posting text is formatted like USD 60,000-97,000 per year, with up to 3 distinct ranges ;-separated (the first one listed fills the numbers, whichever region the job is in) and OTE in front of on-target earnings
salary_sourceatsats: the ATS's own pay field (Greenhouse ranges, Lever salaryRange, Ashby compensation, Recruitee salary, SmartRecruiters custom field or compensation). description: read from the posting text because the ATS field was empty. null: no pay found. Text parsing is conservative: it needs a currency, a pay word such as salary/pay/compensation/OTE/rate close before the figure, and skips bonuses, stipends, budgets and company money figures
posted_at2026-09-24T16:03:09ZUTC. Publish time for Greenhouse, Ashby, SmartRecruiters and Recruitee, publish date for Workable (2026-09-24), creation time for Lever and Personio
job_urlhttps://job-boards.greenhouse.io/gitlab/jobs/8821526002posting page. When Greenhouse or Ashby omit it, it is built from the board token and job id; on the other ATSs it can be null if the upstream item has none (still a delivered, billed row)
apply_url…/applicationapplication form (same as job_url where the ATS has no separate form)
descriptionnullplain text, only with includeDescription
diff_statusnewnew, unchanged, closed in diff mode, else null
statusopenjob: open/closed; company: see above
open_jobs_total319company rows: open roles on the board, before filters. Never less than the roles actually read (a missing or junk SmartRecruiters totalFound doesn't produce "0 open roles; 1 returned")
jobs_returned20company rows: job records delivered for that company
messageLever board 'palantir': 319 open role(s); 20 returned…company rows: plain-English explanation
inputlever:palantirthe input entry this row came from
fetched_at2026-09-25T01:43:16Zrun time (UTC)

Real output from the default input {} (local apify run -i '{}', fetched 2026-09-25 01:43 UTC, about 10 s; the GitLab row's salary_interval is shown as the current version fills it). These are three of the 63 rows (60 jobs + 3 company rows). The Palantir row's pay comes from its posting text, because Lever's pay field is empty on that board:

[
{
"record_type": "job", "company": "GitLab", "company_slug": "gitlab", "ats": "greenhouse",
"job_id": "8821526002",
"title": "Intermediate Security Analyst, Vulnerability Operations (North America)",
"department": "Product Security",
"location": "Remote, Canada; Remote, United States",
"all_locations": "Remote, Canada; Remote, United States",
"remote": true, "workplace_type": "remote", "employment_type": null,
"salary_min": 115000, "salary_max": 150000, "salary_currency": "USD", "salary_interval": "year",
"salary_text": "USD 115,000-150,000 (United States Salary Range)", "salary_source": "ats",
"posted_at": "2026-09-24T16:03:09Z",
"job_url": "https://job-boards.greenhouse.io/gitlab/jobs/8821526002",
"apply_url": "https://job-boards.greenhouse.io/gitlab/jobs/8821526002",
"description": null, "diff_status": null, "status": "open",
"open_jobs_total": null, "jobs_returned": null, "message": null,
"input": "gitlab", "fetched_at": "2026-09-25T01:43:16Z"
},
{
"record_type": "job", "company": "palantir", "company_slug": "palantir", "ats": "lever",
"job_id": "802add74-04cf-479c-9479-ff3043940e29",
"title": "Deployment Strategist - US Government",
"department": "Echo",
"location": "Kitsap, WA",
"all_locations": "Kitsap, WA",
"remote": false, "workplace_type": "onsite", "employment_type": "Full-time",
"salary_min": 110000, "salary_max": 170000, "salary_currency": "USD", "salary_interval": "year",
"salary_text": "USD 110,000-170,000 per year", "salary_source": "description",
"posted_at": "2026-09-24T14:53:54Z",
"job_url": "https://jobs.lever.co/palantir/802add74-04cf-479c-9479-ff3043940e29",
"apply_url": "https://jobs.lever.co/palantir/802add74-04cf-479c-9479-ff3043940e29/apply",
"description": null, "diff_status": null, "status": "open",
"open_jobs_total": null, "jobs_returned": null, "message": null,
"input": "lever:palantir", "fetched_at": "2026-09-25T01:43:16Z"
},
{
"record_type": "company", "company": "openai", "company_slug": "openai", "ats": "ashby",
"job_id": null, "title": null, "department": null, "location": null, "all_locations": null,
"remote": null, "workplace_type": null, "employment_type": null,
"salary_min": null, "salary_max": null, "salary_currency": null, "salary_interval": null,
"salary_text": null, "salary_source": null, "posted_at": null, "job_url": null, "apply_url": null,
"description": null, "diff_status": null, "status": "ok",
"open_jobs_total": 830, "jobs_returned": 20,
"message": "Ashby board 'openai': 830 open role(s); 20 returned (newest first, maxJobsPerCompany=20).",
"input": "https://jobs.ashbyhq.com/openai", "fetched_at": "2026-09-25T01:43:16Z"
}
]

A plain name that only matches an untrusted board gives a free row like this one, from a real run with Microsoft (other keys null): {"record_type": "company", "status": "unconfirmed", "input": "Microsoft", "ats": "workable", "company": "Microsoft", "company_slug": "microsoft", "open_jobs_total": 0, "jobs_returned": 0, "message": "Found Workable board 'microsoft' (Microsoft) for 'Microsoft', but it has no open roles, so it isn't confirmed as the company you meant: no job rows, not charged. If it is the right board, pass 'workable:microsoft' (or its careers URL). Large employers often use Workday, iCIMS, Taleo or SuccessFactors, which this actor doesn't cover.", ...}.

An unknown token gives a free not_found row. Its message lists the ATSs and tokens tried.

The run's key-value store also gets an OUTPUT record with a per-company summary: input, ATS, token, status, open roles and roles returned.

Diff mode (new / closed since last run)

With diffMode: true the actor saves each company's open role IDs in a named key-value store (ats-jobs-api-diff) in your Apify account. The state is keyed by a hash of your companies and filters, or by diffKey if you set one. Each company gets its own record (<diffKey>--greenhouse.gitlab), read and saved while that company is processed, plus a small index record named after the key. So memory doesn't grow with the size of the watchlist, and a run stopped part-way keeps the state of the companies it finished. On the next run with the same key:

  • roles not seen before get diff_status: "new".
  • roles still open get "unchanged". They are returned only when onlyChanges is false.
  • roles that disappeared get a free row with status: "closed", diff_status: "closed", plus the title and URL remembered from the last run.

The first run for a company records all its current roles as the baseline and returns them flagged new, up to maxJobsPerCompany. After that you only pay for what changed. On later runs, a new role that isn't returned because of maxJobsPerCompany, maxRecords or your charge limit is not marked as seen: the next run reports it as new, and the company row's message says how many were held back. (If the first run itself is cut short by maxRecords or the charge limit, only the roles actually returned are recorded, and the rest come back as new next time.) A company that errors or isn't found keeps its previous state, so an outage never shows up as a wave of "closed" roles. A board that a previous run confirmed stays trusted even when it empties out, so its last roles are reported as closed. If a company's state can't be read or saved (a key-value store error, or a record over 8 MB even with titles and URLs dropped), its company row says so, the run's status message and the OUTPUT record (diff_state_problems) list it, and the next run may report (and charge) some of its roles as new again. A record over 8 MB with titles and URLs is saved with job ids only, and the company row says its later closed rows will carry just the id. Schedule it daily (Apify Schedules) for a hiring-signal feed.

Pricing

Pay per event. No subscription, no rental.

EventPriceWhen
actor-start$0.005once per run, when the first job board answers
company-resolved$0.002per company whose board was found and confirmed. It is also charged when your titleKeywords, locationKeywords, remoteOnly or diff mode leave 0 of its roles to return (the board was still checked, and its company row says how many roles it has), and when the board has 0 open roles if you named the ATS or diff mode confirmed the board earlier. When the company has job rows to deliver, it is only charged if your remaining budget also covers at least one of them, and only after its first rows are stored
job-record$0.0015per open-role row delivered
company summary rows, unconfirmed / not_found / error / duplicate / not_processed / invalid rows, diff closed rows, new/unchanged flagsfree

Worked examples:

RunCost
Default demo: 3 companies × 20 newest roles$0.005 + 3 × $0.002 + 60 × $0.0015 = $0.101
One big board, every role (OpenAI on Ashby, 829 roles on 2026-09-24)$0.005 + $0.002 + 829 × $0.0015 = $1.25
50-company watchlist, daily diff, ~30 new roles/day$0.005 + 50 × $0.002 + 30 × $0.0015 = $0.15/day (≈ $4.50/month)
5 typo'd tokens, or 5 big brands that only have empty squatted accounts on these ATSs$0.005 (the actor-start fee only)
2 companies with a title filter that matches nothing$0.005 + 2 × $0.002 = $0.009 (no job rows)
companies: [] or only blank entries$0 (one free explanatory row; nothing is fetched)
The same board given twice (gitlab and greenhouse:gitlab), 5 roles$0.005 + $0.002 + 5 × $0.0015 = $0.0145 (the second entry is a free duplicate row)

Set maxRecords or the run's maximum charge to cap spend. When the limit is reached the run stops cleanly. Companies it didn't reach get a free not_processed row, and nothing is charged for them.

FAQ

Where do I find a company's board token?

It's in the careers URL: boards.greenhouse.io/<token>, jobs.lever.co/<token>, jobs.ashbyhq.com/<token>, jobs.smartrecruiters.com/<token>, apply.workable.com/<token>, <token>.recruitee.com, <token>.jobs.personio.de. You can also paste the whole URL.

Can I just pass company names?

Yes. A plain name (Scale AI) is turned into token guesses (scaleai, scale-ai), and each board found is checked against the company name its ATS reports. Name lookups are best effort: a company whose token differs from its name won't be found, and one whose board sits on an ATS that doesn't report a company name (Lever, Ashby, Personio) is matched on the token alone. For monitoring, pin each company with a prefix or careers URL.

Why is a company "not_found" or "unconfirmed" when it clearly has jobs?

It probably uses an ATS this actor doesn't cover (Workday, iCIMS, Taleo, SuccessFactors…). Many large employers do, and their names on Workable or Recruitee are often empty or sample accounts, which come back as unconfirmed. It could also be that its board token differs from its name, or that it has turned off its public feed (some Personio companies do). Both rows are free and say what was tried.

Why are some salaries empty?

Pay is filled from the ATS's own pay field first: Greenhouse pay-transparency ranges, Lever salary ranges, Ashby compensation, Recruitee salary, and on SmartRecruiters a salary custom field on the posting list (Wise: "Job Ad Salary Range", 118500 - 164000 GBP Annual; AbbVie: "Salary Min"/"Salary Max") or, with includeDescription, each posting's structured compensation. Those rows have salary_source: "ats".

Many companies write pay only in the posting text instead (Palantir on Lever, Notion on Ashby, some Stripe roles on Greenhouse). When the pay field is empty, the actor reads a range stated there, such as "The estimated salary range for this position is $60,000 - $97,000/year", and marks the row salary_source: "description". Lever, Ashby, Recruitee and Personio always send the text, so this works on every run. Greenhouse, Workable and SmartRecruiters only send it with includeDescription on. On 2026-09-25 this filled pay for 238 of Palantir's 319 roles and 92 of Notion's 130.

The text reader is deliberately conservative. It needs a currency and a pay word (salary, pay, compensation, wage, OTE, rate, Gehalt, salaire and similar) close before the figure. It skips bonuses, stipends, budgets and company money figures, and it skips malformed text such as $184,050 $262,928. So some roles whose text mentions pay stay null. Text that lists one range per region as bullet points under a line such as "The expected base salary range for this role in:" is read too: each bullet's range goes into salary_text, and the first bullet fills the numbers. A bare $ is USD unless a currency code sits next to it or every location of the job is in Canada, Australia, New Zealand, Singapore, Hong Kong or Mexico, where it becomes that country's currency. Canada is recognised by its name, its provinces, its largest cities or a CAN: prefix; a location the actor doesn't recognise leaves a bare $ as USD. Placeholder ranges that some Greenhouse boards leave at the form minimum (USD 1-2 per year) are ignored. Greenhouse ranges often don't state an interval. A figure of 20,000 or more in USD, EUR, GBP, CAD, AUD, NZD or CHF is then taken as annual; a smaller one, or one in another currency, leaves salary_interval null.

How fresh is the data?

Live. Every run reads the boards at run time. posted_at is the publish time where the ATS gives one, and the creation time for Lever and Personio.

Does it get every job or only 20?

maxJobsPerCompany defaults to 20 (newest first) to keep default runs cheap. Set it to 0 for every open role. The company row's open_jobs_total always shows the full count.

Is the SmartRecruiters "not_found" reliable?

SmartRecruiters returns the same empty answer for an unknown company ID and for a real company with no open roles. So those companies are reported not_found (free) with a note explaining this.

Are descriptions included?

Only with includeDescription: true. They come back as plain text (HTML stripped), trimmed to descriptionMaxChars (3,000 by default, 0 = full). They're off by default because they make boards large: with descriptions, SpaceX's Greenhouse board is 29 MB and Anduril's is 42 MB. Those two are over the 25 MB per-response cap, so a Greenhouse or Workable board that is too large with descriptions is read without them: its roles come back with description: null, and the company row's message says so. SmartRecruiters is the exception to "one request per company": each description there is one extra request (about 4 per second), so keep maxJobsPerCompany modest when you ask for SmartRecruiters descriptions. A description that can't be fetched comes back as null. Personio's English feed leaves out descriptions written only in German, so those are read from the board's default-language feed (one extra request per Personio board). A posting with no description in either feed stays null, and the company row's message says how many.

Use with AI agents / MCP

This actor is built for agents. It has a flat, stable schema, lenient inputs and plain-English message fields. It never fails a run over one bad company or one bad upstream response, and it doesn't charge for boards it can't confirm. Connect the Apify MCP server (https://mcp.apify.com) to Claude, ChatGPT, Cursor or any MCP client, add this actor as a tool, then ask things like:

  • "What is OpenAI hiring for in London? (board: ashby:openai)"
  • "Which of gitlab, stripe and lever:palantir posted remote engineering roles this week?"
  • "Track these 20 companies daily and tell me about new sales roles."

The agent fills companies, titleKeywords, locationKeywords, remoteOnly or diffMode itself. locationKeywords understands common spellings (UK finds "London, United Kingdom", US finds "Austin, TX"). Costs are stated in the input descriptions, so the agent can budget: $0.005 per run, $0.002 per confirmed company (even when its filters match nothing), $0.0015 per job row. Use salary_source to tell pay from the ATS's field (ats) from pay read out of the posting text (description). Agents should read the company rows: unconfirmed means "not verified, ask the user for the careers URL", and a Caution note in message means the board's name only partly matched. Keep includeDescription off unless the agent needs the full text, which keeps responses small.

Data comes from each vendor's documented public job-board endpoint: the Greenhouse Job Board API, Lever Postings API, Ashby Job Postings API, SmartRecruiters Posting API, Workable's public accounts endpoint, the Recruitee Careers Site API and the Personio XML feed. These are the unauthenticated endpoints companies use to publish their own openings. Requests identify themselves with an honest User-Agent, are paced per host, and back off on HTTP 429/5xx, honouring Retry-After. The actor only returns what companies have chosen to publish on their public career boards. It doesn't collect applicant or employee data, and it strips internal fields such as per-job recruiter mailboxes.

Unofficial — not affiliated with, endorsed by or sponsored by Greenhouse, Lever, Ashby, SmartRecruiters, Workable, Recruitee (Tellent) or Personio. Company names belong to their owners.

Limits: 500 companies per run, 10,000 roles per board (the first 10,000 the board lists; open_jobs_total still shows the full count and the company row notes the limit; the largest real board seen is SmartRecruiters BoschGroup with about 4,800), and 25 MB per board response. With descriptions on, Greenhouse boards can pass 25 MB (SpaceX 29 MB, Anduril 42 MB); those are read without descriptions, as above. Lever, Ashby and Recruitee always send descriptions and can't be read without them. OpenAI's Ashby board, one of the demo boards, is 14 MB at about 830 roles, so an Ashby board past roughly 1,500 roles would exceed the cap. A board over the cap gets a free error row saying it is too large, and retrying won't help. A Personio feed is also capped at 400,000 XML elements, which is about 10,000 positions. A run finishes and returns what it has before its timeout, even when a job board stops answering: no request is allowed to run past a margin before the timeout, and companies it didn't reach get free not_processed rows. With SmartRecruiters descriptions on, roles whose details weren't fetched before the timeout are not returned or charged, and the company row says so. A posting a board lists twice is returned and charged once.

Memory, measured locally as peak working set including the Python runtime (about 75 MB on its own): the default demo {} peaks at about 120 MB, because OpenAI's 14 MB board is one of its three. The largest real boards (OpenAI, Palantir, Stripe and Airbnb, all with full descriptions) peak at about 120 MB. SpaceX and Anduril with includeDescription (read without descriptions after the first response passes the cap) peak at about 125 MB. Diff mode adds almost nothing, because only one company's state is in memory at a time: 500 boards of 2,000 roles each peak at about 78 MB with or without diff mode. Synthetic boards near the 25 MB cap peak at about 150 MB (emoji-only or CJK text), about 210 MB for a mix of emoji and ASCII, and about 275 MB for the densest case, 570,000 tiny postings (of which only the first 10,000 are read). Synthetic Personio feeds near the caps peak at about 195 MB with descriptions on, including dense feeds that stop at the XML element cap. Those synthetic figures include a second copy of the board held by the test harness. The default 256 MB covers every real board measured; give a run 512 MB only if it targets a board near the 25 MB cap. At the 128 MB minimum, keep to boards of a few MB (not OpenAI).

Changelog

  • 0.1, pre-release fixes, round 5 (2026-09-25): A Greenhouse or Workable board that is over the 25 MB cap with descriptions (SpaceX, Anduril) is now read without them. Its rows come back with description: null and a note, instead of an error row. This also stops auto-detect from sending SpaceX to an empty Workable account. An error row for a board over the size cap now says retrying won't help, and auto-detect unconfirmed rows list the boards that couldn't be checked. Personio feeds are parsed incrementally and capped at 400,000 XML elements: a dense 25 MB feed went from about 520-680 MB peak to under 200 MB. salary_interval matches whole words, and a yearly word beats "day": a Greenhouse range titled "Dayton, OH" is no longer per day, and "per annum + 25 days holiday" is yearly. Posting text that lists one pay range per region as bullets (Xero) keeps every range, in order. A bare $ at a major Canadian city or a CAN: location is CAD. Ashby boards over the per-board cap keep the first 10,000 roles listed, as documented.
  • 0.1, pre-release fixes, round 4 (2026-09-25): Diff state is now stored per company (one key-value record each, plus a small index), so a large watchlist no longer holds every company's state in memory: 500 boards × 2,000 roles went from about 608 MB to about 78 MB peak. A company whose diff state can't be read or saved is named on its company row, in the status message and in OUTPUT.diff_state_problems, instead of only in the log. Boards are read up to 10,000 roles, which bounds memory on dense boards near the 25 MB cap. A bare $ in a job located only in Canada, Australia, New Zealand, Singapore, Hong Kong or Mexico now gets that country's currency (Equinox Toronto: CAD 17.95 per hour; Wise Singapore: SGD). Salary figures of 20,000+ in major currencies with no stated interval get salary_interval: "year" (GitLab's 86 Greenhouse ranges). SmartRecruiters open_jobs_total is never below the roles returned. Greenhouse and Ashby rows missing a URL get one built from the board token and job id. Personio permanent roles show Full-time / Part-time like the other ATSs. The input schema now enforces the 500-company limit.
  • 0.1, pre-release fixes (2026-09-25): Pay stated only in the posting text is now read when the ATS pay field is empty (Lever, Ashby, Recruitee and Personio always; Greenhouse, Workable and SmartRecruiters with includeDescription). The new salary_source field (ats / description) says where it came from. locationKeywords matches common spellings (UK = United Kingdom, US = United States plus City, ST, California = CA, about 40 countries). SmartRecruiters custom-field pay now reads single amounts, currency symbols and codes glued to numbers, and keeps unparsed values as salary_text. Greenhouse placeholder ranges (USD 1-2) are ignored. Repeated postings are returned and charged once, and SmartRecruiters paging stops on a repeated page. Non-ASCII and full-width names are transliterated (Über becomes uber). An empty or blank companies list gives a free explanatory row instead of the paid demo. An unreadable run input gives a free row. SmartRecruiters rows whose details weren't fetched before the timeout are no longer billed. Lower peak memory on emoji- or CJK-dense boards. The pricing docs now say that company-resolved is charged when filters match 0 roles.
  • 0.1 (2026-09-24): First release. Supports 7 ATSs with auto-detect and careers-URL parsing. Auto-detect checks the company name each ATS reports and returns free unconfirmed rows for empty, placeholder or other-company boards. Also includes salary normalisation, workplace flags from each board's own fields, keyword and remote filters, optional plain-text descriptions, diff mode with free closed rows, free explanatory rows for bad or unknown input and duplicate boards, and pay-per-event pricing (actor-start, company-resolved, job-record) that never charges a company without room for its rows. Verification fixes before release: remote is also true when a listed location is explicitly remote (Ashby/Lever Hybrid roles with a Remote (US) location); SmartRecruiters pay from custom fields and posting compensation; Personio descriptions fall back to the default-language feed, and Personio internships and working-student roles keep that status; Greenhouse multi-range pay picks the range named after the job's location even when range titles are sentences; diff mode no longer loses new roles held back by maxJobsPerCompany/maxRecords; lone UTF-16 surrogates in upstream data or input can't fail a run or a batch; company-resolved is charged only after the company's first rows are stored; Retry-After up to 60 s is honoured in full; requests stop at the run's timeout margin; lower peak memory on large boards.