"Scraping a LinkedIn profile in Python" usually means one thing: you have a profile URL (or a name, or an email) and you want the structured data behind it — full name, headline, job title, company, location — in a dict you can drop into a database or CRM.
The DIY route is harder than it looks. LinkedIn blocks unauthenticated requests, renders profiles with JavaScript so plain requests + BeautifulSoup returns almost nothing, rate-limits and bans datacenter IPs, and — most importantly — automating a logged-in account is the fastest way to get that account restricted. Maintaining a Selenium fleet with rotating proxies is a project in itself, and a brittle one.
This tutorial takes the reliable path instead: a single REST endpoint that returns the parsed profile for a URL. No browser to drive, no session to keep alive, no ban risk on your own account. Everything below is Python-first (with cURL and Node alternates), runs against POST https://api.linkfinderai.com, and costs 1 credit per call — including lookups that come back empty. The free tier's 100 credits is enough to follow the whole guide.
HTTP 999 or a login wall for unauthenticated requests, the real content is JS-rendered, and scripting a logged-in session risks your account. The API approach sidesteps all of it.
import requests
# Plain GET against a public profile — this does NOT work
resp = requests.get("https://www.linkedin.com/in/john-doe")
print(resp.status_code) # 999 (LinkedIn blocks the request)
print("name" in resp.text) # False — content is JS-rendered behind authGet your API key
Create a free account and copy your key from the dashboard under Settings → API Key. You get 100 credits to start — enough to scrape ~100 profiles while you test.
Keep the key out of your code. Set it as an environment variable and read it at runtime so it never lands in a commit or a notebook you share.
# Add to your shell profile or a .env file (never commit it)
export LINKFINDER_API_KEY="lf_live_your_key_here"import os
API_KEY = os.environ["LINKFINDER_API_KEY"]
BASE_URL = "https://api.linkfinderai.com"Scrape your first profile
One POST, two fields. Set type to linkedin_profile_to_linkedin_info and pass the profile URL as input_data. The API does the heavy lifting server-side and hands you parsed JSON — no browser, no proxy, no login.
import os, requests
API_KEY = os.environ["LINKFINDER_API_KEY"]
resp = requests.post(
"https://api.linkfinderai.com",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={
"type": "linkedin_profile_to_linkedin_info",
"input_data": "https://linkedin.com/in/john-doe",
},
timeout=30,
)
data = resp.json()
if data["status"] == "success":
profile = data["result"]
print(profile["full_name"], "—", profile["job_title"], "@", profile["company_name"])
else:
print("No data:", data.get("message"))curl -X POST "https://api.linkfinderai.com" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $LINKFINDER_API_KEY" \
-d '{
"type": "linkedin_profile_to_linkedin_info",
"input_data": "https://linkedin.com/in/john-doe"
}'const API_KEY = process.env.LINKFINDER_API_KEY;
const resp = await fetch("https://api.linkfinderai.com", {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "linkedin_profile_to_linkedin_info",
input_data: "https://linkedin.com/in/john-doe",
}),
});
const data = await resp.json();
if (data.status === "success") {
const p = data.result;
console.log(`${p.full_name} — ${p.job_title} @ ${p.company_name}`);
}linkedin_company_to_linkedin_info and pass a https://linkedin.com/company/... URL.Read the profile data
Every response wraps your data in a status + result envelope. Branch on status first — a 200 can still come back "error" with a null result if the profile couldn't be resolved. On success, result is the parsed profile:
{
"status": "success",
"result": {
"full_name": "Sarah Mitchell",
"job_title": "VP of Sales",
"seniority_level": "vp",
"company_name": "CloudCore",
"company_domain": "cloudcore.io",
"company_size": "51-200",
"industry": "Software",
"city": "Austin",
"state": "Texas",
"country": "United States",
"linkedin": "https://linkedin.com/in/sarah-mitchell-sales"
}
}{
"result": null,
"status": "error",
"message": "Profile not found"
}From here it's plain Python — index into the dict, write to a CSV, or insert into your database. The exact field list lives in the API docs; treat any field defensively with .get() since not every profile exposes every attribute.
null still costs 1 credit. De-duplicate your URL list and cache results so you never pay twice for the same profile.Scrape from just a name or email
Often you don't have the profile URL yet — only a name and company, or an email from a signup form. Resolve the URL first, then scrape it. A small helper keeps every call to one line:
import os, requests
API_KEY = os.environ["LINKFINDER_API_KEY"]
BASE_URL = "https://api.linkfinderai.com"
def call(enrichment_type, input_data, **extra):
"""One request = one credit. Returns the result or None."""
resp = requests.post(
BASE_URL,
headers={"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json"},
json={"type": enrichment_type, "input_data": input_data, **extra},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
return data.get("result") if data.get("status") == "success" else None
# From a name + company
url = call("lead_full_name_to_linkedin_url", "Sarah Mitchell CloudCore")
# ...or reverse-lookup the URL from an email
url = url or call("email_to_linkedin_url", "[email protected]")
# Then scrape the profile behind it
profile = call("linkedin_profile_to_linkedin_info", url) if url else None
print(profile)That's the building block for the rest of the guide — every scrape is just call("linkedin_profile_to_linkedin_info", some_url).
Scrape a list of profiles in bulk
Reading a CSV of profile URLs and scraping each row is the common job. Two things matter at volume: stay under your plan's requests-per-second limit, and retry transient failures instead of crashing halfway through. Here's a bulk scraper that does both.
import os, csv, time, requests
API_KEY = os.environ["LINKFINDER_API_KEY"]
BASE_URL = "https://api.linkfinderai.com"
INPUT = "profiles.csv" # one column: linkedin_url
OUTPUT = "profiles_scraped.csv"
REQS_PER_SEC = 5 # match your plan (Starter = 5/s)
DELAY = 1.0 / REQS_PER_SEC
def scrape(url, max_retries=4):
delay = 1.0
for _ in range(max_retries):
resp = requests.post(
BASE_URL,
headers={"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json"},
json={"type": "linkedin_profile_to_linkedin_info",
"input_data": url},
timeout=30,
)
if resp.status_code in (429, 500): # back off: 1s -> 2s -> 4s -> 8s
time.sleep(delay); delay *= 2; continue
if resp.status_code == 402:
raise RuntimeError("Out of credits — top up to continue.")
resp.raise_for_status()
data = resp.json()
return data.get("result") if data.get("status") == "success" else None
return None # gave up after repeated rate limiting
FIELDS = ["full_name", "job_title", "company_name", "industry", "city", "country", "linkedin"]
with open(INPUT, newline="") as f_in, open(OUTPUT, "w", newline="") as f_out:
reader = csv.DictReader(f_in)
writer = csv.DictWriter(f_out, fieldnames=FIELDS, extrasaction="ignore")
writer.writeheader()
for row in reader:
profile = scrape(row["linkedin_url"])
if profile:
writer.writerow(profile)
print("scraped:", profile.get("full_name"))
else:
print("skipped:", row["linkedin_url"])
time.sleep(DELAY) # stay under the rate limit
print("Done.")The codes you'll actually hit, and how the script handles each:
| Code | Meaning | What to do |
|---|---|---|
| 200 | Success | Check result — may be null (still 1 credit) |
| 401 | Unauthorized | Missing/invalid key — check the Authorization header |
| 402 | Insufficient credits | Top up or wait for the next billing cycle |
| 422 | Invalid request | Verify the type and input_data format |
| 429 | Rate limit exceeded | Back off exponentially: 1s, 2s, 4s |
| 500 | Server error | Retry after ~30s; contact support if it persists |
| Plan | Credits / mo | Requests / sec | Batch size |
|---|---|---|---|
| Starter | 5,000 | 5 req/s | Up to 500 |
| Professional | 20,000 | 10 req/s | Up to 500 |
| Enterprise | 50,000 | 20 req/s | Up to 500 |
| HyperGrowth | 250,000 | 50 req/s | Up to 500 |
Also pull email, phone & company data
Scraping the profile gives you the firmographics. To turn it into a contactable record, add two more one-credit calls off the same URL — and pull the full company page if you want headcount and industry detail:
# Reuse the call() helper from Step 4
url = "https://linkedin.com/in/sarah-mitchell-sales"
record = {
"profile": call("linkedin_profile_to_linkedin_info", url),
"email": call("linkedin_profile_to_email", url), # verified work email
"phone": call("linkedin_profile_to_phone", url), # direct number
}
# Optional: scrape the company page behind the profile
domain = record["profile"].get("company_domain") if record["profile"] else None
if domain:
record["company"] = call("company_name_to_employee_count", domain)
print(record["email"], record["phone"])That's four calls (four credits) for a complete contact: full profile, email, phone, and company size. Each is the same POST with a different type — there's no separate endpoint to learn.
leads_finder_ai endpoint takes a plain-English description ("VP Sales at B2B SaaS in the US") and returns up to 100 fully-scraped profiles in a single call — see the docs.Start scraping profiles in Python today
Skip the proxies, the headless browsers, and the banned accounts. Get structured LinkedIn profile data from one REST call — 100 free credits, an API on every plan, flat 1-credit pricing.
Get your API keyNo credit card required • API on every plan • Flat pricing • Cancel anytime