If you searched "LinkedIn API Python," you probably want to do something simple: hand a script a LinkedIn profile (or a name, or an email) and get back structured data — title, company, email, phone, firmographics. Here's the catch most tutorials bury: the official LinkedIn API can't do that for the vast majority of developers.
LinkedIn's official API is gated behind partner programs. Without a formal, manually-reviewed partnership, all you get is "Sign In with LinkedIn" and the basic profile of the currently authenticated user — their name, headline, and photo — plus the ability to post on their behalf. There is no public endpoint to look up an arbitrary profile, search people, or pull contact data, and using these for lead generation is explicitly disallowed. The unofficial "Voyager" endpoints get accounts banned. For prospecting and enrichment work, the official route is a dead end.
So this tutorial does the thing you actually came for, in Python, using the LinkFinder AI API instead. Everything runs against a single endpoint — POST https://api.linkfinderai.com — and you pick what you want with a type field. One request costs one credit, including lookups that come back empty, so cost is easy to reason about. You only need the free tier (100 credits) to follow along, and you'll write idiomatic Python with requests, then scale it up with asyncio.
Why not the official LinkedIn API — and get your key
It's worth being precise about what the official API actually offers, so you don't burn a week applying for access you can't use:
| What you want | Official LinkedIn API | This tutorial |
|---|---|---|
| Your own profile after OAuth login | Yes | — |
| Look up an arbitrary profile by URL | No | Yes |
| Find a profile from a name or email | No | Yes |
| Append work email / phone | No | Yes |
| Company & employee data at scale | Partner only | Yes |
| Use for lead generation | Disallowed | Yes |
Create a free account and grab your key from the dashboard under Settings → API Key. The free trial includes 100 credits — enough to run the full flow below. Treat the key like a password: load it from an environment variable, and never hardcode it or commit it to a repo.
# Install the only dependency you need to start
pip install requests
# Store your key as an env var (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"Set up Python and make your first call
Every request is a POST to the same URL with two fields: type (which lookup you want) and input_data (what you already know). Authentication is a single Authorization: Bearer header — no token refresh, no scopes.
The most representative call: pass a LinkedIn profile URL and get the full structured profile back (name, title, company, location) in one request.
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()
print(data["status"]) # "success"
print(data["result"]) # {full_name, job_title, company_name, ...}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"
}'lead_full_name_to_linkedin_url (name + company), email_to_linkedin_url (reverse lookup from an email), or company_name_to_website to resolve a company first. We chain these in Step 4.Read the response shape
Single-value lookups return a small, predictable object. Always branch on status first — a 200 response can still carry status: "error" with a null result when there's no match.
{
"result": "[email protected]",
"status": "success"
}{
"result": null,
"status": "error",
"message": "Email not found"
}The profile endpoint returns far more. linkedin_profile_to_linkedin_info gives a full structured profile, and the natural-language leads_finder_ai endpoint returns an array of fully-formed lead objects in one call — useful when you want to source and enrich at once:
{
"full_name": "Sarah Mitchell",
"job_title": "VP of Sales",
"seniority_level": "vp",
"email": "[email protected]",
"linkedin": "https://linkedin.com/in/sarah-mitchell-sales",
"company_name": "CloudCore",
"company_domain": "cloudcore.io",
"company_size": "51-200",
"industry": "Software",
"city": "Austin",
"state": "Texas",
"country": "United States"
}A clean way to handle this in Python is a tiny helper that returns the result on success and None otherwise, so calling code stays readable:
def unwrap(resp_json):
"""Return result on success, else None. Empty matches are normal."""
if resp_json.get("status") == "success":
return resp_json.get("result")
return Nonenull still costs 1 credit. De-duplicate your input and cache results before you start spending — a clean input list is the biggest lever on your bill.Resolve a profile URL from a name, email, or company
You rarely start with a clean LinkedIn URL. More often you have a name and a company, or just a work email. The pattern is the same: resolve an identifier first, then enrich off it. Each of these is one POST with a different type.
| You have | type | You get |
|---|---|---|
| Name + company | lead_full_name_to_linkedin_url | LinkedIn profile URL |
| Work email | email_to_linkedin_url | LinkedIn profile URL |
| Profile URL | linkedin_profile_to_email | Work email |
| Profile URL | linkedin_profile_to_phone | Phone number |
| Company name | company_name_to_website | Company domain |
| Company domain | company_domain_to_employees | Employee list (filterable) |
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 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 from an email
url = url or call("email_to_linkedin_url", "[email protected]")
print(url) # https://linkedin.com/in/sarah-mitchell-salesBuild a reusable LinkedIn client class
Wrap the endpoint in a small class so your application code reads like intent, not HTTP plumbing. The class holds one requests.Session (connection reuse = faster bulk runs) and exposes one method per lookup. The enrich_profile method chains calls into a full record.
import os, requests
class LinkFinder:
BASE_URL = "https://api.linkfinderai.com"
def __init__(self, api_key=None, timeout=30):
self.api_key = api_key or os.environ["LINKFINDER_API_KEY"]
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
})
def call(self, type_, input_data, **extra):
"""Core request. One call = one credit."""
resp = self.session.post(
self.BASE_URL,
json={"type": type_, "input_data": input_data, **extra},
timeout=self.timeout,
)
resp.raise_for_status()
data = resp.json()
return data.get("result") if data.get("status") == "success" else None
# --- convenience wrappers ---
def profile_info(self, url): return self.call("linkedin_profile_to_linkedin_info", url)
def email(self, url): return self.call("linkedin_profile_to_email", url)
def phone(self, url): return self.call("linkedin_profile_to_phone", url)
def url_from_name(self, name, company): return self.call("lead_full_name_to_linkedin_url", f"{name} {company}")
def url_from_email(self, email): return self.call("email_to_linkedin_url", email)
def enrich_profile(self, linkedin_url):
"""Turn a bare profile URL into a complete contact record."""
record = {"linkedin": linkedin_url}
info = self.profile_info(linkedin_url) or {}
record.update({
"full_name": info.get("full_name"),
"job_title": info.get("job_title"),
"company_name": info.get("company_name"),
"location": info.get("city"),
})
record["email"] = self.email(linkedin_url) # +1 credit
record["phone"] = self.phone(linkedin_url) # +1 credit
return record
if __name__ == "__main__":
lf = LinkFinder()
print(lf.enrich_profile("https://linkedin.com/in/sarah-mitchell-sales"))That single profile used three credits (info + email + phone). If a step returns None, the record still fills in what it can. To enrich companies instead of people, call company_domain_to_employees with department and seniority filters as keyword args:
lf = LinkFinder()
employees = lf.call(
"company_domain_to_employees",
"cloudcore.io",
department="sales",
seniority="director",
)
for person in employees or []:
print(person["full_name"], "—", person["job_title"])Bulk-enrich a list of LinkedIn URLs with asyncio
For a handful of profiles, a simple loop with a small delay is fine. For thousands, you'll want concurrency — but bounded, so you stay under your plan's requests-per-second limit. The clean Python answer is asyncio + httpx with an asyncio.Semaphore as a throttle.
Simple: synchronous loop over a CSV
import csv, time
lf = LinkFinder()
REQS_PER_SEC = 5 # match your plan (Starter = 5/s)
DELAY = 1.0 / REQS_PER_SEC
with open("profiles.csv", newline="") as f_in, \
open("enriched.csv", "w", newline="") as f_out:
reader = csv.DictReader(f_in) # column: linkedin_url
fields = ["linkedin", "full_name", "job_title", "company_name", "email", "phone"]
writer = csv.DictWriter(f_out, fieldnames=fields, extrasaction="ignore")
writer.writeheader()
seen = set()
for row in reader:
url = row["linkedin_url"].strip()
if not url or url in seen: # de-dupe to save credits
continue
seen.add(url)
writer.writerow(lf.enrich_profile(url))
time.sleep(DELAY) # stay under the rate limit
print("Done.")Faster: bounded concurrency with asyncio + httpx
import os, asyncio, httpx
API_KEY = os.environ["LINKFINDER_API_KEY"]
BASE_URL = "https://api.linkfinderai.com"
MAX_CONCURRENCY = 5 # keep at or below your plan's req/s
async def call(client, sem, type_, input_data, **extra):
async with sem: # the semaphore is the throttle
r = await client.post(
BASE_URL,
json={"type": type_, "input_data": input_data, **extra},
)
data = r.json()
return data.get("result") if data.get("status") == "success" else None
async def enrich(client, sem, url):
info, email, phone = await asyncio.gather(
call(client, sem, "linkedin_profile_to_linkedin_info", url),
call(client, sem, "linkedin_profile_to_email", url),
call(client, sem, "linkedin_profile_to_phone", url),
)
info = info or {}
return {
"linkedin": url,
"full_name": info.get("full_name"),
"job_title": info.get("job_title"),
"company_name": info.get("company_name"),
"email": email,
"phone": phone,
}
async def main(urls):
sem = asyncio.Semaphore(MAX_CONCURRENCY)
headers = {"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json"}
async with httpx.AsyncClient(headers=headers, timeout=30) as client:
tasks = [enrich(client, sem, u) for u in urls]
return await asyncio.gather(*tasks)
urls = ["https://linkedin.com/in/sarah-mitchell-sales",
"https://linkedin.com/in/john-doe"]
results = asyncio.run(main(urls))
print(results)MAX_CONCURRENCY if you start seeing 429s — the semaphore caps in-flight requests, but total throughput still has to respect your plan's per-second ceiling.Handle errors & rate limits
Unattended jobs need to handle failure explicitly. These are the status codes you'll actually hit:
| Code | Meaning | What to do |
|---|---|---|
| 200 | Success | Check result — it 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 value 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 |
Add retry-with-backoff to the client's call method so a burst of 429s or a transient 500 doesn't kill a long run:
import time
def call(self, type_, input_data, max_retries=4, **extra):
delay = 1.0
for _ in range(max_retries):
resp = self.session.post(
self.BASE_URL,
json={"type": type_, "input_data": input_data, **extra},
timeout=self.timeout,
)
if resp.status_code in (429, 500):
time.sleep(delay) # 1s -> 2s -> 4s -> 8s
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
raise RuntimeError("Exhausted retries after repeated rate limiting.")Match your concurrency and delays to your plan's limits:
| 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 |
Start pulling LinkedIn data in Python in 2 minutes
Run everything above on the free tier — 100 credits, an API on every plan, and flat 1-credit-per-request pricing with no OAuth, partner application, or annual contract.
Get your API keyNo credit card required • API on every plan • Flat pricing • Cancel anytime