ATS Jobs API — Greenhouse, Lever, Ashby & more
Pricing
from $1.50 / 1,000 job records
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
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
19 hours ago
Last modified
Categories
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
| ATS | Pass any of these | Salary | Notes |
|---|---|---|---|
| Greenhouse | gitlab, greenhouse:gitlab, boards.greenhouse.io/gitlab, job-boards.greenhouse.io/gitlab | pay-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) |
| Lever | lever:palantir, jobs.lever.co/palantir | salaryRange, 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 |
| Ashby | ashby:openai, jobs.ashbyhq.com/openai | compensation tiers, else a range stated in the posting text (Notion) | |
| SmartRecruiters | sr:Equinox, jobs.smartrecruiters.com/Equinox | a 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 text | descriptions (and that pay) cost 1 extra request per job |
| Workable | workable:blueground, apply.workable.com/blueground | only a range stated in the posting text, with includeDescription | |
| Recruitee | recruitee:bunq, bunq.recruitee.com | salary field, else a range stated in the posting text | |
| Personio | personio:1komma5grad, 1komma5grad.jobs.personio.de | only 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
| Field | Type | Default | What it does |
|---|---|---|---|
companies | array of strings | demo: gitlab, lever:palantir, https://jobs.ashbyhq.com/openai | Board 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. |
ats | auto / greenhouse / lever / ashby / smartrecruiters / workable / recruitee / personio | auto | Which ATS plain tokens belong to. Prefixes and URLs always win. |
maxJobsPerCompany | integer | 20 | Newest roles returned per company. 0 = all. |
maxRecords | integer | 0 (no limit) | Cap on job records across the whole run, which caps the cost. |
titleKeywords | array | — | Keep jobs whose title contains any keyword (engineer, sales, data). |
locationKeywords | array | — | 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. |
remoteOnly | boolean | false | Keep only roles that can be done remotely (see remote below). |
includeDescription | boolean | false | Add the plain-text description (HTML stripped). On SmartRecruiters it also fills pay from each posting's published compensation (1 extra request per job). |
descriptionMaxChars | integer | 3000 | Trim descriptions to this length (0 = full text). |
diffMode | boolean | false | Remember open roles between runs and flag new / unchanged, plus a free closed row per role that disappeared. |
onlyChanges | boolean | true | In diff mode, return only new roles (plus closed rows) instead of every open role. |
diffKey | string | hash of companies + filters | Name 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:
jobwithstatus: "open": an open role (billed asjob-record).jobwithstatus: "closed": diff mode only, a role that was open last run and is gone now (free).company: one free summary row per company.messageexplains it in plain English.statusis 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.
| Field | Example | Notes |
|---|---|---|
record_type | job | job or company |
company | GitLab | display name when the ATS gives one, else the board token. Personio: the hiring legal entity (<subcompany>) |
company_slug | gitlab | the board token that answered |
ats | greenhouse | greenhouse, lever, ashby, smartrecruiters, workable, recruitee, personio |
job_id | 8821526002 | the ATS's own posting id (string) |
title | Intermediate Security Analyst… | |
department | Product Security | department, or team where that's all the ATS has |
location | Remote, Canada; Remote, United States | primary location as the company wrote it |
all_locations | Istanbul; Sofia | every listed location, ; -separated. Greenhouse adds its office entries only with includeDescription, because the plain job list doesn't carry them |
remote | true | true 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_type | hybrid | remote, hybrid, onsite or null, as the ATS's own workplace field states it |
employment_type | Full-time | as 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_max | 115000 / 150000 | numbers 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_currency | USD | ISO 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_interval | year | year, 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_text | USD 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_source | ats | ats: 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_at | 2026-09-24T16:03:09Z | UTC. Publish time for Greenhouse, Ashby, SmartRecruiters and Recruitee, publish date for Workable (2026-09-24), creation time for Lever and Personio |
job_url | https://job-boards.greenhouse.io/gitlab/jobs/8821526002 | posting 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 | …/application | application form (same as job_url where the ATS has no separate form) |
description | null | plain text, only with includeDescription |
diff_status | new | new, unchanged, closed in diff mode, else null |
status | open | job: open/closed; company: see above |
open_jobs_total | 319 | company 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_returned | 20 | company rows: job records delivered for that company |
message | Lever board 'palantir': 319 open role(s); 20 returned… | company rows: plain-English explanation |
input | lever:palantir | the input entry this row came from |
fetched_at | 2026-09-25T01:43:16Z | run 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 whenonlyChangesisfalse. - 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.
| Event | Price | When |
|---|---|---|
actor-start | $0.005 | once per run, when the first job board answers |
company-resolved | $0.002 | per 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.0015 | per open-role row delivered |
company summary rows, unconfirmed / not_found / error / duplicate / not_processed / invalid rows, diff closed rows, new/unchanged flags | free |
Worked examples:
| Run | Cost |
|---|---|
| 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.
Sources, limits and legal
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: nulland a note, instead of anerrorrow. This also stops auto-detect from sendingSpaceXto an empty Workable account. Anerrorrow for a board over the size cap now says retrying won't help, and auto-detectunconfirmedrows 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_intervalmatches 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 aCAN: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 getsalary_interval: "year"(GitLab's 86 Greenhouse ranges). SmartRecruitersopen_jobs_totalis 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 showFull-time/Part-timelike 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 newsalary_sourcefield (ats/description) says where it came from.locationKeywordsmatches common spellings (UK=United Kingdom,US=United StatesplusCity, 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 assalary_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 (Überbecomesuber). An empty or blankcompanieslist 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 thatcompany-resolvedis 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
unconfirmedrows 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:remoteis alsotruewhen a listed location is explicitly remote (Ashby/Lever Hybrid roles with aRemote (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 bymaxJobsPerCompany/maxRecords; lone UTF-16 surrogates in upstream data or input can't fail a run or a batch;company-resolvedis charged only after the company's first rows are stored;Retry-Afterup to 60 s is honoured in full; requests stop at the run's timeout margin; lower peak memory on large boards.