Every sales team knows the same thing: the best time to reach someone is right after they start a new job.
Ask a rep why and they will tell you without hesitating. New people have budget. They have a mandate. They were hired to fix something. They did not choose the tools they inherited, so they owe nothing to the incumbent vendor. And for about two quarters, changing things is not just permitted, it is the expectation.
So everybody agrees this is the best trigger in B2B. And almost nobody runs it as a system.
What happens instead is that a rep notices a job change, remembers it for a day, maybe sends something. A champion at a happy customer quietly leaves and nobody finds out until renewal. Someone who loved your product two companies ago is now three months into a new role with a budget, and you have no idea.
The gap is not knowledge. The gap is that this trigger only pays off if you catch it inside the window, and a human scrolling a feed cannot do that reliably across a territory of two thousand accounts.
This post is about closing that gap in code. Not theory, not a strategy deck. The event types, the plays that actually work inside each one, and the monitoring loop that turns a nice idea into something that runs on a cron.
Three events, three completely different plays
The job-changes/search endpoint returns three event_type values: joined, left, and title_change. Most teams only think about the first one, which means two thirds of the signal goes unused.
They are not variations on a theme. They are three different sales motions.
joined is the classic, and it works
Someone starts a new role. This is the window everyone talks about, and it is genuinely the strongest one, but the reason it works is more specific than "they are new."
A new leader is being measured on what changes in their first two quarters. That is not a soft preference, it is how their performance review is going to read. They need visible wins, and buying something that fixes a known problem is one of the fastest visible wins available.
The practical implication is that your message should not be about your product. It should be about the problem they were hired to solve. A VP Engineering who just joined a company with a reliability reputation problem does not want a feature list, they want to not have that problem in six months.
left is the one nobody uses, and it is worth more than it looks
When someone leaves a company, that single event tells you two separate things, and both are actionable.
The account they left just changed. If they were your champion, your renewal risk went up today, and you will otherwise find out about it during the renewal conversation, which is the worst possible time. If they were the champion for a competing vendor, that account's commitment to that vendor just got weaker.
The person is landing somewhere. People carry their tooling preferences with them. Someone who liked your product at their last company is the single warmest inbound you will ever get, and the only reason it does not feel like inbound is that nobody told you it happened.
Most teams have no process for either. Champion departure is treated as a surprise, and champion relocation is treated as luck.
title_change is the budget signal
A promotion is a budget event. Someone who was evaluating tools last year and could not get approval may now be the person who approves. The relationship already exists, the education is already done, and the only thing that changed is authority.
This is the cheapest play on the list because you are not starting cold. You are following up on a conversation that stalled for a reason that no longer applies.
Watching your own accounts first
Before building anything broad, start with the accounts you already care about. This is the highest-value version of this and it is about fifteen lines.
The endpoint takes organization_ids as a comma-separated list of opaque org_ identifiers, plus a days_ago recency window between 1 and 365.
1from renidly import Renidly
2
3renidly = Renidly() # reads RENIDLY_API_KEY from the environment
4
5departures = renidly.data.job_changes.search(
6 event_type="left",
7 organization_ids="org_5ehe8408s7tme,org_06d0d44dogo2m",
8 days_ago=7,
9 limit=50,
10)
11
12for event in departures:
13 print(event)If you are on Node, it is the same call with the method name following the language convention. Note that the filter parameters keep their snake_case form in both languages, which catches people out on day one:
1import { Renidly } from "renidly";
2
3const renidly = new Renidly(); // reads RENIDLY_API_KEY from the environment
4
5const departures = await renidly.data.jobChanges.search({
6 event_type: "left",
7 organization_ids: "org_5ehe8408s7tme,org_06d0d44dogo2m",
8 days_ago: 7,
9 limit: 50,
10});One detail worth flagging because it will cost you an afternoon otherwise: this endpoint wants organization IDs, while people search wants organization slugs. They are not interchangeable. If you have slugs, resolve them once through data.companies.retrieve(slug=...), store the org_ ID, and use that from then on. IDs never change, slugs can, and the reasoning behind that is covered in more depth in the primer on identity resolution.
Also worth knowing: job-changes/search is page-paginated with page and limit, not cursor-paginated like people search. Small inconsistency, easy to trip over if you copy a pagination helper from one to the other.
Territory monitoring without an account list
If you do not have a target list yet, or you want to catch movement outside it, filter by role and geography instead.
1new_leaders = renidly.data.job_changes.search(
2 event_type="joined",
3 title="head of engineering",
4 geo_country_code="US",
5 days_ago=30,
6 limit=50,
7)The title filter does partial matching, which matters more than it sounds. Job titles are not a controlled vocabulary. "Head of Engineering," "Engineering Lead," "VP Engineering," and "Director of Engineering" are roughly the same buyer and four different strings, so go broader than feels comfortable and filter down in your own code rather than writing a narrow string and silently missing most of the market.
Here is a technique worth stealing: run the same query at multiple recency windows and treat them as different queues.
1WINDOWS = {"hot": 7, "warm": 30, "cooling": 90}
2
3for label, days in WINDOWS.items():
4 events = renidly.data.job_changes.search(
5 event_type="joined",
6 title="head of engineering",
7 geo_country_code="US",
8 days_ago=days,
9 limit=50,
10 )
11 route_to_queue(label, events)Someone seven days in and someone ninety days in need different messages. The first is still in listening mode and has not formed opinions yet. The last has formed opinions, has probably identified what is broken, and responds to something specific rather than an introduction. Same trigger, different moment, different email.
Most teams send both of them the same "congrats on the new role" message, which is why that message stopped working.
The part that actually takes engineering: not sending twice
Here is where the naive version falls apart.
You run your query daily with days_ago=30. Day one returns 200 events. Day two returns roughly the same 200 events plus a few new ones, because the window is still thirty days wide. If you act on everything the query returns, you email the same person thirty times.
The endpoint gives you a recency window, not a changelog. Deduplication is your job, and it is the difference between a working system and an apology to your list.
Build a seen-set keyed on something stable:
1import hashlib
2from renidly import Renidly, RenidlyConfig, RenidlyError
3
4renidly = Renidly(config=RenidlyConfig(auto_rate_limit=True, max_retries=3))
5
6
7def event_key(event) -> str:
8 """Stable fingerprint for one event so we only act once."""
9 parts = [
10 str(getattr(event, "person_id", "") or ""),
11 str(getattr(event, "organization_id", "") or ""),
12 str(getattr(event, "event_type", "") or ""),
13 ]
14 return hashlib.sha256("|".join(parts).encode()).hexdigest()
15
16
17def poll_and_dispatch(**filters) -> int:
18 dispatched = 0
19 try:
20 events = renidly.data.job_changes.search(days_ago=30, limit=50, **filters)
21 except RenidlyError as e:
22 log.warning("job change poll failed", code=e.error_code)
23 return 0
24
25 for event in events:
26 key = event_key(event)
27 if already_seen(key):
28 continue
29 mark_seen(key, ttl_days=180)
30 dispatch(event)
31 dispatched += 1
32
33 return dispatchedThe Node version is the same idea, and I would put the seen-set in whatever durable store you already run rather than standing up something new for it:
1import crypto from "node:crypto";
2import { Renidly, RenidlyError } from "renidly";
3
4const renidly = new Renidly(undefined, { autoRateLimit: true, maxRetries: 3 });
5
6function eventKey(e: any): string {
7 const parts = [e.person_id ?? "", e.organization_id ?? "", e.event_type ?? ""];
8 return crypto.createHash("sha256").update(parts.join("|")).digest("hex");
9}
10
11export async function pollAndDispatch(filters: Record<string, unknown>) {
12 let events;
13 try {
14 events = await renidly.data.jobChanges.search({ days_ago: 30, limit: 50, ...filters });
15 } catch (e) {
16 if (e instanceof RenidlyError) return 0;
17 throw e;
18 }
19
20 let dispatched = 0;
21 for (const event of events) {
22 const key = eventKey(event);
23 if (await alreadySeen(key)) continue;
24 await markSeen(key, { ttlDays: 180 });
25 await dispatch(event);
26 dispatched++;
27 }
28 return dispatched;
29}Set the TTL on the seen-set longer than your widest query window, with room to spare. If you poll at ninety days and expire keys at thirty, you will re-dispatch everything on a rolling basis and never understand why.
I have written the key builder defensively with getattr because you should confirm the exact field names on the event object against your SDK version before shipping. Print one event, look at what comes back, then hardcode the fields. It takes thirty seconds and saves you a silent dedupe failure where every event hashes to the same value and you dispatch exactly one.
Enriching the event into something a rep can use
A job change event tells you movement happened. It does not hand you a person you can talk to. That is a second step, and the shape of it depends on what you plan to do.
If you want the full professional record, enrich in bulk rather than one at a time. Batch takes up to 1000 items, returns a job handle immediately, and keys every result back to exactly what you submitted:
1person_ids = [e.person_id for e in new_events]
2
3job = renidly.data.people.enrich_batch(ids=person_ids)
4result = job.wait()
5
6for row in result.results:
7 build_brief(row.matched_input, row)If you need a reachable email address rather than a profile, that is the Email API, and the reverse lookup guide covers the confidence tiers and why you should branch on them rather than treating every match as equal.
Two things about enriching triggers that are easy to get wrong.
Enrich after dedupe, not before. Every enrichment call is billed, including one for a person you already contacted last week. Filter first, then spend.
Enrich the ones you will actually contact. If your rep can work forty accounts this month and your query returns four hundred events, enriching all four hundred is a ten-times overspend. Rank first, cut to capacity, then enrich. The full mechanics of batch cost control are in the batch enrichment guide, and the short version is that you pay per lookup submitted regardless of whether it resolves, so submitting a list you will not work is pure waste.
Ranking, so a rep gets forty and not four hundred
The output of a trigger system is only useful if it is small enough to act on. A queue nobody can finish gets ignored entirely, and then the whole system is dead weight.
Score before you dispatch. A simple weighted sum beats nothing, and beats most of what teams actually ship:
1def score(event, person, company) -> int:
2 s = 0
3
4 # Recency: the window is the whole point
5 if event.days_ago <= 14: s += 30
6 elif event.days_ago <= 45: s += 15
7
8 # Fit: is the company the right shape
9 if company and 200 <= (company.staff_count or 0) <= 2000: s += 20
10
11 # Seniority: can they sign
12 if person and any(w in (person.title or "").lower()
13 for w in ("vp", "head", "director", "chief")): s += 20
14
15 # Relationship: have we met before
16 if is_known_contact(person): s += 40
17
18 return sThat last line is worth more than the rest combined. Someone who already knows your product and has just landed somewhere new is not a cold lead, and treating them like one is the most common waste in this entire play. Cross-reference every joined event against your CRM before it gets scored, and route the matches to whoever owned the original relationship rather than to a sequence.
The output goes to a human with context attached: what happened, when, what the company looks like, and whether you have history. Not a raw event dump. A rep who gets forty scored items with a sentence of context each will work all forty. A rep who gets four hundred rows of JSON will work zero.
Running it on a schedule
Once, daily, per configured watch. Not real time. Job changes are not a real-time signal and polling harder does not make them arrive faster, it just costs more.
1WATCHES = [
2 {"event_type": "joined", "title": "head of engineering", "geo_country_code": "US"},
3 {"event_type": "left", "organization_ids": CUSTOMER_ORG_IDS},
4 {"event_type": "title_change", "organization_ids": PIPELINE_ORG_IDS},
5]
6
7def daily():
8 total = 0
9 for watch in WATCHES:
10 total += poll_and_dispatch(**watch)
11 log.info("dispatched %s new events", total)Turn on auto_rate_limit (autoRateLimit in Node) so a morning batch of watches cannot collide with whatever else shares the key. If the same key serves production traffic, set rate_limit_safety below 1.0 to leave headroom for the requests a user is actually waiting on.
Log credits consumed per watch from the first run. Every response carries a .meta object outside the response data, so event is your data and meta.credit_consumed is your telemetry with no collision. When someone asks which watch is worth keeping, you want a query and not a guess:
1page = renidly.data.job_changes.search(event_type="joined", days_ago=7, limit=50)
2log_cost(watch_name, page.meta.credit_consumed, page.meta.remaining_balance)Remember that each page is separately billed. If a watch is walking many pages daily and producing three usable events, that watch is a cost center and you should narrow it or drop it.
What it takes to run this yourself
The honest version, because plenty of teams look at this and think it is a nightly script and a cron job.
The detection is the hard part. To know that someone changed jobs, you need a before and an after, which means you are not maintaining a dataset, you are maintaining a longitudinal one. You need yesterday's state of every person you care about, today's state, a diff, and enough confidence in both snapshots that a diff means a real change rather than a record correction or a formatting difference. A profile that got edited is not a job change, and telling those apart is most of the work.
Then the maintenance:
Coverage decay. You can only detect changes for people you were already tracking. Anyone who enters your market after you built the list is invisible until you find them, so the tracked set has to keep growing or the system quietly stops working.
Entity stability across the change. The whole point is following a person from one company to another, which requires an identifier that survives them changing their title, their company, and often their display name at the same time. Names are not keys, and a matcher that treats them as keys will lose exactly the people you most wanted to track.
Noise control. Title reformats, company rebrands, subsidiary reorganizations, and profile cleanups all look like events to a naive diff. Every false positive costs you credibility with the reps consuming the output, and you get a limited number of those before they stop opening the queue.
Cadence. Diff too rarely and you miss the window that makes the whole trigger valuable. Diff too often and you spend most of your compute confirming that nothing changed.
In practice that is a data engineer more or less permanently, plus storage for the longitudinal snapshots, plus someone tuning the noise filters when reps complain. It is a real system with an owner, not a script. Worth building if change detection is your product. Hard to justify as one input into a go-to-market motion.
The version where you skip all of that
Everything above is one endpoint and a dedupe table. The longitudinal comparison, the entity stability across a job change, and the noise filtering are already done, so job-changes/search hands you the diff rather than the raw material for computing one.
Most enrichment tools give you a snapshot and leave change detection to you, which is why so few teams run this play despite everyone agreeing it works. Renidly exposes movement as a first-class queryable event with three distinct types, a recency window, and organization and role filters, so the trigger system you were never going to get budget to build is an afternoon.
The fastest way to see whether it is worth anything to you is not a broad query. Take the twenty accounts you would genuinely be upset to lose, resolve their org_ IDs once, and run a single left query with days_ago=90. If a champion at one of those accounts walked out in the last quarter and you did not know, you have your answer, and you have it before you have written an integration.
That query is about six lines and fits inside the free tier.
1pip install renidly1npm install renidlyBoth SDKs cover every endpoint and handle auth, retries, pagination, batch jobs, rate limiting, and typed errors, with the same shape across both languages. The free tier includes 100 credits with no card required, which is enough to run the twenty-account check above and a few territory queries besides.
The Quickstart takes about five minutes. For volume pricing, an SLA, or a procurement conversation, get in touch.


