Almost every engineer you want to hire has a LinkedIn profile. That is exactly the problem. Every other recruiter can see the same profile, run the same boolean search, and send the same InMail to the same inbox. The profile is public. The attention behind it is not.
Outbound sourcing converts far better than inbound, which is why the inbox is crowded. Gem's 2026 Recruiting Benchmarks Report, built on more than 165 million applications and 1.2 million hires, found that sourced candidates are nearly eight times more likely to be hired than inbound applicants. The same report found recruiters handling 93 percent more applications than in 2021, with roughly one hire per 200 applications. Inbound is drowning in volume. Outbound works, and everyone knows it.
So find engineers by role, experience, and place, then reach them somewhere other than the channel every competitor is already using. In our September 2026 build, about 5.8 million people have an engineering job function, and 85 percent of them carry at least one direct channel: a current work email (69 percent), a mobile number (49 percent), or a personal email (44 percent). The profile lives on LinkedIn. The conversation doesn't have to.
This post builds that pipeline with the Person Search API: search by role and experience, size the pool, page through it, pick a contact channel per candidate, and check your ATS before you send anything. Code is in Python and JavaScript.
Search by role, experience, and place
Start from the engineering function, then narrow by experience, location, and the words in the current job title. Title keywords carry most of the stack signal: backend, platform, infrastructure, data, security. job_function narrows the pool to people classified as engineers, so a keyword like "platform" doesn't also pull in product and sales titles.
Here is a search for backend and platform engineers in Austin with at least five years in the field:
SELECT * FROM people
WHERE city = 'austin'
AND state = 'texas'
AND job_function = 'engineering'
AND years_of_experience >= 5
AND (job_title ILIKE '%backend%' OR job_title ILIKE '%platform%')Pick keywords with care. ILIKE '%text%' matches the text anywhere, so '%java%' also matches every "JavaScript Engineer" title. Use the longer word, or match it as a whole word: job_title ILIKE 'java %' OR job_title ILIKE '% java %'. The search docs list every searchable column and the operators each one supports.
Selective filters also keep the search fast. A search has a 30-second budget, and one that runs past it returns HTTP 408. In our tests, city plus function plus a title keyword came back in about a second, while a whole state with no title filter ran past the limit. If you get a 408, add a filter rather than retrying.
A quick access note: Person Search is an enterprise endpoint. Enrichment runs on self-serve credits, but search needs enterprise access. If you are on a free trial, contact us to turn it on.
Size the pool before you page through it
Run the query once with a small limit. The response carries total, the number of people matching the whole WHERE clause across the database, not just the page you asked for. The Total-Count-Status header tells you how that number was derived: exact for selective queries, estimate for broad ones where the count is the query planner's approximation.
import os
import requests
API = "https://api.datalegion.ai"
HEADERS = {"API-Key": os.environ["DATALEGION_API_KEY"]}
CONTACT_FIELDS = ",".join([
"full_name", "job_title", "company_name", "city", "state",
"years_of_experience", "linkedin_url", "emails", "phones",
])
def search_people(query: str, limit: int = 100, offset: int = 0) -> tuple[dict, str | None]:
resp = requests.post(
f"{API}/person/search",
headers=HEADERS,
json={
"query": query,
"limit": limit,
"offset": offset,
"include_fields": CONTACT_FIELDS,
},
timeout=35, # the API gives a search up to 30 seconds
)
resp.raise_for_status()
return resp.json(), resp.headers.get("Total-Count-Status")
QUERY = (
"SELECT * FROM people WHERE city = 'austin' AND state = 'texas' "
"AND job_function = 'engineering' AND years_of_experience >= 5 "
"AND (job_title ILIKE '%backend%' OR job_title ILIKE '%platform%')"
)
page, count_status = search_people(QUERY, limit=1)
print(f"pool: {page['total']} ({count_status})")const API = "https://api.datalegion.ai";
const CONTACT_FIELDS = [
"full_name", "job_title", "company_name", "city", "state",
"years_of_experience", "linkedin_url", "emails", "phones",
].join(",");
async function searchPeople(query, limit = 100, offset = 0) {
const resp = await fetch(`${API}/person/search`, {
method: "POST",
headers: {
"API-Key": process.env.DATALEGION_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
query,
limit,
offset,
include_fields: CONTACT_FIELDS,
}),
signal: AbortSignal.timeout(35000), // the API gives a search up to 30 seconds
});
if (!resp.ok) throw new Error(`search failed: ${resp.status}`);
return { page: await resp.json(), countStatus: resp.headers.get("Total-Count-Status") };
}
const QUERY =
"SELECT * FROM people WHERE city = 'austin' AND state = 'texas' " +
"AND job_function = 'engineering' AND years_of_experience >= 5 " +
"AND (job_title ILIKE '%backend%' OR job_title ILIKE '%platform%')";
const { page, countStatus } = await searchPeople(QUERY, 1);
console.log(`pool: ${page.total} (${countStatus})`);The count is a sourcing decision in itself. The Austin query above matches a few hundred people, which is a list a recruiter can work. A few dozen means you can review every profile by hand. A few thousand means your filter is doing too little work: add a seniority level, a company size, or a tighter title keyword before you spend time on outreach.
Page through the results
Each page returns up to 100 people, and offset goes up to 10,000. Search is rate limited to 50 requests per minute, so a short pause between pages keeps a long pull under the limit.
import time
def pull_pool(query: str, max_people: int = 1000) -> list[dict]:
people = []
offset = 0
while offset < max_people and offset <= 10_000:
page, _ = search_people(query, limit=100, offset=offset)
matches = page.get("matches", [])
people.extend(m["person"] for m in matches)
if len(matches) < 100:
break
offset += 100
time.sleep(1.3) # stay under 50 searches per minute
return peopleconst sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function pullPool(query, maxPeople = 1000) {
const people = [];
let offset = 0;
while (offset < maxPeople && offset <= 10000) {
const { page } = await searchPeople(query, 100, offset);
const matches = page.matches ?? [];
people.push(...matches.map((m) => m.person));
if (matches.length < 100) break;
offset += 100;
await sleep(1300); // stay under 50 searches per minute
}
return people;
}If the pool is larger than 10,000, split the query instead of paging deeper: one search per city, one per seniority band. Smaller slices are also faster and easier to prioritize.
Pick a channel per candidate
Every person comes back with an emails array and a phones array. Each entry carries a type, a confidence level based on how many sources agree, and, for work addresses and phones, a current flag. Personal emails are never marked current, since they are not tied to an employer, so confidence is the signal to read on those.
Which channel to try first is your call. Many recruiters prefer a personal address for a first touch, because a recruiting message in a current employer's inbox can land badly. Others start with the work address because it is the one the person checks all day. The function below encodes the first preference; reorder it to match yours.
def pick_channel(person: dict) -> tuple[str | None, str | None]:
emails = person.get("emails") or []
phones = person.get("phones") or []
personal = next(
(e for e in emails
if e.get("type") == "personal" and e.get("confidence") in ("high", "moderate")),
None,
)
if personal:
return "personal_email", personal["address"]
work = next(
(e for e in emails if e.get("type") == "professional" and e.get("current")),
None,
)
if work:
return "work_email", work["address"]
mobile = next(
(p for p in phones if p.get("type") == "mobile" and p.get("current")),
None,
)
if mobile:
return "mobile", mobile["number"]
return None, Nonefunction pickChannel(person) {
const emails = person.emails ?? [];
const phones = person.phones ?? [];
const personal = emails.find(
(e) => e.type === "personal" && ["high", "moderate"].includes(e.confidence),
);
if (personal) return ["personal_email", personal.address];
const work = emails.find((e) => e.type === "professional" && e.current);
if (work) return ["work_email", work.address];
const mobile = phones.find((p) => p.type === "mobile" && p.current);
if (mobile) return ["mobile", mobile.number];
return [null, null];
}Treat the phone as a channel for a person-to-person call, not an automated one. FCC rules under the TCPA require consent before an autodialed or prerecorded call or text to a mobile number, so a recruiter dialing by hand is a different thing from a sequencer texting a list. Check the rules with your counsel before you put phone outreach on autopilot.
Check your ATS before you send
The single largest pool of engineers you can hire is the one you have already met. Gem's same report found that 46 percent of sourced hires now come from rediscovered candidates, people already sitting in a company's ATS or CRM, up from 26 percent in 2021. Before a new name goes into a sequence, check whether they are already a known candidate, and route known candidates to the recruiter who owns the relationship instead of sending a cold message to someone you interviewed last year.
def split_known(people: list[dict], ats_emails: set[str], ats_linkedins: set[str]):
new, known = [], []
for person in people:
addresses = {
e["address"].lower() for e in person.get("emails") or [] if e.get("address")
}
linkedin = (person.get("linkedin_url") or "").lower()
if addresses & ats_emails or (linkedin and linkedin in ats_linkedins):
known.append(person)
else:
new.append(person)
return new, knownfunction splitKnown(people, atsEmails, atsLinkedins) {
const fresh = [];
const known = [];
for (const person of people) {
const addresses = (person.emails ?? [])
.filter((e) => e.address)
.map((e) => e.address.toLowerCase());
const linkedin = (person.linkedin_url ?? "").toLowerCase();
const inAts =
addresses.some((a) => atsEmails.has(a)) ||
(linkedin && atsLinkedins.has(linkedin));
(inAts ? known : fresh).push(person);
}
return { fresh, known };
}The rediscovery pass works in the other direction too. ATS records go stale: the candidate who was a mid-level engineer when you spoke two years ago may now be a staff engineer at a different company. Refreshing those records with person enrichment, keyed on the LinkedIn URL or email you already hold, turns an old applicant list into a current sourcing list. The real-time enrichment pipeline post covers the refresh pattern.
Put it together
The full loop is short. Search by role and experience, size the pool, pull it, drop anyone already in your ATS, and pick one channel per remaining candidate.
people = pull_pool(QUERY, max_people=500)
new, known = split_known(people, ats_emails=set(), ats_linkedins=set())
for person in new:
channel, address = pick_channel(person)
if channel:
print(person["full_name"], person.get("job_title"), channel, address)const people = await pullPool(QUERY, 500);
const { fresh } = splitKnown(people, new Set(), new Set());
for (const person of fresh) {
const [channel, address] = pickChannel(person);
if (channel) console.log(person.full_name, person.job_title, channel, address);
}What this pipeline does not do is write the message. The search finds the engineer and the channel; whether they reply depends on whether your note says something specific about their work. The record carries their title, employer, and years in the field, and an enrichment call adds their full work history and skills. Use it.
For more ways to put person search to work beyond outbound, including market sizing and workforce research, see Person Search Beyond Prospecting. If you would rather describe the search in plain English, natural language search translates it into the same query. The full list of searchable columns is at www.datalegion.ai/docs/api-reference/person-search.
Build it with a coding agent
To scaffold this pipeline in one pass, paste the prompt below into Claude Code, Codex, or Cursor:
Build an engineering sourcing pipeline on the Data Legion Person Search API.
- POST https://api.datalegion.ai/person/search
- Header: API-Key: $DATALEGION_API_KEY
- Body: {"query": "SELECT * FROM people WHERE ...", "limit": 100,
"offset": 0, "include_fields": "full_name,job_title,company_name,city,
state,years_of_experience,linkedin_url,emails,phones"}
- Response: {"matches": [{"person": {...}}], "total": <count>}. The
Total-Count-Status header says whether total is exact or an estimate.
- Filter on job_function, years_of_experience, city/state and current
job_title keywords, e.g. job_title ILIKE '%backend%'. Keep filters
selective: a search that runs past 30 seconds returns HTTP 408.
- Each email has type ("professional" or "personal"), confidence
("high", "moderate", "low") and, for work emails, current. Each phone
has type ("mobile" ...), current and confidence.
References for context (read these first):
- The walkthrough this is based on:
https://www.datalegion.ai/blog/post/sourcing-engineers-outside-linkedin
- Person Search API: https://www.datalegion.ai/docs/api-reference/person-search
- Person field schema: https://www.datalegion.ai/docs/person-data/schema
Requirements:
- A search function that sizes the pool (total + Total-Count-Status) before
paging.
- Paging with limit 100, offset up to 10,000, under 50 searches per minute.
- Deduplicate against an existing ATS export by email and LinkedIn URL.
- Pick one contact channel per new candidate with a configurable order.
- Write the shortlist to CSV with name, title, company, channel and address.
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.