"They just opened 6 SDR roles" is one of the strongest timing signals in outbound. If a company is hiring for the team that would use your product, you have a reason to reach out now. Most teams get that signal from LinkedIn scrapes or expensive data vendors. But most tech companies already publish their entire job board as clean JSON through their applicant tracking system (ATS). This tutorial shows how to turn that into a company-level hiring-signal feed you can refresh every week.
The data source: public ATS job-board APIs
Career pages are usually powered by one of a handful of ATSs, and each exposes a public, unauthenticated endpoint meant for embedding job lists:
| ATS | Public endpoint pattern |
|---|---|
| Greenhouse | https://boards-api.greenhouse.io/v1/boards/{slug}/jobs?content=true |
| Lever | https://api.lever.co/v0/postings/{slug}?mode=json (EU: api.eu.lever.co) |
| Ashby | https://api.ashbyhq.com/posting-api/job-board/{slug}?includeCompensation=true |
| SmartRecruiters | https://api.smartrecruiters.com/v1/companies/{id}/postings |
These are the employer's system of record. Roles show up here first and disappear when they're filled, so the data is fresher and cleaner than most aggregators.
A 20-line DIY version (Greenhouse only)
import httpx, collections def greenhouse_signal(slug): jobs = httpx.get(f"https://boards-api.greenhouse.io/v1/boards/{slug}/jobs", params={"content": "true"}, timeout=30).json()["jobs"] by_dept = collections.Counter( (j.get("departments") or [{"name": "Other"}])[0]["name"] for j in jobs) sales = [j for j in jobs if any(k in j["title"].lower() for k in ("sdr", "bdr", "account executive", "sales"))] return {"company": slug, "open_roles": len(jobs), "sales_roles": len(sales), "top_departments": by_dept.most_common(5)} print(greenhouse_signal("stripe")) That works, but a real feed also needs:
- detection of which ATS a company uses,
- Lever's EU cluster and SmartRecruiters' paging,
- seniority classification,
- salary parsing,
- memory between runs, so you can say "+11 roles since last week".
That last part is what makes it a signal rather than just a list.
The hosted version: Hiring Signal Feed
Hiring Signal Feed is an Apify Actor that does all of the above. It returns one row per company, with:
-
open_roles,roles_by_department,roles_by_seniority,remote_roles -
new_roles_7dandnew_roles_30d -
open_roles_change,roles_added_since_last_runandroles_closed_since_last_run, from a snapshot it keeps for you -
salary_bandsand theatsthe company uses
Optionally, it also returns normalized job rows. Pricing is $0.01 per company row (or $0.0015 per job row), plus $0.02 per run.
pip install apify-client pandas import os, pandas as pd from apify_client import ApifyClient client = ApifyClient(os.environ["APIFY_TOKEN"]) accounts = ["greenhouse:stripe", "greenhouse:discord", "ashby:ramp", "https://jobs.lever.co/spotify", "notion"] # bare names are auto-detected run = client.actor("eligible_fame/hiring-signal-feed").call(run_input={ "companies": accounts, "outputMode": "companies", "titleKeywords": ["sales", "sdr", "bdr", "account executive"], "snapshotStoreName": "my-sales-hiring-watch", }) df = pd.DataFrame(client.dataset(run["defaultDatasetId"]).iterate_items()) df = df[df.row_type == "company"] print(df[["company", "ats", "open_roles", "new_roles_30d", "roles_added_since_last_run"]] .sort_values("new_roles_30d", ascending=False)) Because titleKeywords is set, every count is computed only on sales roles. So new_roles_30d = 9 means nine new sales jobs this month.
Score and route accounts
df["score"] = (df.new_roles_30d.fillna(0) * 2 + df.roles_added_since_last_run.fillna(0) * 3 + df.open_roles.fillna(0) * 0.2) hot = df[df.score >= 10].sort_values("score", ascending=False) hot.to_csv("hot_accounts.csv", index=False) Get the actual postings for personalization
Run again with outputMode: "jobs" for just the hot accounts. Each job row includes title, department, location, posted_at, salary and the posting url. That's ideal for a first line like "Saw you're hiring a Founding AE in Austin…".
Make it weekly
- Save the input as an Apify Task and attach a Schedule (for example, Mondays at 07:00).
- Keep
snapshotStoreNamethe same, so each run diffs against the last one. - Pipe results to your CRM:
- In Clay, use an HTTP API column that calls
run-sync-get-dataset-items. - In n8n, Make or Zapier, use the Apify app (Run Actor, then Get dataset items, then upsert into HubSpot or Sheets).
- In Clay, use an HTTP API column that calls
cURL for the HTTP column:
curl -X POST "https://api.apify.com/v2/acts/eligible_fame~hiring-signal-feed/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"companies":["{{domain_or_slug}}"],"outputMode":"companies"}' Signals beyond sales hiring
The same feed answers other questions. A few common ones:
- Selling data tools: filter for
data engineerandanalytics. - Selling security: watch for
securityandGRCroles. - Investors: track
open_roles_change_pctacross a portfolio for early momentum or slowdowns. - Recruiters: look for companies whose
new_roles_7djumps in your niche.
A sudden spike in manager and lead/principal seniority often means a new team is being built. That's usually a better trigger than one-off backfills.
Limitations to know
- It only covers companies that use these four ATSs. That's most venture-backed tech, plus many enterprises on SmartRecruiters, but not every employer. If a company isn't found, you get a free
errorrow. -
posted_atsemantics differ between ATSs. Some companies repost roles, so the snapshot diff (roles_added_since_last_run) is the most reliable "new" signal. - Salary only appears where the employer publishes it.
Wrap-up
Hiring data is the cheapest high-intent signal there is, because companies publish it themselves. Whether you write the 20-line script or use Hiring Signal Feed, the key is tracking change over time at the company level, not just collecting rows.

