DEV Community

member_924bafb1
member_924bafb1 Subscriber

Posted on

Build a Hiring-Signal Feed from Greenhouse, Lever, Ashby and SmartRecruiters (with Python)

"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"))
Enter fullscreen mode Exit fullscreen mode

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_7d and new_roles_30d
  • open_roles_change, roles_added_since_last_run and roles_closed_since_last_run, from a snapshot it keeps for you
  • salary_bands and the ats the 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
Enter fullscreen mode Exit fullscreen mode
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))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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

  1. Save the input as an Apify Task and attach a Schedule (for example, Mondays at 07:00).
  2. Keep snapshotStoreName the same, so each run diffs against the last one.
  3. 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).

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"}'
Enter fullscreen mode Exit fullscreen mode

Signals beyond sales hiring

The same feed answers other questions. A few common ones:

  • Selling data tools: filter for data engineer and analytics.
  • Selling security: watch for security and GRC roles.
  • Investors: track open_roles_change_pct across a portfolio for early momentum or slowdowns.
  • Recruiters: look for companies whose new_roles_7d jumps 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 error row.
  • posted_at semantics 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)

Collapse
 
outricher profile image
Outricher •

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 sales keyword 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 ATS departments field 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.