"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.
Top comments (1)
The ATS boards are the cleanest version of this signal, agreed, and the snapshot diff is what turns a list into a feed. One trap from my side of it: I build outreach lists from career data and write a series about that here, and the
saleskeyword is the thing that bit me most often. As a substring it matches "Salesforce Developer", "Pre-Sales Engineer" and "Sales Enablement", so a company hiring three Salesforce admins reads as a growing sales team. Whole-word matching on the title plus a short exclude list (salesforce, enablement, engineer) fixed most of it; the ATSdepartmentsfield you already pull is the other cheap check, because the posting's department rarely lies about the function.The signal that pairs well with this one comes a bit later: once the posting closes, the hire shows up on a profile with a start date. Counting people at the company whose current position started this year, by function, tells you whether the roles were actually filled or quietly dropped, and it hands you the names for the first line.