Your best champion changed jobs eleven weeks ago. Nobody told you. The renewal you were counting on went quiet, and the person who would have fought for it now works somewhere else.
That exact thing happened to me in early 2025, and I found out from an email bounce. So I built what I should have had all along: a weekly job change detection sweep that runs on a cron, filters for the contacts we actually know, and lands in Slack while the move is still news.
This is the whole recipe. A working job change tracking API workflow in curl and Python, a champion filter, a CRM upsert sketch, and alert routing. Code included, failure modes included.
What is job change detection?
Job change detection is programmatically spotting when a contact moves to a new company, gets promoted, or changes title, then acting on it quickly.
Champion tracking is the version most sales teams want: you watch a named list of people who already bought from you, renewed with you, or championed you internally. When one of them moves, you want to know the same week, because that is when the opportunity (and the risk) is created.
And the mechanics are simpler than most people expect. One endpoint, a date range, a type filter. The hard part was never the API call. It is everything after: matching, deduping, routing, and not being weird in the first message.
📌 Definition: A champion is someone who already trusted you enough to buy, renew, or advocate. When they move, that trust moves with them, into a company where you have no pipeline yet.
Why job changes deserve a weekly cron
Because people move constantly, and every single move rewrites two account maps at once.
The scale is bigger than it feels. About 3.1 million Americans quit their jobs in July 2026 alone, per the BLS Job Openings and Labor Turnover Survey. One month. Some slice of your CRM changed employers this quarter, whether you noticed or not.
So each detected move hands you two plays, not one:
- The new-buyer alert. A person who trusts your product just landed somewhere new, and new leaders review tooling in their first quarter. That is the warmest outbound timing that exists.
- The churn flare. The old account just lost its internal advocate. That one goes to customer success, today, not to sales.
This is not a fringe tactic, either. Professional networks track job changes at planetary scale (LinkedIn’s Economic Graph exists because role changes are economic signal). You are pointing the same idea at a few hundred people you know.
One honest caveat before the code. Detection runs on public profile updates, and people update on their own schedule: day one, week six, sometimes never. You will not catch everything. But a weekly sweep catches the bulk, roughly ten weeks sooner than a bounce.
The endpoint: one POST, a date range, and a type
Everything below runs on a single endpoint: POST https://api.cufinder.io/v3/signals/job-changes, documented in the Job Changes API docs. You send three things:
- start_date and end_date, both
YYYY-MM-DD. The window you want to sweep. - type, one of four values:
company_change(moved from company A to company B),promotion(same company, seniority jumped),lateral_title_change(same company, new title, no seniority jump), orno_company_to_company(had no employer, now has one). - page, optional, for walking paginated results.
Auth is a single x-api-key header, and each request costs 1 credit (per request, not per record). Every record comes back typed and timestamped: the person’s LinkedIn URL, detected_at, plus from and to blocks with company name, company LinkedIn ID, and title.
To keep expectations straight: this page is the working recipe. The full parameter and response reference (every field, every type, full response shapes) lives in the Job Changes API endpoint guide. Recipe here, reference there.
Step 1: Smoke-test with curl
Before any Python, prove the key works and see one real payload with your own eyes. Thirty seconds, one credit:
curl -X POST https://api.cufinder.io/v3/signals/job-changes \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"start_date": "2026-09-02", "end_date": "2026-09-09", "type": "company_change"}'
You get JSON back: a job_changes array, plus a meta block showing the credits charged and a has_more pagination flag. Mechanics confirmed. Now let’s make it a habit instead of a curiosity.
Step 2: Sweep a full week in Python
The weekly sweep is a loop: request a seven-day window, follow has_more, collect everything. Here is the whole thing:
import requests
from datetime import date, timedelta
URL = "https://api.cufinder.io/v3/signals/job-changes"
HEADERS = {"x-api-key": "YOUR_API_KEY"}
def sweep(change_type="company_change", days=7, max_pages=20):
end = date.today()
start = end - timedelta(days=days)
changes, page = [], 1
while page <= max_pages:
body = {"start_date": start.isoformat(),
"end_date": end.isoformat(),
"type": change_type, "page": page}
r = requests.post(URL, headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
payload = r.json()
changes += payload["data"]["job_changes"]
if not payload["meta"]["pagination"]["has_more"]:
break
page += 1
return changes
Two notes on that max_pages cap. The sweep returns everyone who moved in the window, not just your people; filtering is the next step. And every page is one request, so every page is 1 credit. The cap keeps a curious afternoon from walking the entire market.
Step 3: Filter for the people you know
Export your champions from the CRM as champions.csv with a linkedin_url column: past buyers, power users, renewal sponsors, closed-lost allies. Then intersect.
But normalize first. Your CRM says https://www.linkedin.com/in/jane-doe/, the API says linkedin.com/in/jane-doe, and a raw string match returns nothing forever:
import csv
def norm(url):
u = (url or "").strip().lower()
u = u.replace("https://", "").replace("http://", "")
u = u.replace("www.", "")
return u.rstrip("/")
def load_champions(path="champions.csv"):
with open(path) as f:
return {norm(row["linkedin_url"]) for row in csv.DictReader(f)}
def champion_moves(changes, champions):
return
That tiny match layer is a baby identity resolution system, and it is where all the value concentrates. The market-wide feed is raw material; the intersection with people who know you is pipeline.
Which type should you sweep? Start with company_change only: it is the champion-moved play, and the type itself guarantees employer A differs from employer B. Add a second weekly sweep for promotion later, because a champion who just gained budget authority at the same company is a warm expansion conversation, not a cold one.
Step 4: Upsert, do not insert
Upsert means update-or-create: if the contact exists in your CRM, update them; if not, create them. It matters here because your champions already exist as records, and a naive insert gives every mover a duplicate twin with a dead email address.
def to_crm_record(move):
return {
"linkedin_url": move["linkedin_url"],
"company": move["to"]["company_name"],
"title": move["to"]["title"],
"previous_company": move["from"]["company_name"],
"signal": "job_change",
"signal_date": move["detected_at"][:10],
}
def upsert(move):
record = to_crm_record(move)
# Sketch: swap in your own CRM client here.
# crm.contacts.upsert(match_on="linkedin_url", record=record)
# crm.tasks.create(contact=record["linkedin_url"],
# title="Champion moved: reach out",
# due_in_hours=48)
return record
One deliberate choice: match on the profile URL, never the email. The old work email died the day they left. The profile URL is the identifier that survives the move, which is exactly why the API keys every record to it.
Step 5: Route the alert, or nothing happens
Here is the failure I have watched more than once: the job change alerts pile up in a channel, and everybody assumes somebody else owns them. Detection without routing is just well-organized regret.
import requests
WEBHOOK = "https://hooks.slack.com/services/T000/B000/XXXX"
def alert(move, owner):
lines = [
f"Champion move: {move['linkedin_url']}",
f"{move['from']['company_name']} ({move['from']['title']})",
f"-> {move['to']['company_name']} ({move['to']['title']})",
f"Owner: {owner}. SLA: 48 hours.",
"Verify the live profile before you send.",
]
requests.post(WEBHOOK, json={"text": "\n".join(lines)}, timeout=10)
Every alert gets three things at birth: an owner (route by territory or account history, automatically), a deadline (48 hours, because timing signals decay), and context bundled into one message so the right action is also the lazy action. The rep should never have to go look anything up.
Cadence: weekly, with a memory
Run it Monday morning, weekly. Profile updates trickle in over days, so daily runs mostly spend credits to learn nothing changed since yesterday.
Give the sweep a memory, too. Overlap the window by a day (sweep eight days, not seven) so nothing falls between runs, then keep a seen-set of linkedin_url plus detected_at pairs in a small JSON file and skip anything you have already alerted on. Ten lines of state, zero duplicate pings.
💡 Rule: Overlap the date range by a day, dedupe on linkedin_url + detected_at. Missed moves and double alerts both erode the team's trust in the channel.
Your first job change tracking run is the exception. It sets the baseline, it will feel like a firehose, and none of it is same-week news. Tune your list on it, then trust week two onward.
What breaks (and the fixes)
Profile lag. Job change detection keys off public profile updates, and humans procrastinate. Treat detected_at as “when the change became visible,” not “when it happened,” and never claim otherwise in your outreach.
URL mismatches. Trailing slashes, www, uppercase, full scheme: any of them silently empties your champion filter. The norm() function above exists because I lost a week to a trailing slash. Normalize both sides, always.
Promotions mistaken for moves. Same-company changes are their own types. Sweep company_change alone and you miss the just-got-budget play; sweep everything unfiltered and your reps drown. Pick types per play, deliberately.
Duplicate alerts across weeks. Overlapping windows re-return records near the boundary. That is what the seen-set is for. Without it, the third duplicate ping is the one that gets your channel muted.
Sending on sight. A detected move is not a verified move. Titles get edited, profiles get scrubbed, and occasionally a change is someone tidying their history. Thirty seconds on the live profile before any send is the cheapest quality gate in this whole workflow.
How to mention a job change without being creepy
Never open with the move itself. “Congrats on the new role!” from a vendor reads as exactly what it is: automated monitoring wearing a party hat.
Open with the problem their first quarter creates instead:
- “New RevOps leaders usually inherit five disconnected tools in week one. Happy to share how two teams consolidated.”
- “Most sales leaders rebuild their pipeline reporting in the first 90 days. Is that on your list yet?”
- “When you set this up at your last company, the data quality wall came around month two. Want the shortcut this time?”
The move decided WHO gets the message and WHEN. Their new-role problem decides what it says. And give it a day or two after verification; landing in the inbox 40 minutes after a profile update does not read as attentive. It reads as surveillance.
🧠 Rule of thumb: The job change earns the timing. The message still has to earn the reply. Problem-first beats congrats-first every single week.
The champion we almost missed
The Munich bounce deserves its ending. That churned renewal (our RevOps champion had left eleven weeks earlier) pushed me to run this exact workflow for a quarter across about 120 champions. Weekly sweep, company_change only, live-profile check before every send.
→ 120 champions watched → 9 moves detected → 7 verified on the live profile → 5 sent → 3 real conversations
Three conversations from five emails is a reply rate cold outbound never sees, and one of them turned into the fastest deal of my year: she was rebuilding her stack in week four at the new company and answered in nine minutes. The detection layer cost single-digit credits a week. The discipline of verifying before sending cost more, and mattered more.
How this recipe was put together
Endpoint behavior, parameters, the four type values, response fields, and the 1-credit-per-request cost come from the live API documentation, checked in September 2026. The quits figure comes from the BLS JOLTS data (July 2026, preliminary). Funnel numbers are from my own team’s runs, and your ratios will differ. The standing limitation, stated plainly: this detects what public profiles reveal, on the timeline people choose to reveal it.
Frequently asked questions
What is a job change tracking API?
An endpoint that returns people who changed roles in a date range, as typed, timestamped records. You send a window and a change type; you get back each person’s profile URL plus from/to company and title. Your job is matching those records against contacts you care about.
How often should the sweep run?
Weekly. Profile updates arrive gradually, so daily runs mostly rediscover yesterday. Overlap each window by a day and dedupe on the detection timestamp, and a Monday cron catches everything a daily one would, at a seventh of the cost.
Which type filter should I use first?
Start with company_change. It is the champion-moved play, and the highest-urgency one. Add a promotion sweep once routing works, because a champion who just gained seniority is your warmest expansion path at that account.
What does it cost to run?
One credit per request, per the docs, and each results page is one request. A weekly single-type sweep a few pages deep runs on single-digit credits. Enriching matched people afterward (fresh email, phone) uses separate endpoints with their own credit costs.
Can this catch champions leaving my customer accounts?
Yes, and it should. The same sweep matched against your customer-contact list flags departures as churn risk. Route those to customer success: find the successor, check the renewal date, rebuild the relationship early.
Do I need to scrape LinkedIn to track job changes?
No, and please don’t. Scraping is brittle, block-prone, and against the platform’s terms; an API returns typed, timestamped records to track job changes for a credit. And to compare the market before committing, my honest walk through the best job change data APIs covers who offers what.
Start with ten champions
Not a hundred. Ten people who genuinely championed you, one weekly sweep, one Slack channel with an owner attached. You will have your first verified move within a few weeks, and your first “wow, good timing” reply shortly after.
Then grow the job change tracking list, add the promotion sweep, wire the upsert into your real CRM. The rest of the endpoint family is in the CUFinder API directory when you want to stack more signals on top.
One cron. One CSV. One channel. That is the whole starting lineup, and it is eleven weeks faster than a bounce. Go set it up.