Many products need fresh job postings: job boards and aggregators, recruiting agencies watching target accounts, sales teams using hiring as a buying signal, market researchers counting roles by function.
The good news is that most tech companies publish their jobs through an applicant tracking system (ATS) with a public, documented job-board API. Greenhouse, Lever, Ashby, Workable, SmartRecruiters and Recruitee all have one. The bad news is that there are six of them, with six different JSON shapes, six ways of writing a location, salary fields in some and not others, and none of them tells you which ATS stripe.com uses.
This tutorial uses the Greenhouse, Lever & Ashby Jobs API – All ATS Job Boards, an Apify Actor our team at Ostrata built to put all six behind one input and one output schema. It also has an only-new mode: schedule it daily and each run returns only the postings it hasn't seen before.
What you can put in
The companies field takes one line per company, in any of these forms:
- a company domain:
stripe.com. The Actor reads the homepage and careers page (plain HTTP,robots.txtrespected, max 3 pages) to find the job-board link, and if that fails it tries the domain name as a board slug on each ATS API (ramp.combecomesashby:ramp) - a job-board URL:
jobs.lever.co/spotify,jobs.ashbyhq.com/ramp,boards.greenhouse.io/gitlab,apply.workable.com/huggingface,jobs.smartrecruiters.com/BoschGroup,bunq.recruitee.com - an
ats:slugpair:greenhouse:stripe,lever:qonto
Filters and options:
| Field | Default | What it does |
|---|---|---|
keywords / excludeKeywords
|
– | Keep / drop jobs whose title contains any of these words |
locations |
– | Keep jobs whose location or country contains any of these (Paris, FR, Remote) |
departments |
– | Department or team filter |
remoteOnly |
false |
Only jobs marked remote |
postedWithinDays |
0 |
0 = no limit |
onlyNew + stateStoreName
|
false, ats-jobs-state
|
Return only jobs not seen in previous runs |
maxJobsPerCompany |
500 |
Postings read per board, before filters |
includeDescription / includeDescriptionHtml
|
true / false
|
Plain-text and HTML descriptions |
useSeedList + seedListAts
|
false |
Add a built-in list of about 180 public boards, optionally limited to some ATS |
detectFromWebsite, probeApis
|
true |
The two domain-detection strategies above |
concurrency |
4 |
Companies in parallel (each ATS API is also rate-limited per host) |
Step 1: Console
- Open apify.com/ostrata/ats-jobs-api and click Try for free.
- Paste companies, one per line. Mixing domains, URLs and
ats:sluglines is fine. - Add title keywords or locations if you want, then Start.
- Export the dataset as JSON, CSV or Excel, or connect it to an integration.
Step 2: curl
curl -s -X POST \
"https://api.apify.com/v2/acts/ostrata~ats-jobs-api/run-sync-get-dataset-items?maxTotalChargeUsd=0.5" \
-H "Authorization: Bearer $APIFY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"companies": ["greenhouse:gitlab", "https://jobs.lever.co/spotify", "jobs.ashbyhq.com/ramp",
"apply.workable.com/huggingface", "jobs.smartrecruiters.com/Ubisoft2", "bunq.recruitee.com"],
"keywords": ["engineer", "développeur", "developer"],
"maxJobsPerCompany": 50
}'
Step 3: JavaScript (apify-client)
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('ostrata/ats-jobs-api').call(
{
companies: ['stripe.com', 'ramp.com', 'huggingface.co', 'qonto.com'],
locations: ['Paris', 'France', 'Remote'],
includeDescription: false,
},
{ maxTotalChargeUsd: 1 },
);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.table(items.map((j) => ({ company: j.company, ats: j.ats, title: j.title, location: j.location })));
Step 4: Python, with only-new mode (apify-client 3.x)
import os
from decimal import Decimal
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("ostrata/ats-jobs-api").call(
run_input={
"companies": ["greenhouse:gitlab", "workable:huggingface"],
"onlyNew": True,
"stateStoreName": "gitlab-hf-alerts", # one name per saved search
"includeDescription": False,
},
max_total_charge_usd=Decimal("1"),
)
for job in client.dataset(run.default_dataset_id).iterate_items():
print(job["company"], "|", job["title"], "|", job["url"])
How only-new mode works
With onlyNew: true, the Actor keeps the IDs of the jobs it has delivered in a named key-value store in your own Apify account (stateStoreName). On the next run it skips any job whose ID is already stored, so you only receive, and only pay for, new postings.
We tested it on the same two boards (with the state store named ats-test-state). The first run returned all 235 open jobs (229 GitLab, 6 Hugging Face) with isNew: null, because there was nothing to compare against yet. The second run saw the same 235 jobs, delivered 0 and charged nothing. Its SUMMARY record:
{
"inputs": 2,
"boardsFound": 2,
"jobsSeen": 235,
"jobsFiltered": 0,
"jobsAlreadySeen": 235,
"delivered": 0,
"onlyNew": true,
"stateStoreName": "ats-test-state"
}
From the second run on, every delivered job has isNew: true. To turn this into a daily alert, save the input as an Apify Task, add a Schedule (for example 0 8 * * *), and attach a webhook, a Slack or email integration, or have your backend read the dataset.
Two details worth knowing: jobs removed from your filters stay "unseen", so they show up as new if you widen the filters later. And the state only keeps jobs that are still online, so a posting that's taken down and republished with the same ID counts as new again.
Real output
One normalized row from our six-board test run (19 Oct 2026, description field omitted):
{
"id": "ashby:ramp:34413f8d-26bf-4bbc-8ade-eb309a0e2245",
"ats": "ashby",
"board": "ramp",
"company": "ramp",
"title": "Security Engineer, Cloud",
"department": "Engineering",
"team": "Backend",
"location": "New York, NY (HQ)",
"locations": ["New York, NY (HQ)", "Remote (Canada)", "Remote (US)", "Miami, FL"],
"country": "USA",
"workplaceType": "Hybrid",
"remote": true,
"employmentType": "FullTime",
"salary": { "min": 211400, "max": 290600, "currency": "USD", "interval": "1 YEAR", "summary": "$211.4K – $290.6K • Offers Equity" },
"postedAt": "2026-04-07T17:12:35.753Z",
"url": "https://jobs.ashbyhq.com/ramp/34413f8d-26bf-4bbc-8ade-eb309a0e2245",
"applyUrl": "https://jobs.ashbyhq.com/ramp/34413f8d-26bf-4bbc-8ade-eb309a0e2245/application",
"isNew": null,
"via": "input"
}
A Greenhouse row has the same keys. For GitLab, salary, employmentType and country were null, because that board doesn't publish them.
That curl run (six boards, title keywords, maxJobsPerCompany: 50) read 229 postings and delivered the 57 matching titles: 19 GitLab (Greenhouse), 14 Ramp (Ashby), 14 Spotify (Lever), 5 Hugging Face (Workable), 4 Ubisoft (SmartRecruiters) and 1 bunq (Recruitee). It took about 23 seconds locally.
The domain-detection test (stripe.com, ramp.com, huggingface.co, ostrata.io, qonto.com) found four boards. Hugging Face (Workable) and Qonto (Lever) came from links on their careers pages (via: "careers_page"), and Stripe (Greenhouse) and Ramp (Ashby) from probing the APIs (via: "api_probe"). ostrata.io has no public board: the run checked /, /careers and /jobs, then reported no_job_board_found and charged nothing.
Use it from an AI agent (MCP)
{
"mcpServers": {
"ats-jobs": {
"url": "https://mcp.apify.com?tools=ostrata/ats-jobs-api"
}
}
}
Sign in with OAuth on first use, or send Authorization: Bearer <APIFY_TOKEN>. Then ask your assistant something like "List open engineering roles in Paris or remote at stripe.com, ramp.com and qonto.com". The agent passes the domains, and the Actor finds each company's ATS on its own.
Pricing
Pay-per-event, as listed on the Apify Store on 19 Oct 2026:
- $0.003 per job returned in the dataset ($3 per 1,000).
- Not charged: jobs removed by your filters, jobs already seen in only-new mode, boards with no jobs, companies with no board found, errors.
- Standard Apify start event: $0.00005 per run.
The six-board example above cost 57 × $0.003 = $0.171. A daily only-new run on a stable list costs only the new postings, often a few cents. maxTotalChargeUsd stops the run cleanly at your budget. In our budget test, a cap that covered 10 jobs delivered exactly 10.
Honest limits
- Six ATS only. Workday, Taleo, Teamtailor, Welcome to the Jungle and in-house career sites aren't covered yet.
-
Company names: Lever and Ashby don't expose the company's display name, so
companyis the board slug (ramp,spotify). - Salary appears only when the ATS publishes it: mostly Ashby, some Lever and Recruitee boards.
-
Large boards:
maxJobsPerCompany(default 500) caps what's read per board. Stripe's Greenhouse board hit that cap in one of our tests, so raise it if you need everything. -
SmartRecruiters descriptions need one extra API call per job. Turn
includeDescriptionoff for very large boards. - Privacy: recruiter and hiring-manager names and contact emails, which some APIs include, are dropped.
Written by the Ostrata team (ostrata.io).
Top comments (0)