X-Ray Sourcing After Google CSE: Rebuilding site:linkedin.com/in Search
If your sourcing stack includes a LinkedIn X-ray tool — anything that runs site:linkedin.com/in boolean strings for you — there's a date you need to know: January 1, 2027. That's when the Google Custom Search JSON API, the plumbing under a whole generation of X-ray tools, stops serving. It's already closed to new customers.
The technique doesn't have to die with the transport. On July 21, 2026 we rebuilt X-ray sourcing on a SERP API and ran a 14-pattern query pack live: role+location, boolean skills, seniority, open-to-work, exclusions, company pages, deep pulls. Every pattern returned 100% on-target LinkedIn URLs. This post publishes the pack, the measured coverage, a parsing recipe that turns results into a candidate CSV, and the honest limits — including the one that put 17 people named Austin into a search for engineers in Austin.
TL;DR: X-ray sourcing survives the CSE shutdown — the same site:linkedin.com/in strings run through one HTTPS call to a SERP API. In our 14 live runs: 125 of 125 profile results had titles that parse cleanly as Name - Headline | LinkedIn, single-page pulls returned ~7 usable profiles each, deep pulls returned 28 and 20 unique URLs before honestly reporting exhaustion, and about a fifth of snippets carried a structured Location: line. City terms collide with first names — keep a human review step.
The extinction date for CSE-based X-ray tools
X-ray search is older than most of the tools that sell it: point a general web search index at LinkedIn from the outside, restrict to public profile pages with site:linkedin.com/in, and narrow with boolean terms. No login, no InMail credits, no LinkedIn API agreement — just the public search listing of public pages.
The tooling that automated the technique was overwhelmingly built on Google's Custom Search / Programmable Search Engine. Free X-ray tools, sourcing-tool browser extensions, internal recruiting dashboards — a large share of them are thin wrappers around one endpoint: the Custom Search JSON API.
That endpoint is going away. Google's own developer page states it plainly — "The Custom Search JSON API is closed to new customers" and "Existing Custom Search JSON API customers have until January 1, 2027 to transition to an alternative solution" (developers.google.com, verified July 21, 2026). The formal announcement went out on Google's Programmable Search Engine blog on January 20, 2026.
For recruiting teams that means two things. Nobody can build a new tool on CSE today — sign-ups are closed. And every existing API-driven X-ray tool has a hard stop: on January 1, 2027 its search calls start returning errors. If a vendor hasn't told you what their tool runs on, that's now a fair question to ask.
This post is deliberately scoped to the recruiting rebuild. For the full migration landscape — Vertex AI Search, why it isn't a drop-in, all four options compared — read our CSE shutdown migration guide. If you're migrating an existing CSE code integration field-by-field, the drop-in replacement code post has tested shims in Node, Python and Go.
The rebuild: one HTTPS call
The entire replacement is a GET request. The boolean string you already use goes in q, unchanged:
curl "https://apiserpent.com/api/search/quick" \
-H "X-API-Key: YOUR_API_KEY" \
--data-urlencode 'q=site:linkedin.com/in "software engineer" "austin"' --get
Two endpoints matter for sourcing. /api/search/quick returns a single page (~10 results) fast — use it to triage whether a boolean string is worth mining. /api/search takes num up to 100 and pages internally — use it when a pattern proves out and you want depth. Parameters are in the docs, and you can run every query in this post from the playground without writing code.
Here's the actual response shape from the run above (trimmed to the fields a sourcer reads; full raw output in our research archive):
{
"results": {
"organic": [
{
"position": 1,
"title": "Tim Xu - Sr. Software Engineer | LinkedIn",
"url": "https://www.linkedin.com/in/tim-xu-4b9b4b203",
"snippet": "Sr. Software Engineer · Experience: Broadcom Software ·
Education: Texas A&M University · Location: Austin ·
500+ connections on LinkedIn. ..."
}
]
},
"meta": {
"partialResults": { "requested": 10, "returned": 7, "reason": "engine_exhausted" }
}
}
Name, current title, company, education, location — that's a sourcing record, sitting in a public search listing, one call away. Now let's measure how far the patterns actually reach.
The query pack: 14 patterns, all run live
We ran every pattern below against the live API on July 21, 2026, paced at roughly one call per second, and archived the raw JSON of each run. "Usable" means results whose URL is the intended LinkedIn page type — /in/ profiles for people patterns, /company/ for the company pattern.
| Pattern | Query | Usable | On-target URLs | Notes from the run |
|---|---|---|---|---|
| Role + city | site:linkedin.com/in "software engineer" "austin" | 7 | 100% /in/ | Titles like "Tim Xu - Sr. Software Engineer | LinkedIn"; snippet carries Experience/Education/Location |
| Role + company | site:linkedin.com/in "product manager" "at google" | 7 | 100% /in/ | "at Google" surfaces in headlines: "Michael Smart - Product Manager at Google" |
| intitle: role | site:linkedin.com/in intitle:"data engineer" "chicago" | 7 | 100% /in/ | Forces the role into the title/headline, not just the About text |
| Skills boolean | site:linkedin.com/in "devops" (kubernetes OR terraform) "london" | 7 | 100% /in/ | OR-groups work; snippets quote CI/CD and IaC project text |
| Exclusions | site:linkedin.com/in "react developer" -"job" -"jobs" -"hiring" | 7 | 100% /in/ | Minus-quoted terms strip job-ad noise from the set |
| Seniority | site:linkedin.com/in "senior machine learning engineer" "san francisco" | 7 | 100% /in/ | Long quoted titles match exactly |
| Open to work | site:linkedin.com/in "open to work" "frontend developer" | 7 | 100% /in/ | Matches profiles that put the phrase in the headline; our run skewed to in.linkedin.com profiles |
| Company pages | site:linkedin.com/company "fintech" "berlin" | 7 | 100% /company/ | Same technique, employer research instead of people |
| Quoted exec title | site:linkedin.com/in "head of marketing" "new york" | 7 | 100% /in/ | "Kim Robinson - Head of Marketing, NYSE"; structured snippet with Location: New York |
| Non-tech role | site:linkedin.com/in "registered nurse" "houston" | 7 | 100% /in/ | Works identically outside tech: "Registered Nurse at Houston Methodist" |
| Certification | site:linkedin.com/in "aws certified solutions architect" | 7 | 100% /in/ | Certifications live in headlines: "Tim Asanansi - 5x AWS Certified" |
| Localized subdomain | site:uk.linkedin.com/in "software engineer" "london" | 7 | 100% uk. /in/ | Pin a country by targeting its LinkedIn subdomain directly |
| Deep pull | Role + city query, num=50 via /api/search | 28 unique | 100% /in/ | meta.partialResults: requested 50, returned 28, reason engine_exhausted |
| Deep pull #2 | site:linkedin.com/in "nurse practitioner" "dallas", num=30 | 20 unique | 100% /in/ | Same honest exhaustion metadata; zero duplicate URLs |
The headline number: 14 of 14 patterns returned 100% on-target LinkedIn URLs — zero off-domain noise to filter. Single-page latency ranged 2.9–9.2 s across the pack; the deep pulls took ~20 s each.
Notice what the chart is really saying: X-ray queries are narrow. A tightly quoted boolean string matches a thin slice of profiles, so a single page returning ~7 usable rows is the normal case, not a failure. Sourcing volume comes from running many patterns — rotate synonyms ("frontend" / "front-end" / "UI engineer"), companies, and adjacent cities — and deduping the union, which is exactly what the CSV section automates.
What the API returns (and how to parse it)
Everything below is measured from the 125 profile results in our archived runs, not inferred from docs.
URL shapes. Profile URLs come back as https://www.linkedin.com/in/<slug>, where the slug is either a chosen vanity (austin-curtis-engineer) or a name-plus-hash default (tim-xu-4b9b4b203). Localized subdomains — uk.linkedin.com, ca.linkedin.com, in.linkedin.com — appear organically inside site:linkedin.com/in result sets, and you can target one deliberately (pattern 12). Practical consequence: dedupe on the /in/<slug> path, not the full URL, because the same person can surface under different subdomains.
The title is your parser's best friend. Every one of the 125 profile results — 125 of 125 — followed the shape Name - Headline | LinkedIn. Strip the | LinkedIn suffix, split on the first " - ", and you have a name column and a headline column. Long headlines arrive truncated with an ellipsis; treat the headline as a preview, not a complete record.
Snippets come in three flavors. In our runs, about 22% were structured — Headline · Experience: Broadcom Software · Education: Texas A&M University · Location: Austin · 500+ connections — which is machine-parsable gold: current employer, school and city in one line. The rest were either free-text excerpts from the profile's About section (still useful for skim-reading skills) or the generic "View NAME's profile on LinkedIn" boilerplate carrying nothing extra. Build your parser to optionally harvest Location: and Experience: when present, never to require them.
One field to ignore: displayedUrl on these results is the literal string "LinkedIn", not an address. Parse url.
Depth, pagination and the exhaustion metadata
CSE-era X-ray tools paged with start=1, 11, 21…, ten results a call. The replacement flips that: ask /api/search for num=50 and it does the paging internally, returning one merged list with sequential position values.
What you actually get back is bounded by the query. Our role+city deep pull requested 50 and returned 28 unique URLs; the nurse-practitioner pull requested 30 and returned 20. Both responses said so honestly in a public metadata field instead of padding:
"partialResults": {
"requested": 50,
"returned": 28,
"reason": "engine_exhausted",
"note": "The search engine returned all 28 available results for this query —
fewer than the 50 requested."
}
When you see engine_exhausted, that pattern is mined out — broaden the boolean or rotate to the next pattern rather than re-paying for the same shallow pool.
Dedupe, measured. Inside each deep pull we found zero duplicate URLs (28 of 28 and 20 of 20 unique). Across two pulls of the identical query — the quick page-1 call and the num=50 deep call — 5 of the quick call's 7 URLs also appeared in the deep set, giving a union of 30 unique profiles. So: dedupe across calls (result sets overlap run to run), and expect each fresh pull of a narrow query to add only a few genuinely new rows.
From results to a candidate CSV
This is the complete pipeline — X-ray query in, spreadsheet-ready candidate list out. Node 18+, zero dependencies. We ran this exact file against the live API on July 21, 2026:
// xray-to-csv.js — X-ray search -> candidate CSV (name, headline, url, location)
// Node 18+, zero dependencies. Run: SERPENT_API_KEY=sk_... node xray-to-csv.js
const API_KEY = process.env.SERPENT_API_KEY; // get one free at apiserpent.com
async function xrayToCsv(query, num = 30) {
const url = new URL('https://apiserpent.com/api/search');
url.searchParams.set('q', query);
url.searchParams.set('num', String(num));
const res = await fetch(url, { headers: { 'X-API-Key': API_KEY } });
if (!res.ok) throw new Error(`API error ${res.status}`);
const data = await res.json();
const rows = [['name', 'headline', 'url', 'location']];
for (const r of data.results.organic) {
if (!/linkedin\.com\/in\//.test(r.url)) continue; // profiles only
// Title shape: "Name - Headline | LinkedIn"
const m = r.title.replace(/ \| LinkedIn$/, '').match(/^(.+?) [-–] (.+)$/);
if (!m) continue;
// Structured snippets carry "Location: <city>" — free-text ones don't
const loc = ((r.snippet || '').match(/Location: ([^·]+)/) || [])[1] || '';
rows.push([m[1], m[2], r.url.split('?')[0], loc.trim()]);
}
const esc = (v) => /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
return rows.map((row) => row.map(esc).join(',')).join('\n');
}
xrayToCsv('site:linkedin.com/in "software engineer" "austin, texas"')
.then((csv) => console.log(csv));
Real output from that run — 21 candidate rows from one num=30 call; the first six, exactly as emitted:
name,headline,url,location
Aaron Austin,Software Engineer,https://www.linkedin.com/in/aaronaustintx,Austin ...
Austin Curtis,Senior Software Engineer,https://www.linkedin.com/in/austin-curtis-engineer,
Austin Marino,Software Engineer,https://www.linkedin.com/in/austinmarino,
Austin Bovee,Lead Software Engineer,https://www.linkedin.com/in/austin-bovee,
Zach Austin,Staff Software Engineer,https://www.linkedin.com/in/zacharysaustin,
Austin Pittman,Software Engineer,https://www.linkedin.com/in/austinpittmanase,
Which brings us to the output's most instructive feature: look at those names.
Honest limits
The Austin problem: city terms collide with first names. In our deep pull for "software engineer" "austin", 17 of 28 results were people named Austin, and only 3 snippets explicitly said Location: Austin. Rewriting the query as "austin, texas" helped but didn't cure it — Texans named Austin still match, as the CSV above shows. The honest fix is layered: quote "city, state", harvest the Location: snippet field when present, and keep a human review pass before anyone gets an email. This failure mode is invisible unless you actually look at the rows — which is why we published them.
Coverage is index coverage. X-ray sees profiles as a web index sees them: public profiles that are indexed. Members who restrict their public profile visibility won't surface, and no query syntax changes that. Treat X-ray as a wide, cheap first net — not a census.
Depth is bounded. As measured above: ~7 usable rows on a single page, 20–28 unique URLs on deep pulls before engine_exhausted. Plan volume as patterns × ~20, not pages × 10.
Headlines go stale. The title and snippet reflect the profile as last indexed, not as it is this morning. People change jobs; "500+ connections" snippets linger. Verify current employment on the profile itself before outreach — the search listing is a lead, not a fact-check.
Localized subdomains cut both ways. They're a free country filter when targeted (site:uk.linkedin.com/in), but they also mean a plain site:linkedin.com/in set mixes subdomains — dedupe on the /in/ slug, and don't assume subdomain equals current residence.
If you need structured, always-fresh profile fields rather than search-listing data, that's a different product category — we compared the options honestly in the best LinkedIn data APIs in 2026, and our recruiting pipeline guide covers the structured-data workflow. This post's lane is the SERP technique: cheapest per lead, widest net, listing-level fields.
Ethics, ToS and person-data care
Short and practical, because sourcing tools live or die on this.
Public SERP data only. Everything in this post reads public search listings of public pages. Don't scrape behind LinkedIn's login wall, don't automate logged-in accounts — that's where the serious ToS and legal exposure lives. The case law around public data is genuinely nuanced; our neutral guide to whether scraping LinkedIn is legal walks through hiQ, Van Buren and the ToS front without cheerleading.
Person-data care. The moment you store candidate rows, privacy law applies. Under GDPR, recruiter outreach typically leans on a legitimate-interest basis — which comes with duties: be transparent about where you got the data, honor deletion requests promptly, don't hoard stale candidate lists, and keep the data minimal (name, headline, public URL — not a shadow profile). Our wider primer on legal and ethical search-data collection covers the framework.
Rate discipline. Pace your calls (we ran this entire study at ~1 request/second), cache raw responses so you never re-fetch what you already have, and respect the platform you're reading. Boring, and effective.
Rebuild your X-ray stack before the deadline
Serpent is a pay-as-you-go SERP API: send your boolean strings to one endpoint, get structured JSON back — up to 100 results per call, with honest partial-result metadata. 10 free searches to test your query pack, then flat per-call pricing from $0.60 per 1,000. No subscription, no Cloud project, no cx ID.
Explore: Google SERP API · Pricing · SERP APIs for lead generation
FAQ
What is LinkedIn X-ray search?
Pointing a general web search index at LinkedIn from the outside: site:linkedin.com/in restricts results to public profile pages, boolean terms narrow to a role, skill or place. You read the public listing — title, URL, snippet — without logging in to LinkedIn at all.
Does X-ray sourcing still work after Google CSE shuts down?
The technique survives; the transport dies. The Custom Search JSON API is closed to new customers and retires January 1, 2027, breaking tools built on it. The same queries run unchanged through a SERP API — in our July 21, 2026 test, 14 of 14 patterns returned 100% on-target LinkedIn URLs.
How many profiles does one X-ray query return?
Narrow queries are shallow by nature. Measured: ~7 usable profiles on a single page; deep pulls with num=50 and num=30 returned 28 and 20 unique URLs before meta.partialResults reported exhaustion. Volume comes from running many patterns and deduping the union.
Can I filter by city reliably?
Not by syntax alone — 17 of 28 results in our quoted "austin" deep pull were people named Austin. Use "city, state" phrasing, read the Location: field present in ~22% of snippets, and keep a human review step before outreach.
Do I need a LinkedIn account or login?
No. X-ray reads public web-search listings of public profile pages. That's also the ethical line: public SERP data only, never logged-in scraping, and honor deletion requests once you store candidate data.
What happens to free X-ray tools built on CSE?
Tools calling the Custom Search JSON API stop working on January 1, 2027, and no new CSE keys are being issued today. Ask any X-ray tool vendor what their search calls run on — if the answer is CSE, they have a deadline too.



