Ask most revenue teams for their ICP and you get two attributes: a job title and a company size. "VP of Engineering at companies with 200 to 2,000 employees."
That is not an ICP. That is a demographic, and it describes roughly forty thousand people, most of whom will never buy anything from you. Run it and you get a list that converts like a cold list, because it is one.
The teams that get outbound working are not using better copy. They are using more signals. They have worked out that the difference between a list that converts at 0.4% and one that converts at 6% is usually four or five extra filters that encode situation, not just identity.
This post is about how to build that. It covers the signals worth layering, the ones that are counterintuitive but work, how to discover the exact filter values instead of guessing at them, and what it actually costs to run this infrastructure yourself if you decide to.
Identity filters versus situation filters
Every filter you can apply falls into one of two buckets, and almost everyone over-weights the first.
Identity filters describe who someone is. Title, seniority, location, company, industry, headcount. They are necessary and they are where everyone stops.
Situation filters describe what is happening to them right now. Did they just start this job? Are they three months from the tenure where they get budget authority? Did they return to a company they previously left? Are they at their fifth startup or their first?
Situation is what predicts a response. A VP of Engineering who has been in seat for eleven years is a different prospect from one who started six weeks ago, and no title filter distinguishes them.
The people/search endpoint exposes both classes as first-class parameters, which is the thing that makes this practical rather than theoretical. Here is the full set worth knowing.
Identity: first_name, last_name, headline, summary, title, geo_city, geo_country_code, primary_language, organization_slugs, is_creator, is_premium.
Situation: last_change_within_days, last_change_type, tenure_min_years, tenure_max_years, company_count_min, company_count_max, current_company_count_min, is_boomerang.
Capability: skills, skills_match, skill_count_min, skill_count_max, certifications, certification_authority, speaks_language.
Background: institution_ids, education_level, degree, field_of_study.
Scope and paging: current_only, cursor, limit.
Filters combine with AND, which means precision comes from stacking, and every filter you add narrows the set. That is the point.
Start with the situation, not the title
Here is the inversion that changes results. Most teams build the title filter first and then wonder how to narrow forty thousand people. Build the situation filter first and you are working with a few hundred from the start.
The single highest-value situation filter is a recent job change. Someone who started a new role in the last ninety days is rebuilding their stack, has budget they have not committed, and is explicitly looking for things that make them look effective in their first two quarters. They also have no loyalty to the incumbent vendor, because they did not choose it.
1from renidly import Renidly
2
3renidly = Renidly() # reads RENIDLY_API_KEY from the environment
4
5new_in_seat = renidly.data.people.search(
6 title="vp engineering",
7 current_only=True,
8 last_change_within_days=90,
9 last_change_type="joined",
10 geo_country_code="US",
11 limit=50,
12)
13
14for person in new_in_seat:
15 print(person.full_name, person.headline)That is the Python version. If your stack is Node, the shape is identical and the filter names are the same, since Data API parameters keep their snake_case names in both languages even though the method names follow each language's convention:
1import { Renidly } from "renidly";
2
3const renidly = new Renidly();
4
5const newInSeat = await renidly.data.people.search({
6 title: "vp engineering",
7 current_only: true,
8 last_change_within_days: 90,
9 last_change_type: "joined",
10 geo_country_code: "US",
11 limit: 50,
12});
13
14for (const person of newInSeat.data) {
15 console.log(person.fullName, person.headline);
16}last_change_type takes joined, left, or title_change, and each one is a different play.
joined is the new-in-seat motion above. title_change is a promotion, which usually means expanded budget authority and a mandate to change something. left is the one most teams never think to use: when someone leaves a company, they are a warm contact at wherever they land next, and they are also a signal that the account they left may be in flux.
The tenure window nobody filters on
tenure_min_years and tenure_max_years let you target a band rather than a threshold, and the band matters.
Someone in month two has no budget and no political capital. Someone at year eight has an entrenched stack and a vendor relationship they are not going to disturb. The window between roughly six months and three years is where people have enough authority to buy and enough remaining ambition to change things.
1in_the_window = renidly.data.people.search(
2 title="director of data",
3 current_only=True,
4 tenure_min_years=1,
5 tenure_max_years=3,
6 geo_country_code="US",
7 limit=50,
8)Test this against your own closed-won data before you trust my numbers. Pull the tenure distribution of everyone who actually bought from you, and the band will be obvious. For most enterprise software it is one to three years. For infrastructure and developer tools it often skews earlier, because the new hire is the one doing the evaluation.
Three signals that look strange and work
These are the filters that separate a list built by someone who has done this from a list built from a template.
Career velocity
company_count_min and company_count_max count how many companies someone has worked at. On its own that sounds like noise. Combined with tenure it is a personality profile.
Low company count plus long tenure is a builder who stays. They evaluate slowly, they buy carefully, and they renew forever. High company count plus short tenure is a mover. They bring their preferred stack with them, they buy fast, and they are gone in two years.
Both are good customers. They need completely different sales motions, and knowing which one you are talking to before the first call is worth more than any amount of email personalization.
1# Fast movers: bring their own stack, decide quickly
2movers = renidly.data.people.search(
3 title="head of growth",
4 current_only=True,
5 company_count_min=4,
6 tenure_max_years=2,
7 limit=50,
8)
9
10# Long-tenure builders: slower cycle, much higher retention
11builders = renidly.data.people.search(
12 title="head of growth",
13 current_only=True,
14 company_count_max=2,
15 tenure_min_years=4,
16 limit=50,
17)Boomerangs
is_boomerang finds people who left a company and later returned. Most people have never considered this a targetable attribute.
It is one of the strongest trust signals available. A boomerang has organizational credibility that a normal new hire does not, because they were wanted back. They typically move fast, they know where the bodies are buried, and they are frequently brought back specifically to fix something. Being brought back to fix something is a buying trigger with a person attached.
1returners = renidly.data.people.search(
2 title="engineering manager",
3 current_only=True,
4 is_boomerang=True,
5 last_change_within_days=180,
6 limit=50,
7)Concurrent roles
current_company_count_min counts how many current positions someone holds. Set it to 2 or more and you surface a population most filters cannot see: fractional executives, advisors, board members, and consultants.
For anyone selling infrastructure, this segment is disproportionately valuable. A fractional CTO advising four companies is four potential deployments from one conversation, and they are professionally motivated to recommend tools that make them look good across all of them.
1multi_role = renidly.data.people.search(
2 title="cto",
3 current_only=True,
4 current_company_count_min=2,
5 limit=50,
6)In Node, all three of these work exactly the same way. The filter object is the only thing that changes:
1const returners = await renidly.data.people.search({
2 title: "engineering manager",
3 current_only: true,
4 is_boomerang: true,
5 last_change_within_days: 180,
6 limit: 50,
7});Do not guess filter values, discover them
This is where most integrations quietly lose half their results. skills and institution_ids do not take free text, and passing something plausible-looking returns nothing rather than an error, so you conclude the filter does not work when in fact you passed an unrecognized value.
The correct pattern is a discovery call first. Search the catalog, take the canonical value, then filter on it.
For skills, look up the normalized name:
1matches = renidly.data.skills.search("kubernetes")
2for s in matches:
3 print(s.id, s.name, s.normalized_name)
4
5platform_people = renidly.data.people.search(
6 skills="kubernetes,terraform",
7 skills_match="all", # "any" is the default
8 title="platform engineer",
9 current_only=True,
10 limit=50,
11)The skills_match parameter is worth pausing on. The default is any, which is a union and will widen your list more than you expect. If you want people who genuinely have all of a set of capabilities, set it to all explicitly. Teams that leave it on the default and then complain about list quality are usually running a union and thinking they are running an intersection.
Education works the same way. Institution filters take opaque inst_ identifiers, not names, so search first:
1schools = renidly.data.institutions.search("stanford university")
2inst_id = schools[0].id # e.g. "inst_..."
3
4alumni_ic_ps = renidly.data.people.search(
5 institution_ids=inst_id,
6 title="product manager",
7 current_only=True,
8 field_of_study="computer science",
9 limit=50,
10)Build these discovery lookups once and cache the resulting IDs. They are stable, and you should not be resolving "kubernetes" to its normalized name on every query run.
If you have not read it yet, the reasoning behind preferring opaque identifiers over human-readable names is covered in more depth in the primer on B2B identity resolution. The short version: names change, IDs do not, and anything you key on a name will break silently.
Going company-first instead
Sometimes the account list comes before the people. You have a target account list from your sales team and you need the right contacts inside each one.
Two routes. If you have company slugs, organization_slugs filters people search directly and accepts a comma-separated list:
1targets = renidly.data.people.search(
2 organization_slugs="northwind-logistics,acme-freight,globex-shipping",
3 title="operations director",
4 current_only=True,
5 limit=50,
6)If you want everyone at a single company, companies.employees is the better tool, and it has one parameter combination worth knowing about:
1leavers = renidly.data.companies.employees(
2 "northwind-logistics",
3 current_only=False,
4 sort="recently_left",
5 limit=50,
6)sort="recently_left" with current_only=False gives you people who recently departed an account. That is two plays at once. It is a churn early-warning signal if the account is a customer, and it is a warm-intro list if the account is a competitor's customer, because those people are landing somewhere new and they already know the category.
To build the account list itself, companies.search takes name or website as the required anchor, then narrows on staff_count_min, staff_count_max, industries, hq_city, hq_country_code, founded, and follower count ranges.
Paginate properly
Every search endpoint is cursor-paginated with a maximum of 50 per page, and the cursors are opaque tokens. Pass them back exactly as received. Do not parse them, do not construct them, and do not try to skip ahead by mutating one.
Both SDKs handle the walk for you, which is the main reason to use them rather than hand-rolling HTTP:
1count = 0
2for person in renidly.data.people.search(
3 title="vp engineering",
4 current_only=True,
5 last_change_within_days=90,
6).auto_paging_iter():
7 process(person)
8 count += 1
9 if count >= 500:
10 break # stop deliberately, do not walk foreverNode gives you the same thing through async iteration, and the returned object is both awaitable and iterable, so you can take one page or walk them all from the same call:
1let count = 0;
2for await (const person of renidly.data.people.search({
3 title: "vp engineering",
4 current_only: true,
5 last_change_within_days: 90,
6})) {
7 await process(person);
8 if (++count >= 500) break;
9}Always bound the walk. Each page is a separately billed request, and an unbounded loop over a broad filter is the most common way teams surprise themselves on a bill. Read the cost off page.meta.credit_consumed and the balance off page.meta.remaining_balance as you go, so the spend is observable while it happens rather than at the end of the month.
Probe cheaply before you pull
One habit worth building: never discover the shape of a segment by pulling it. Discover it with a ladder of one-record probes, then pull once.
Every additional filter narrows the set, but not evenly, and you want to know which filter is doing the work before you spend anything walking pages. Run each rung of your filter stack with limit=1 and watch has_more:
1LADDER = [
2 {"title": "vp engineering", "current_only": True, "geo_country_code": "US"},
3 {"title": "vp engineering", "current_only": True, "geo_country_code": "US",
4 "tenure_min_years": 1, "tenure_max_years": 3},
5 {"title": "vp engineering", "current_only": True, "geo_country_code": "US",
6 "tenure_min_years": 1, "tenure_max_years": 3,
7 "skills": "kubernetes,terraform", "skills_match": "all"},
8 {"title": "vp engineering", "current_only": True, "geo_country_code": "US",
9 "tenure_min_years": 1, "tenure_max_years": 3,
10 "skills": "kubernetes,terraform", "skills_match": "all",
11 "last_change_within_days": 90, "last_change_type": "joined"},
12]
13
14for i, filters in enumerate(LADDER, 1):
15 page = renidly.data.people.search(**filters, limit=1)
16 print(f"rung {i}: results={len(page)} more={page.has_more} cost={page.meta.credit_consumed}")The rung where has_more flips to false is the rung where your segment stopped being a segment. If adding a skills filter takes you from "plenty more" to a single result, one of two things is true: the skill names are wrong and you skipped the discovery step above, or your hypothesis does not describe enough real people to build a campaign on. Both are worth finding out from a one-record probe rather than from a paginated pull and a confused sales meeting.
Keep the ladder in version control next to your segment definitions. When a filter stops returning what it used to, the ladder tells you which rung changed, which is a much faster diagnosis than re-deriving the whole query.
What this costs to build yourself
Everything above is a set of API calls. It is worth being clear about what the equivalent looks like if you build the underlying capability in-house, because plenty of teams start down that road and the cost is not where they expect.
The data problem is not the acquisition, it is the maintenance. Getting a snapshot of professional records is the easy part and every team underestimates what comes after. Roughly a fifth of professionals change jobs each year. A dataset you assemble in January is materially wrong by June and badly wrong by the following January. So you are not building a dataset, you are building a refresh pipeline that runs forever, and that pipeline is the actual product.
Entity resolution is a full-time problem. Deduplicating people across sources, deciding that two records with different name spellings are the same person, and maintaining stable identifiers that survive record merges is a specialist discipline. Teams that treat it as a fuzzy-matching problem end up with a table where the same person appears four times and no two systems agree on which row is canonical.
Normalization is where the schedule goes. Titles are free text and there are hundreds of ways to write "VP of Engineering". Skills need a controlled vocabulary or skills_match="all" means nothing. Companies need canonical entities with subsidiary relationships, or your headcount filters are wrong for anyone with a parent company. Each of these is weeks of work and then permanent maintenance.
The staffing is the real number. A production version of this is typically two to three data engineers plus part of a platform engineer, ongoing. Not for a launch, but permanently, because the pipeline degrades the moment nobody is watching it. At loaded cost that is a multiple of any enrichment contract, and it competes for the same headcount as your actual product.
Then there is the compliance surface. Handling personal data at scale means data-subject request handling, retention policy, deletion propagation across every downstream system that consumed a record, and a defensible answer when a customer's legal team asks where it came from. That is an ongoing obligation, not a one-time review.
None of this is an argument that you cannot build it. Teams do. It is an argument that you should count the cost honestly before you commit, because the build is usually justified on the acquisition cost and then paid for out of the maintenance cost, which is five times larger and never ends.
The version where you skip all of that
The reason to use Renidly is not that the queries above are hard to write. They are not, and you could write equivalents against your own store if you had one.
It is that every one of those maintenance problems is already solved, permanently, by someone else. The records are deduplicated and canonical. Titles and skills are normalized against a controlled vocabulary you can query directly. Every person and organization carries a stable opaque identifier that survives every change to the underlying record, so the ICP segment you build today still resolves correctly in three years. Freshness is a parameter you set per call rather than a property of a pipeline you have to keep alive.
Most providers give you a fixed schema and a search endpoint, and you build everything else. Renidly gives you the situation filters as first-class parameters, which is what makes the difference between a demographic and an ICP: last_change_within_days, is_boomerang, current_company_count_min, and tenure bands are things you would otherwise have to derive yourself from a longitudinal dataset you would first have to build and then maintain forever.
You get one API key, one credit pool, one response envelope, and per-call cost telemetry on every response so spend is observable rather than discovered. Your team writes filter logic and ships. Nobody is on call for a refresh pipeline.
Where to start this week
Do not start by building a query. Start by looking backwards.
Take your last fifty closed-won deals and find the champion at each one. If all you have stored is an email address, reverse resolution turns that into a full person and company record in a single call, which is usually faster than asking your reps to reconstruct it from memory.
Then check the distribution across the situation filters: what was their tenure at the time the deal started, how many companies had they worked at, had they recently changed roles, were they holding more than one current position.
Some of those distributions will be flat, and you can ignore those filters. One or two will be sharply skewed, and those are your real ICP signals. Encode those, and you have a filter built from your own outcomes rather than from a template.
Then run it, cap it at a few hundred records, and compare the list to what your current process produces.
1pip install renidly1npm install renidlyBoth SDKs cover every endpoint and handle auth, retries, pagination, batch jobs, rate limiting, and typed errors, so the discovery-then-filter pattern above is a few lines rather than a client library you maintain. The free tier includes 100 credits with no card required, which is enough to run the backwards-looking analysis on your own closed-won list before you commit to anything.
The Quickstart takes about five minutes. If you are sizing this for a team, need volume pricing, or are working through procurement, talk to us.


