The macro labor market is quiet right now. The Bureau of Labor Statistics put the quits rate at 1.9 percent in its most recent JOLTS reading, below the pre-pandemic norm of roughly 2.3 to 2.4 percent and well under the 3.0 percent peak of late 2021. When the average is frozen, the companies that are moving stand out more, not less. A team hiring hard in a flat market is making a deliberate bet. A team shedding people in a flat market is under pressure that the headline numbers hide.
Those movements are visible before they show up in revenue, press releases, or a funding announcement. Headcount is one of the few signals that reveals intent and operational pressure at the same time. A funding round tells you a company might spend. A hiring surge tells you where the budget already landed, because a hire is committed salary, not a maybe.
This is why workforce data has become a standard input for two very different audiences. Sales teams read hiring as a buying signal. Investors read it as a growth or risk signal. The alternative-data firm Neudata found, in its 2024 survey of data buyers, that these firms (mostly hedge funds and asset managers) subscribe to around 20 datasets a year and spend roughly $1.6 million annually, with workforce and hiring data a core category. Vendors like Revelio Labs and Live Data Technologies exist to sell exactly this.
You can build a lighter version yourself. This post walks through a monitor that reads monthly company data, tracks it over time, and flags three things across a watchlist: hiring spikes, attrition spikes, and headcount inflections. The code is shown in Python and JavaScript, and the detection logic is identical in both.
Why monthly, and why a time series
A snapshot tells you a company has 240 employees. It doesn't tell you whether that's up from 180 or down from 300. The signal lives in the change, and a change only has meaning against history.
An inflection makes the point concrete. An inflection point is a sustained change in the growth rate: the month a company stops accelerating and starts to coast, or the quarter a steady climb turns into a decline. The word that matters is sustained. One month of fast hiring could be a single backfilled team. Three consecutive months of acceleration is a trajectory. You cannot tell those apart from a single reading, and you cannot date the turn from an annual one. A monthly series lets you see two or three points in a row and separate a real inflection from noise.
Quarterly macro data is too coarse and too lagged to catch this at the company level. By the time a sector trend shows up in an aggregate report, the specific company that turned first has already turned. A monthly company-level series is the resolution that catches the inflection while it still tells you something.
Data Legion refreshes its data on a monthly cadence, so the natural loop is to pull each company on your watchlist once a month and append to a series you keep.
The data behind a workforce signal
A single company enrich call returns the workforce fields you need. Request them explicitly so the response stays small.
import os
import requests
WORKFORCE_FIELDS = ",".join([
"name", "domain", "industry", "size", "legion_employee_count",
"legion_employee_count_by_month",
"legion_new_hire_count", "legion_attrition_count",
"legion_turnover_rate", "legion_employee_growth_rate",
])
def fetch_company(domain: str) -> dict | None:
resp = requests.post(
"https://api.datalegion.ai/company/enrich",
headers={"API-Key": os.environ["DATALEGION_API_KEY"]},
json={"domain": domain, "include_fields": WORKFORCE_FIELDS},
timeout=5,
)
if not resp.ok:
return None
matches = resp.json().get("matches", [])
return matches[0]["company"] if matches else Noneconst WORKFORCE_FIELDS = [
"name", "domain", "industry", "size", "legion_employee_count",
"legion_employee_count_by_month",
"legion_new_hire_count", "legion_attrition_count",
"legion_turnover_rate", "legion_employee_growth_rate",
].join(",");
async function fetchCompany(domain) {
const resp = await fetch("https://api.datalegion.ai/company/enrich", {
method: "POST",
headers: {
"API-Key": process.env.DATALEGION_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ domain, include_fields: WORKFORCE_FIELDS }),
signal: AbortSignal.timeout(5000),
});
if (!resp.ok) return null;
const { matches = [] } = await resp.json();
return matches.length ? matches[0].company : null;
}The shape that matters most is legion_employee_count_by_month, a monthly series where each entry already carries the month's hires, departures, and growth rate. The time bucket fields summarize the same activity over trailing windows.
{
"name": { "display": "Larkspur Software" },
"domain": "larkspur.example",
"industry": "software",
"size": "51-200",
"legion_employee_count": 168,
"legion_employee_count_by_month": [
{ "month": "2026-03", "count": 142, "growth_rate": 0.029, "hires": 7, "departures": 3 },
{ "month": "2026-04", "count": 149, "growth_rate": 0.049, "hires": 10, "departures": 3 },
{ "month": "2026-05", "count": 158, "growth_rate": 0.060, "hires": 13, "departures": 4 },
{ "month": "2026-06", "count": 168, "growth_rate": 0.063, "hires": 15, "departures": 5 }
],
"legion_new_hire_count": { "1m": 15, "3m": 38, "6m": 61, "12m": 96 },
"legion_attrition_count": { "1m": 5, "3m": 12, "6m": 21, "12m": 39 },
"legion_turnover_rate": { "1m": 0.030, "3m": 0.074, "6m": 0.135, "12m": 0.262 },
"legion_employee_growth_rate": { "1m": 0.063, "3m": 0.183, "6m": 0.331, "12m": 0.617 }
}Read across the monthly array and you can see Larkspur's growth rate climbing month over month, hires outpacing departures, and the trailing windows confirming it: 38 hires in the last quarter against 12 departures. That is a company in expansion, and the acceleration in the growth_rate column is the inflection worth catching.
Detecting a hiring spike
A spike is the current month standing well above its own recent baseline. Compare the latest month's hires to the trailing average of the months before it, and flag when the ratio crosses a threshold you choose.
def hiring_spike(series: list[dict], window: int = 6, ratio: float = 1.5) -> dict | None:
if len(series) < window + 1:
return None
latest = series[-1]
baseline = series[-(window + 1):-1]
avg_hires = sum(m["hires"] for m in baseline) / len(baseline)
if avg_hires == 0:
return None
lift = latest["hires"] / avg_hires
if lift >= ratio:
return {
"signal": "hiring_spike",
"month": latest["month"],
"hires": latest["hires"],
"baseline_avg": round(avg_hires, 1),
"lift": round(lift, 2),
}
return Nonefunction hiringSpike(series, window = 6, ratio = 1.5) {
if (series.length < window + 1) return null;
const latest = series[series.length - 1];
const baseline = series.slice(-(window + 1), -1);
const avgHires = baseline.reduce((s, m) => s + m.hires, 0) / baseline.length;
if (avgHires === 0) return null;
const lift = latest.hires / avgHires;
if (lift >= ratio) {
return {
signal: "hiring_spike",
month: latest.month,
hires: latest.hires,
baseline_avg: Math.round(avgHires * 10) / 10,
lift: Math.round(lift * 100) / 100,
};
}
return null;
}Tune the ratio to your tolerance for noise. At 1.5 you catch a month that ran 50 percent hot. A small company will trip this on a single team build-out, which is fine for sales (any expansion is a reason to reach out) and noisy for investing (you want the sustained version below).
Detecting an attrition spike
The mirror image, and the more interesting risk signal. Departures rising faster than the company's own norm is an early warning the headline employee count will not show, because hires can mask it for a while. The cleanest read is the turnover rate: compare the one-month rate to the twelve-month rate, which normalizes for company size.
def attrition_spike(company: dict, ratio: float = 1.75) -> dict | None:
turnover = company.get("legion_turnover_rate") or {}
recent = turnover.get("1m")
annual = turnover.get("12m")
if not recent or not annual:
return None
# Annualize the monthly rate and compare to the trailing year.
monthly_annualized = recent * 12
if monthly_annualized >= annual * ratio:
return {
"signal": "attrition_spike",
"turnover_1m_annualized": round(monthly_annualized, 3),
"turnover_12m": round(annual, 3),
}
return Nonefunction attritionSpike(company, ratio = 1.75) {
const turnover = company.legion_turnover_rate || {};
const recent = turnover["1m"];
const annual = turnover["12m"];
if (!recent || !annual) return null;
// Annualize the monthly rate and compare to the trailing year.
const monthlyAnnualized = recent * 12;
if (monthlyAnnualized >= annual * ratio) {
return {
signal: "attrition_spike",
turnover_1m_annualized: Math.round(monthlyAnnualized * 1000) / 1000,
turnover_12m: Math.round(annual * 1000) / 1000,
};
}
return null;
}Leadership departures are the version of this signal that's both public and load-bearing. Executive exits are at record highs. The outplacement firm Challenger, Gray and Christmas counted a record 446 public-company CEO departures in 2025, part of more than 2,000 CEO exits for the second year running. A spike in senior attrition at a target account is a reason to pause outreach, and at a portfolio company a reason to ask questions.
Detecting an inflection
The spike checks look at one month. An inflection looks at the slope across several. The simplest reliable version: take the growth rate from the last few months and check whether it has moved in one direction consistently, rather than bounced.
def growth_inflection(series: list[dict], months: int = 3) -> dict | None:
if len(series) < months + 1:
return None
rates = [m["growth_rate"] for m in series[-months:]]
rising = all(rates[i] > rates[i - 1] for i in range(1, len(rates)))
falling = all(rates[i] < rates[i - 1] for i in range(1, len(rates)))
if not (rising or falling):
return None # noisy, not a sustained turn
return {
"signal": "acceleration" if rising else "deceleration",
"months": [m["month"] for m in series[-months:]],
"growth_rates": [round(r, 3) for r in rates],
}function growthInflection(series, months = 3) {
if (series.length < months + 1) return null;
const recent = series.slice(-months);
const rates = recent.map((m) => m.growth_rate);
const rising = rates.every((r, i) => i === 0 || r > rates[i - 1]);
const falling = rates.every((r, i) => i === 0 || r < rates[i - 1]);
if (!rising && !falling) return null; // noisy, not a sustained turn
return {
signal: rising ? "acceleration" : "deceleration",
months: recent.map((m) => m.month),
growth_rates: rates.map((r) => Math.round(r * 1000) / 1000),
};
}Three consecutive months of rising growth is acceleration. Three of falling growth, especially if it crosses into negative net change, is deceleration, which often precedes a hiring freeze or a round of cuts. This is the signal that benefits most from the monthly cadence, because "sustained" is something you can only measure with a series.
Running it across a watchlist
Put the pieces together and the monitor is a loop over your list of domains, collecting whatever fired.
def scan(domains: list[str]) -> list[dict]:
alerts = []
for domain in domains:
company = fetch_company(domain)
if not company:
continue
series = company.get("legion_employee_count_by_month") or []
for check in (
hiring_spike(series),
attrition_spike(company),
growth_inflection(series),
):
if check:
alerts.append({"domain": domain, **check})
return alerts
watchlist = ["larkspur.example", "northwind.example", "contoso.example"]
for alert in scan(watchlist):
print(alert)async function scan(domains) {
const alerts = [];
for (const domain of domains) {
const company = await fetchCompany(domain);
if (!company) continue;
const series = company.legion_employee_count_by_month || [];
for (const check of [
hiringSpike(series),
attritionSpike(company),
growthInflection(series),
]) {
if (check) alerts.push({ domain, ...check });
}
}
return alerts;
}
const watchlist = ["larkspur.example", "northwind.example", "contoso.example"];
for (const alert of await scan(watchlist)) {
console.log(alert);
}Run it monthly, store the output, and you have a feed instead of a one-time report. The two audiences diverge here only in what they do with the feed. A sales team routes a hiring spike or an acceleration to the rep who owns that account, so outreach lands while the budget is fresh. An investor screens a portfolio or a sector list, treating a sustained deceleration or a leadership-attrition spike as a prompt for diligence. The detection code is the same; the routing is yours.
For the broader set of ways to query the same workforce data, including bottom-up market sizing, see Person Search Beyond Prospecting. The full company field list, including the seniority and job-function distributions you can layer on top of these checks, is at www.datalegion.ai/docs/company-data/schema. Start with one signal and a watchlist you care about. The value compounds the second month, when you have something to compare against.
Build it with a coding agent
To stand up the monitor without copying each function by hand, paste this into Claude Code, Codex, or Cursor:
Build a workforce-signal monitor on the Data Legion Company Enrich API.
- POST https://api.datalegion.ai/company/enrich
- Header: API-Key: $DATALEGION_API_KEY
- Body: {"domain": "<company domain>"}
- Read matches[0].company. Useful fields: legion_employee_count_by_month
(array of { month, count, growth_rate, hires, departures }) and the
trailing-window objects legion_new_hire_count, legion_attrition_count,
legion_turnover_rate, and legion_employee_growth_rate, each shaped
{ "1m", "3m", "6m", "12m" }.
References for context (read these first):
- The walkthrough this is based on:
https://www.datalegion.ai/blog/post/mining-monthly-workforce-signals
- Company Enrich API:
https://www.datalegion.ai/docs/api-reference/company-enrichment
- Company field schema: https://www.datalegion.ai/docs/company-data/schema
- Data freshness: https://www.datalegion.ai/docs/data-quality/data-freshness
Requirements:
- Detectors for a hiring spike, an attrition spike, and a growth inflection,
each reading the monthly series and returning a typed result.
- A scan() that runs the detectors across a watchlist of domains and returns
only the companies that triggered a signal.
- Persist each run so month-over-month comparisons work.
- Stay under 100 requests per minute.
Before you start, ask me which language and framework to use. Sketch a short
plan, then build it with tests and a short README. When you finish, check the
result against each requirement above and tell me what is covered.