Disclosure: I'm the maker of the ATS Jobs Scraper on the Apify Store used below. You can call each ATS's public job-board API yourself; this guide shows the workflow with ours, with real output.
A company that is hiring five account executives this month is about to spend money on everything those reps need: a CRM seat, a dialer, data, onboarding, recruiters. That makes open job postings one of the most honest buying signals there is. They're public, dated, and a company doesn't post a role it has no budget for.
This guide turns that into a hiring-intent lead list: take a list of target accounts, find which of them are hiring for a role (here, SDRs, BDRs and account executives) in the last 30 days, count the openings and rank the companies. The same pattern works for recruiters ("who is hiring data engineers?") and for anyone who sells to a specific team.
If you only need raw jobs from a few boards in one schema, see Scrape Greenhouse, Lever and Ashby jobs with Python. This guide is about the company-level signal.
Everything below was run on 29 October 2026 and the numbers are the real output.
Why ATS boards, not LinkedIn
Most software companies publish their openings through an applicant tracking system: Greenhouse, Lever, Ashby, Workable or SmartRecruiters. Those boards have public JSON APIs, so you get the exact title, department, location and the date the role was first posted, without logging in or scraping a social network. The catch is that each ATS has its own URL scheme and JSON shape, and you have to know which one a company uses. The Actor hides that: give it a bare company slug such as okta and it finds the board.
1. Define the signal
Three choices make or break a hiring-intent list:
- Roles that map to your buyer. For a sales tool:
sales development,business development,sdr,bdr,account executive. Keywords match the job title, case-insensitive. UseexcludeKeywordsto drop noise such asintern. - Freshness.
postedWithinDays: 30keeps only roles first published in the last 30 days. An opening from last week is intent; one that has been up since spring is a backlog. - Accounts. Your target list, as slugs or careers-page links. Unknown companies come back as a free
NOT_FOUNDrow, so a messy CRM export won't crash the run.
2. The script
No SDK, just requests. It fetches the matching jobs in one call, groups them by company, and writes a ranked CSV:
# pip install requests
import csv, os, re, requests
from collections import defaultdict
from datetime import datetime, timezone
URL = "https://api.apify.com/v2/acts/scrapemole~ats-jobs-scraper/run-sync-get-dataset-items"
HEADERS = {"Authorization": f"Bearer {os.environ['APIFY_TOKEN']}"}
# Your target accounts: bare slugs or careers-page links.
TARGETS = ["stripe", "airbnb", "datadog", "gitlab", "figma", "cloudflare", "mongodb",
"elastic", "okta", "twilio", "dropbox", "asana", "notion", "ramp", "linear",
"vercel", "deel", "brex", "gusto", "intercom", "airtable", "retool",
"posthog", "supabase", "clickhouse"]
payload = {
"companies": TARGETS,
"keywords": ["sales development", "business development", "sdr", "bdr", "account executive"],
"excludeKeywords": ["intern"],
"postedWithinDays": 30, # only fresh openings count as intent
"maxJobsPerCompany": 100,
"maxItems": 300, # hard cap on what one run can cost
"includeDescription": False,
}
rows = requests.post(URL, json=payload, headers=HEADERS, timeout=300).json()
jobs = [r for r in rows if r["status"] == "ok"]
quiet = [r for r in rows if r["status"] != "ok"]
SDR = re.compile(r"\b(sdr|bdr|sales development|business development)\b", re.I)
now = datetime.now(timezone.utc)
by_company = defaultdict(list)
for j in jobs:
by_company[j["companyName"]].append(j)
leads = []
for name, js in by_company.items():
newest = max(j["postedAt"] for j in js if j.get("postedAt"))
leads.append({
"company": name,
"ats": js[0]["ats"],
"open_sales_roles_30d": len(js),
"sdr_bdr": sum(bool(SDR.search(j["title"])) for j in js),
"account_exec": sum("account executive" in j["title"].lower() for j in js),
"days_since_newest": (now - datetime.fromisoformat(newest.replace("Z", "+00:00"))).days,
"with_salary": sum(j.get("salaryMin") is not None for j in js),
"sample_title": sorted(js, key=lambda j: j.get("postedAt") or "", reverse=True)[0]["title"],
"sample_url": sorted(js, key=lambda j: j.get("postedAt") or "", reverse=True)[0]["url"],
})
leads.sort(key=lambda l: (-l["open_sales_roles_30d"], l["days_since_newest"]))
with open("hiring_intent_leads.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(leads[0]))
w.writeheader()
w.writerows(leads)
print(f"{len(jobs)} sales jobs posted in the last 30 days at {len(leads)} of {len(TARGETS)} companies")
for l in leads[:10]:
print(f'{l["company"]:<12} {l["open_sales_roles_30d"]:>3} roles SDR/BDR {l["sdr_bdr"]:>2} AE {l["account_exec"]:>2} newest {l["days_since_newest"]}d ago')
for r in quiet:
print("no signal:", r["company"], r["errorCode"])
3. The output
The whole run took about 5 seconds:
221 sales jobs posted in the last 30 days at 19 of 25 companies
Stripe 52 roles SDR/BDR 5 AE 47 newest 0d ago
Okta 37 roles SDR/BDR 2 AE 35 newest 1d ago
Cloudflare 32 roles SDR/BDR 3 AE 29 newest 0d ago
GitLab 28 roles SDR/BDR 3 AE 25 newest 1d ago
Elastic 22 roles SDR/BDR 2 AE 20 newest 1d ago
Datadog 9 roles SDR/BDR 2 AE 7 newest 7d ago
Twilio 8 roles SDR/BDR 1 AE 7 newest 0d ago
Brex 8 roles SDR/BDR 0 AE 8 newest 2d ago
MongoDB 7 roles SDR/BDR 1 AE 6 newest 1d ago
Clickhouse 3 roles SDR/BDR 0 AE 3 newest 8d ago
no signal: ramp NO_DATA
no signal: airtable NO_DATA
no signal: deel NO_DATA
no signal: supabase NO_DATA
no signal: posthog NO_DATA
no signal: retool NOT_FOUND
The top of hiring_intent_leads.csv:
company,ats,open_sales_roles_30d,sdr_bdr,account_exec,days_since_newest,with_salary,sample_title,sample_url
Stripe,greenhouse,52,5,47,0,3,"Account Executive, SMB, Velocity Hunter, Spanish speaking",https://stripe.com/jobs/search?gh_jid=8318707
Okta,greenhouse,37,2,35,1,24,"Enterprise Account Executive, Okta",https://www.okta.com/company/careers/opportunity/8276038?gh_jid=8276038
Cloudflare,greenhouse,32,3,29,0,18,Senior Territory Account Executive - Shanghai ,https://boards.greenhouse.io/cloudflare/jobs/8323918?gh_jid=8323918
Notion,ashby,1,1,0,15,1,"Manager, Sales Development",https://jobs.ashbyhq.com/notion/d02d5ff4-fea3-4be8-b142-cb120e966399
How to read it:
- Volume is a tier, not a score. Stripe, Okta, Cloudflare, GitLab and Elastic are each hiring 20+ sellers right now: they are scaling a sales org and buy at that scale. A company with one or two openings is backfilling.
- The SDR/AE mix tells you the motion. Lots of account executives and few SDRs (Brex: 8 AEs, 0 SDRs) usually means inbound or partner-led pipeline. A new Manager, Sales Development role (Notion) often means an SDR team is about to be built, which is exactly when tooling gets chosen.
NO_DATAis a signal too. Ramp, Airtable, PostHog and Supabase have open roles, but none in sales in the last 30 days, and Deel's board had no open jobs. Those rows cost nothing and tell you who not to pitch a sales tool to this month.NOT_FOUNDmeans "look it up". Retool's careers page isn't on one of the five supported boards under that slug, so paste its careers link instead or drop it.- Names can differ from slugs.
intercomcame back as Fin, the company's current name on its Greenhouse board. Join on the input slug, not the display name, when you merge this back into a CRM. - Salary coverage varies. Okta published pay on 24 of 37 roles, Stripe on 3 of 52, which mostly follows the pay-transparency rules where the role is based.
4. Same accounts, different buyer
Change only the keywords and the same 25 accounts give a completely different list. With ["data engineer", "analytics engineer"] (no other changes) the run returned 7 roles at 6 companies: Elastic 2, and one each at GitLab, Airbnb, Asana, Supabase and Notion. Stripe, top of the sales list with 52 openings, had none. That is why a hiring-intent list should always be built for one buyer persona at a time.
Other useful filters: departments (e.g. ["Sales"], matched against the ATS's department or team), locations (e.g. ["United States", "London"]) to keep your territory only, and remoteOnly.
5. Keep it fresh
Run the same input weekly and add "onlyNew": true with a stateKey such as "sales-intent". The Actor remembers which jobs it already returned and only delivers (and charges for) new ones, so each week's run lists the companies that opened new sales roles since last week, which is the moment to reach out. The Greenhouse, Lever and Ashby guide shows the only-new mode step by step. Put it in an Apify schedule, or call it from cron, n8n, Make or Clay, and push the ranked rows to your CRM or a Slack channel.
What it costs
You pay per job delivered: $0.0015 on Apify's free plan, down to $0.0008 on higher plans. The sales run above delivered 221 jobs, so about $0.33 on the free plan; the data-engineer variant delivered 7 jobs, about a cent. There's no start fee and no proxy cost, because these are public job-board APIs. NO_DATA and NOT_FOUND rows are free, and maxItems caps what a single run can cost.
Related
- ATS Jobs Scraper on Apify
- Scrape Greenhouse, Lever and Ashby jobs with Python, for raw job feeds, CSV export and alerts
- Google Jobs Scraper, for companies that don't use one of these ATSs
- All our Actors: apify.com/scrapemole
Questions, or a company whose board doesn't work? Open an issue on the Actor page or email us and I'll look at it.