Most company careers pages aren't really "pages". They're a front end on top of an applicant tracking system (ATS) such as Greenhouse, Lever, Ashby or Workday, and those systems publish public JSON job feeds that the careers page itself reads from.
That means you don't need to scrape HTML to watch a company's jobs. In this post:
- How to read one ATS feed yourself (free, no key).
- How to watch many companies across 8 ATS platforms in one call, and get a Slack message only when a new job appears.
The DIY way: one company on Greenhouse
Figma's jobs live on Greenhouse under the board slug figma. The public feed:
import requests r = requests.get("https://boards-api.greenhouse.io/v1/boards/figma/jobs", timeout=20) for job in r.json()["jobs"]: print(job["title"], "|", job["location"]["name"], "|", job["absolute_url"]) Lever (https://api.lever.co/v0/postings/{company}?mode=json) and Ashby (https://api.ashbyhq.com/posting-api/job-board/{company}) have similar public endpoints.
The catch, once you want more than a couple of companies:
- Every ATS has a different schema (locations, salary, remote flags, dates all differ).
- You have to work out which ATS a company uses and what its slug is.
stripe.com/jobsdoesn't tell you. - Workday is a different beast entirely (paginated POST search per tenant/site).
- To build an alert, you need to remember what you've already seen.
One call for 8 ATS platforms
I packaged all of that into ATS Jobs API on Apify. It supports Greenhouse, Lever, Ashby, Workday, Workable, SmartRecruiters, Recruitee and Personio, auto-detects the ATS from a careers URL or company name, and returns one schema for all of them.
pip install apify-client requests export APIFY_TOKEN=... # Apify Console > Settings > API & Integrations import os from apify_client import ApifyClient client = ApifyClient(os.environ["APIFY_TOKEN"]) run = client.actor("olsols4/ats-jobs-api").call(run_input={ "companies": [ "https://stripe.com/jobs", # careers page: ATS is auto-detected "https://jobs.lever.co/palantir", # ATS board URL "greenhouse:figma", # ats:slug shorthand "ramp", # plain name: tries each ATS "https://nvidia.wd5.myworkdayjobs.com/NVIDIAExternalCareerSite", # Workday ], "titleKeywords": ["engineer", "data"], "excludeTitleKeywords": ["intern"], "locations": ["London", "United Kingdom", "Remote"], "includeDescription": False, # lighter, faster runs }) for job in client.dataset(run["defaultDatasetId"]).iterate_items(): print(f'{job["company"]:<12} {job["ats"]:<11} {job["title"]} | {job["location"]}') Every row has the same fields regardless of the source ATS: company, ats, jobId, title, department, team, location, remote, workplaceType, employmentType, salaryMin/salaryMax/salaryCurrency/salaryInterval (where the company publishes pay, which Ashby, Lever and Recruitee boards often do), postedAt, url, applyUrl and optionally description.
Turn it into an alert: only new jobs, posted to Slack
The input has an onlyNewJobs flag. With it on, the Actor remembers the jobs it has already returned for that exact company list and only outputs postings it hasn't seen before. The first run returns everything (that's your baseline); every run after that returns only the new ones, and you only pay for those.
Create a Slack incoming webhook (Slack > Apps > Incoming Webhooks), then:
# job_alert.py import os import requests from apify_client import ApifyClient client = ApifyClient(os.environ["APIFY_TOKEN"]) SLACK_WEBHOOK = os.environ["SLACK_WEBHOOK_URL"] COMPANIES = ["greenhouse:figma", "https://jobs.lever.co/palantir", "ramp", "https://stripe.com/jobs"] run = client.actor("olsols4/ats-jobs-api").call(run_input={ "companies": COMPANIES, "titleKeywords": ["engineer"], "locations": ["London", "Remote"], "includeDescription": False, "onlyNewJobs": True, }) new_jobs = list(client.dataset(run["defaultDatasetId"]).iterate_items()) if not new_jobs: raise SystemExit("No new jobs today.") lines = [f"*{len(new_jobs)} new job(s)*"] for j in new_jobs[:40]: # keep the Slack message a sensible size pay = "" if j.get("salaryMin"): pay = f' · {j["salaryCurrency"]} {j["salaryMin"]:,.0f}–{(j.get("salaryMax") or j["salaryMin"]):,.0f}' lines.append(f'• <{j["url"]}|{j["title"]}> at {j["company"]} ({j["location"]}){pay}') requests.post(SLACK_WEBHOOK, json={"text": "\n".join(lines)}, timeout=20) print(f"Posted {len(new_jobs)} jobs") Run it once a day from cron, a GitHub Actions schedule, or anything else:
0 8 * * * APIFY_TOKEN=... SLACK_WEBHOOK_URL=... python3 /path/to/job_alert.py Keep the COMPANIES list identical between runs; the "seen" memory is keyed on it, so changing the list starts a fresh baseline.
No-code alternative (n8n, Make, Zapier)
If you'd rather not run a script, any tool with an HTTP node can call the Actor's synchronous endpoint, which runs it and returns the rows in one request:
POST https://api.apify.com/v2/acts/olsols4~ats-jobs-api/run-sync-get-dataset-items Authorization: Bearer <APIFY_TOKEN> Content-Type: application/json {"companies": ["greenhouse:figma", "ramp"], "onlyNewJobs": true, "includeDescription": false} In n8n that's a Schedule Trigger → HTTP Request (the call above) → IF (items exist) → Slack node. Alternatively schedule the Actor inside Apify itself and use its Slack or webhook integration.
Other things people use this for
- Sales signals: a company suddenly hiring three "RevOps" or "Data Engineer" roles is often about to buy tooling. Watch a list of target accounts with
titleKeywords. - Niche job boards: fill a board with postings straight from employers instead of reposted aggregator listings.
- Salary research: pull every role with published pay bands for a set of companies and compare.
Cost and limits
- $1 per 1,000 jobs. Companies with no matching jobs cost nothing, and in
onlyNewJobsmode you pay only for new postings, so a daily alert over a few dozen companies usually costs cents. - If a company uses an ATS that isn't supported yet (iCIMS, Taleo, SuccessFactors and so on), it returns nothing. A plain company name can also guess the wrong slug; pass the careers page or ATS board URL instead.
- Big Workday tenants can have thousands of roles: narrow them with
titleKeywords/locationsor lowermaxJobsPerCompany.
If your target company's careers site isn't picked up, open an issue on the Actor page with the URL and I'll look at adding it.
