API Docs Get API key
Python Tutorial

LinkedIn API in Python

The practical way to pull LinkedIn profile, email, phone, and company data in Python — without partner approval or OAuth headaches. One REST endpoint, one Python function, 1 credit per call.

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.

1

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 wantOfficial LinkedIn APIThis tutorial
Your own profile after OAuth loginYes
Look up an arbitrary profile by URLNoYes
Find a profile from a name or emailNoYes
Append work email / phoneNoYes
Company & employee data at scalePartner onlyYes
Use for lead generationDisallowedYes

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"
LinkFinder is not affiliated with LinkedIn. It's an independent B2B data API — you call its endpoint, not LinkedIn's, so there's no OAuth flow, partner application, or session to manage.
2

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"
     }'
No profile URL yet? Start from what you have. Use 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.
3

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 None
A lookup that returns null 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.
4

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 havetypeYou get
Name + companylead_full_name_to_linkedin_urlLinkedIn profile URL
Work emailemail_to_linkedin_urlLinkedIn profile URL
Profile URLlinkedin_profile_to_emailWork email
Profile URLlinkedin_profile_to_phonePhone number
Company namecompany_name_to_websiteCompany domain
Company domaincompany_domain_to_employeesEmployee 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-sales
5

Build 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"])
6

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)
Each profile here fires three concurrent lookups, so it spends three credits. Lower 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.
7

Handle errors & rate limits

Unattended jobs need to handle failure explicitly. These are the status codes you'll actually hit:

CodeMeaningWhat to do
200SuccessCheck result — it may be null (still 1 credit)
401UnauthorizedMissing/invalid key — check the Authorization header
402Insufficient creditsTop up or wait for the next billing cycle
422Invalid requestVerify the type value and input_data format
429Rate limit exceededBack off exponentially: 1s, 2s, 4s
500Server errorRetry 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:

PlanCredits / moRequests / secBatch size
Starter5,0005 req/sUp to 500
Professional20,00010 req/sUp to 500
Enterprise50,00020 req/sUp to 500
HyperGrowth250,00050 req/sUp to 500
People change jobs and companies grow, so a profile that was complete six months ago is already drifting. Schedule a monthly Python job that re-enriches your highest-value records to keep your data honest.

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 key

No credit card required • API on every plan • Flat pricing • Cancel anytime