If your CRM sync, recruiting tool or lead-scoring pipeline called Proxycurl, it broke in July 2025 when Proxycurl shut down after LinkedIn's lawsuit. Since then a lot of teams have been looking for a replacement that returns the same kind of data: a LinkedIn URL goes in, a structured professional profile comes out.
This guide covers:
- Your options in 2026, and their trade-offs
- What data you can get from a LinkedIn URL
- Working Python code for profiles, companies and people search
- A Proxycurl → new API field mapping so your migration is a find-and-replace, not a rewrite
- Bulk enrichment of thousands of URLs, with rate limits and retries
- FAQ: legality, freshness, emails, cost
The examples use EnrichGrid, the API I work on. The patterns (polling, retries, concurrency, field mapping) apply to any provider.
Your options in 2026
| Option | Profile data? | Effort | Main risk |
|---|---|---|---|
| Official LinkedIn API | Only the signed-in member's own profile, or partner programs with approval | Apply, wait, hope | Partner access is rarely granted for enrichment |
| Build your own scraper | Yes | High: proxies, logins, captchas, constant breakage | Accounts get restricted, and maintenance never ends |
| Browser extensions that scrape inside your session | Yes | Low | They act through your LinkedIn account |
| Data APIs (EnrichGrid, People Data Labs, Coresignal, Bright Data datasets…) | Yes | Low: one HTTP call | Varies by vendor: freshness, pricing model, coverage |
For most products, a data API is the practical choice: no LinkedIn account involved, no scraping infrastructure, predictable JSON. When you compare vendors, check four things:
- Freshness. Is the profile collected live or from a months-old dump?
- Pricing model. Are you charged per request or per found profile? On real-world lists, 10–30% of URLs are outdated, so this decides your bill.
- Field coverage. Do you get full work history, or only the current job?
- Minimums. Annual contracts and seat fees vs pay-as-you-go.
What data you can get from a LinkedIn URL
A person lookup returns, for a typical public profile:
- name, headline, summary, location and country
- current title, seniority (C-level, VP, director…) and job function
- current company with its website, headcount and industry
- every past job with start and end dates
- education with degrees and fields of study
- skills with endorsement counts
- certifications, recommendations, courses, honors, publications
- connections and followers
A company lookup returns firmographics: industry, headcount and range, website, HQ, founding year, type, specialties, description and followers.
What you won't get from a compliant provider: private emails or phone numbers scraped from profiles. Treat any vendor that promises personal contact details "from LinkedIn" with caution.
Step by step in Python
1. Get an API key
Create a free account at enrichgrid.com (50 free credits, no card) and copy the key from Dashboard → API keys. Keep it in an environment variable:
export ENRICHGRID_KEY="lk_..."
pip install requests
2. Enrich a person
import os
import time
import requests
API = "https://enrichgrid.com/v1"
HEADERS = {"x-api-key": os.environ["ENRICHGRID_KEY"]}
def enrich_person(linkedin_url: str) -> dict | None:
"""Return the profile dict, or None when the profile doesn't exist (not charged)."""
r = requests.post(f"{API}/enrich/person", headers=HEADERS,
json={"identifier": linkedin_url}, timeout=60)
r.raise_for_status()
data = r.json()
# Rare profiles are collected live and answer 202 with a URL to poll.
while data["status"] == "pending":
time.sleep(2.5)
data = requests.get(f"https://enrichgrid.com{data['poll']}", headers=HEADERS, timeout=30).json()
return data["profile"] if data.get("found") else None
profile = enrich_person("https://www.linkedin.com/in/williamhgates")
print(profile["first_name"], profile["last_name"], "-", profile["headline"])
print("Now:", profile["current_title"], "at", profile["current_company_name"])
The identifier accepts a full URL, the slug (williamhgates) or the profile ID, so you don't have to normalize URLs first.
The response is the same shape every time, which keeps parsing simple:
{
"id": "7f3c2a9e…",
"status": "done",
"found": true,
"credits_charged": 1,
"kind": "person",
"profile": {
"first_name": "…",
"headline": "…",
"current_title": "…",
"current_company_name": "…",
"seniority": "cxo",
"location": "…",
"sections": {
"experiences": [{"title": "…", "company_name": "…", "started_on": "2015-05-01", "ended_on": null, "is_current": true}],
"educations": [{"school_name": "…", "degree_name": "…", "field_of_study": "…"}],
"skills": [{"name": "…", "endorsement_count": 66}]
}
},
"credits_left": 4999
}
3. Walk the work history
Each job in sections.experiences carries the employer's firmographics in entreprise, so you get company size and website without a second call:
for job in profile["sections"]["experiences"]:
company = job.get("entreprise") or {}
end = job["ended_on"] or "present"
print(f'{job["title"]} @ {job["company_name"]} ({job["started_on"]} → {end})'
f' · {company.get("staff_count_range", "?")} employees · {company.get("website_url", "")}')
4. Enrich a company
def enrich_company(identifier: str) -> dict | None:
r = requests.post(f"{API}/enrich/company", headers=HEADERS, json={"identifier": identifier}, timeout=60)
r.raise_for_status()
data = r.json()
return data["company"] if data.get("found") else None
c = enrich_company("https://www.linkedin.com/company/microsoft")
print(c["name"], c["industry"], c["staff_count_range"], c["website_url"], c["hq_city"])
5. Search people by criteria
Find people by title, company, location, company size, tenure or school. Each page returns up to 10 profile slugs, which you then enrich:
def search_people(filters: dict, pages: int = 3) -> list[str]:
slugs, cursor = [], None
for _ in range(pages):
r = requests.post(f"{API}/search", headers=HEADERS,
json={"filters": filters, "cursor": cursor}, timeout=60)
r.raise_for_status()
data = r.json()
slugs += data["slugs"]
cursor = data["next_cursor"]
if not cursor:
break
return slugs
# Heads of sales at 11–200 person companies in Germany
slugs = search_people({
"country": ["DE"],
"title": {"any": ["head of sales", "vp sales"], "zones": ["current"]},
"company_size": {"min": 11, "max": 200},
})
leads = [enrich_person(s) for s in slugs]
Available filters: country, city, title, company, past_company, company_size, company_industry_id, industry_id, school, career_years, tenure_years. They combine with AND.
Migrating from Proxycurl: field mapping
Endpoints
| Proxycurl | EnrichGrid |
|---|---|
GET /proxycurl/api/v2/linkedin?url=… |
POST /v1/enrich/person with {"identifier": url}
|
GET /proxycurl/api/linkedin/company?url=… |
POST /v1/enrich/company |
GET /proxycurl/api/linkedin/school?url=… |
POST /v1/enrich/school |
| Person Search API | POST /v1/search |
Authorization: Bearer <key> |
x-api-key: <key> |
Person fields
| Proxycurl field | EnrichGrid field |
|---|---|
public_identifier |
slug |
first_name, last_name
|
first_name, last_name
|
headline |
headline |
summary |
summary |
occupation |
current_title + current_company_name
|
city, country
|
location, location_country
|
experiences[].title |
sections.experiences[].title |
experiences[].company |
sections.experiences[].company_name |
experiences[].starts_at / ends_at (day/month/year objects) |
sections.experiences[].started_on / ended_on (ISO dates) |
education[].school |
sections.educations[].school_name |
education[].degree_name, field_of_study
|
same names under sections.educations[]
|
skills (list of strings) |
sections.skills[].name (+ endorsement_count) |
connections, follower_count
|
num_connections, follower_count
|
A small adapter keeps the rest of your code untouched:
def to_proxycurl_shape(p: dict) -> dict:
"""Map an EnrichGrid profile to the Proxycurl fields most code reads."""
s = p.get("sections", {})
def ymd(d):
if not d:
return None
y, m, dd = (int(x) for x in d.split("-"))
return {"year": y, "month": m, "day": dd}
return {
"public_identifier": p.get("slug"),
"first_name": p.get("first_name"),
"last_name": p.get("last_name"),
"full_name": " ".join(filter(None, [p.get("first_name"), p.get("last_name")])),
"headline": p.get("headline"),
"summary": p.get("summary"),
"occupation": " at ".join(filter(None, [p.get("current_title"), p.get("current_company_name")])),
"country": p.get("location_country"),
"connections": p.get("num_connections"),
"follower_count": p.get("follower_count"),
"experiences": [{"title": e.get("title"), "company": e.get("company_name"),
"starts_at": ymd(e.get("started_on")), "ends_at": ymd(e.get("ended_on"))}
for e in s.get("experiences", [])],
"education": [{"school": e.get("school_name"), "degree_name": e.get("degree_name"),
"field_of_study": e.get("field_of_study")} for e in s.get("educations", [])],
"skills": [k["name"] for k in s.get("skills", [])],
}
Enriching thousands of URLs
For lists, run requests in parallel up to your plan's rate limit and respect Retry-After on 429:
from concurrent.futures import ThreadPoolExecutor
import csv
session = requests.Session()
session.headers.update(HEADERS)
def enrich_safe(url: str, attempts: int = 5) -> dict:
for _ in range(attempts):
r = session.post(f"{API}/enrich/person", json={"identifier": url}, timeout=90)
if r.status_code == 429 or r.status_code == 503: # rate limit or busy: nothing charged
time.sleep(float(r.headers.get("Retry-After", 2)))
continue
if r.status_code == 402:
raise RuntimeError("Out of credits")
r.raise_for_status()
data = r.json()
while data["status"] == "pending":
time.sleep(2.5)
data = session.get(f"https://enrichgrid.com{data['poll']}", timeout=30).json()
return {"url": url, "found": data.get("found"), **(data.get("profile") or {})}
return {"url": url, "found": None}
urls = [line.strip() for line in open("linkedin_urls.txt") if line.strip()]
with ThreadPoolExecutor(max_workers=5) as pool: # match your plan's requests/second
rows = list(pool.map(enrich_safe, urls))
with open("enriched.csv", "w", newline="", encoding="utf-8") as f:
fields = ["url", "found", "first_name", "last_name", "headline", "current_title",
"current_company_name", "seniority", "location", "location_country"]
w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
w.writeheader()
w.writerows(rows)
A few practical notes:
- Not-found profiles are free, so you don't need to pre-clean the list.
- Repeat lookups within 30 days come from cache, instantly, so re-running a job after a crash is cheap and fast.
- If you'd rather not write code, upload an Excel or CSV file in the dashboard (bulk enrichment) and download it with every field filled in.
Using it from an AI agent
The same data is available as an MCP server for Claude, Cursor and other agents. In Claude Code:
claude mcp add --transport http enrichgrid https://enrichgrid.com/mcp --header "x-api-key: $ENRICHGRID_KEY"
Then ask the agent things like "enrich these 20 LinkedIn URLs and rank them by seniority".
FAQ
Is there an official LinkedIn API for profile data?
Not for general enrichment. LinkedIn's APIs give access to the signed-in member's own data, and broader access goes through partner programs that are rarely granted for this use case.
Is it legal to use LinkedIn profile data?
Public professional data is widely used for B2B sales, recruiting and research, but you are responsible for how you use it. If you process data about people in the EU, you need a lawful basis under GDPR (often legitimate interest for B2B), and you must honor opt-out and deletion requests. Don't use it for spam.
How fresh is the data?
EnrichGrid collects profiles live and caches them for up to 30 days. You can force a fresh collection with "live": true at the same price.
Do I get emails or phone numbers?
No. The API returns professional profile data only.
How much does it cost?
One credit per profile or company found; lookups that find nothing are free. 50 free credits on sign-up, then plans from $29/month for 10,000 credits, down to about $0.001 per profile at volume. See pricing.
What are the rate limits?
From 2 requests/second on the free tier to 50 on Enterprise. Responses include X-RateLimit-* headers, and 429 responses include Retry-After.
Wrapping up
Replacing Proxycurl is mostly three changes: a new endpoint and auth header, a field-mapping adapter, and polling for the rare profile that's collected live. The rest of your pipeline can stay as it is.
- 📘 API reference: enrichgrid.com/api-docs
- 🔁 Migration guide: enrichgrid.com/proxycurl-alternative
- 🐍 Python guide: enrichgrid.com/guides/linkedin-data-python
If you migrated from Proxycurl to something else, I'd like to hear what worked for you in the comments.
Top comments (0)