How to Scrape LinkedIn Profiles & Companies (Python, 2026)
This tutorial shows how to scrape LinkedIn profiles and companies with Python. If you want LinkedIn data inside your own code, you have two realistic paths: apply to one of LinkedIn's partner programs, or call a third-party API that reads public pages for you. This guide takes the second path, using our LinkedIn scraper API. By the end you will have code that reads a member profile, a company page and its posts, runs a filtered people search, walks the pages of a result, and tells you what the whole job cost before you run it.
Not a developer? You don't need any of this. The InsightSocial Chrome extension exports LinkedIn data straight to CSV from the page you're looking at, and our step-by-step guide to scraping LinkedIn profiles walks through it with no code.
Why not just use LinkedIn's official API?
Because it isn't built for reading other people's data. LinkedIn's self-serve permissions (Sign In with LinkedIn using OpenID Connect, and Share on LinkedIn) let your app read the name, headline, photo and email of the member who logged in, and post on their behalf. That's it. Anything broader, such as advertising data, Sales Navigator profile matching or recruiting integrations, sits behind partner programs that LinkedIn approves case by case.
That's the right design for a login button. It's the wrong tool if you need to look up 200 prospects, benchmark five competitors' company pages, or monitor posts about a keyword. For those jobs, developers use a data API that returns public LinkedIn pages as JSON. We compare the options in Best LinkedIn data APIs for developers.
What can you pull from LinkedIn with an API?
The LinkedIn API reference lists 65+ endpoints. These are the ones most jobs start with:
| What you want | Endpoint | Key parameter | Credits |
|---|---|---|---|
| A member's public profile | /v1/linkedin/profile | url | 50 |
| Their whole background in one call | /v1/linkedin/profile/complete | url | 100 |
| Posts a member published | /v1/linkedin/profile/posts | url | 50–2,000 |
| A company page | /v1/linkedin/company | url | 50 |
| A company from its website | /v1/linkedin/company/by-domain | domain | 50 |
| A company's recent posts | /v1/linkedin/company/posts | company_id | 50 |
| Company + last 10 posts | /v1/linkedin/profile/full | url (company page) | 50 |
| Comments on a post | /v1/linkedin/post/comments | url | 50 |
| People search with filters | /v1/linkedin/search/people | query | 100–500 |
| Keyword search over posts | /v1/linkedin/search/posts | query | 10–480 |
Prices come from the endpoint pricing table. A range means the endpoint is metered: we hold the top of the range while the call runs and charge what it actually read. See Credits for how that works.
How do I set up the API key?
Sign up, open the API section of the portal, and copy your key. It starts with isk_live_. Keys are stored hashed and encrypted, and you can reveal yours again in the dashboard whenever you need it. Every request sends it in one header, x-api-key. There's no OAuth dance and nothing to configure per platform. The quickstart has the full walkthrough.
export INSIGHTSOCIAL_API_KEY="isk_live_..."
The free plan gives you 500 credits a month, and your first 10 calls priced at 100 credits or less cost nothing. That covers the profile, company and post calls below. People search is the exception: it is not covered by free calls, and a metered call reserves its ceiling before it runs, so search/people with include=profile holds 500 credits, the free plan's whole month. You'll want Pro or a credit pack for that one, and you're only charged what it actually reads.
How do I scrape a LinkedIn profile?
Pass the full profile URL. Here is the same call in all three languages.
curl -G "https://api.insightsocial.app/v1/linkedin/profile" \
--data-urlencode "url=https://www.linkedin.com/in/satyanadella/" \
-H "x-api-key: $INSIGHTSOCIAL_API_KEY"
import os
import requests
BASE = "https://api.insightsocial.app/v1"
HEADERS = {"x-api-key": os.environ["INSIGHTSOCIAL_API_KEY"]}
body = requests.get(
f"{BASE}/linkedin/profile",
params={"url": "https://www.linkedin.com/in/satyanadella/"},
headers=HEADERS,
timeout=60,
).json()
if body["success"]:
author = body["data"]["author"]
print(author["display_name"], author["followers"], author["url"])
print("cost:", body["credits_used"], "left:", body["credits_remaining"])
else:
print(body["error"]["type"], body["error"]["message"])
const BASE = "https://api.insightsocial.app/v1";
const headers = { "x-api-key": process.env.INSIGHTSOCIAL_API_KEY };
const url = new URL(`${BASE}/linkedin/profile`);
url.searchParams.set("url", "https://www.linkedin.com/in/satyanadella/");
const body = await (await fetch(url, { headers })).json();
if (body.success) {
const { display_name, followers } = body.data.author;
console.log(display_name, followers, "cost", body.credits_used);
} else {
console.log(body.error.type, body.error.message);
}
Every response uses the same envelope: the payload sits under data, and credits_used, credits_remaining and charge_reason tell you what the call cost and why. schema_version is "2", and unavailable lists any field LinkedIn normally shows that this response could not fill (usually none). The field names in data.author are shared across all 25 platforms we cover, so a parser written for LinkedIn also works for Instagram or X. On a member profile, author.id is the vanity slug from the URL; on a company page it's the numeric company id. Both are strings. The response schema lists every field.
Two cheaper habits worth building early:
- Cache on your side. Every repeat of a call is charged, so store profiles you've fetched instead of re-requesting them. For retries after a timeout, send an
Idempotency-Key: a replay costs 0. - Pick the right depth.
/profilegives you identity and counts. If you also need experience, education and skills,/profile/completereturns them in one 100-credit call instead of four separate 50-credit calls. It's a beta endpoint: the fields keep LinkedIn's own structure, with string ids, ISO times and snake_case keys.
How do I get a company page and its posts?
Company endpoints other than /company itself take a numeric company_id, which you can't read off the page URL. Resolve it once with /company (it comes back as data.author.id), store it, and reuse it.
def get(path, **params):
return requests.get(f"{BASE}/{path}", params=params,
headers=HEADERS, timeout=60).json()
company = get("linkedin/company", url="https://www.linkedin.com/company/hubspot/")
company_id = company["data"]["author"]["id"]
print(company["data"]["author"]["display_name"], "id", company_id)
posts = get("linkedin/company/posts", company_id=company_id, sort_by="recent")
for item in posts["data"]["items"]:
post = item["post"]
likes = post["engagement"]["likes"]
print(post["published_at"], likes, post["url"])
That's two calls and 100 credits. If all you need is a snapshot for a dashboard, /v1/linkedin/profile/full (beta) does both in one 50-credit call. Despite the name, it takes a company page URL and returns the company and its latest 10 posts together. It doesn't compute engagement figures: divide each post's likes, comments and shares by the company's follower count yourself. LinkedIn doesn't publish view counts on posts, so engagement.views is always null.
For a company you only know by its website, /v1/linkedin/company/by-domain?domain=hubspot.com returns the same record, including the company_id.
How do I search LinkedIn people through an API?
/v1/linkedin/search/people takes a keyword plus optional filters. The filters want LinkedIn's own ids, and there are cheap lookup endpoints for each one:
| Filter | Where the id comes from | Lookup cost |
|---|---|---|
current_company, past_company | /v1/linkedin/company (author.id) | 50 |
geocode_location | /v1/linkedin/search/location | 10 |
school | /v1/linkedin/search/schools | 10 |
industry | /v1/linkedin/search/industry | 10 |
title | Free text, no lookup | 0 |
A search page returns up to 10 people and costs 100 credits. By default the follower figure on each row is LinkedIn's rounded display number, and author.followers_approximate is true. Add include=profile to join each row to that member's full profile in the same call. That adds 40 credits per row it fills, and the page is capped at 500 credits. Use limit (1 to 10) to cap both the rows and the extra cost.
const search = new URL(`${BASE}/linkedin/search/people`);
search.searchParams.set("query", "growth marketing");
search.searchParams.set("title", "Head of Growth");
search.searchParams.set("geocode_location", "103644278"); // from /search/location
const page = await (await fetch(search, { headers })).json();
for (const row of page.data.items) {
console.log(row.author.display_name, row.author.url);
}
console.log("cost", page.credits_used);
How does pagination work?
List endpoints return a pagination object next to data. Send pagination.next_cursor back as the cursor parameter exactly as you got it (it starts with v2c. and works with the same parameters for 24 hours), and stop when has_more is false. Don't judge completeness by an empty page or a total count. The pagination docs cover the details.
This helper walks any LinkedIn list and stops at a page cap, so a large result can't quietly eat your balance:
import time
def walk(path, max_pages=5, **params):
cursor, spent = None, 0
for _ in range(max_pages):
query = dict(params, **({"cursor": cursor} if cursor else {}))
res = requests.get(f"{BASE}/{path}", params=query,
headers=HEADERS, timeout=120)
if res.status_code in (429, 503):
time.sleep(float(res.headers.get("Retry-After", 2)))
continue # retry the same cursor
body = res.json()
if not body["success"]:
raise RuntimeError(f'{body["error"]["type"]} {body["request_id"]}')
spent += body["credits_used"]
yield from body["data"]["items"]
page = body.get("pagination") or {}
if not page.get("has_more"):
break
cursor = page["next_cursor"]
print(f"{path}: {spent} credits")
people = list(walk("linkedin/search/people", max_pages=3,
query="data engineer", current_company=company_id))
Each key allows 60 requests a minute and 10 in flight. Going over returns a 429 with a Retry-After header, and a rejected request costs nothing. Pages of one list are sequential because each needs the previous cursor, but you can walk several lists in parallel. The rate limits page explains how to size a job.
What does a real LinkedIn job cost?
Here is a realistic B2B research job, costed from the published prices:
- Snapshot 5 competitor company pages with their latest posts: 5 ×
/profile/fullat 50 = 250 - Resolve those 5 companies'
company_idfor filtering: 5 ×/companyat 50 = 250 - Find decision-makers: 5 people-search pages (50 rows), no join, at 100 = 500
- Pull the full profile for the 25 best matches: 25 ×
/profileat 50 = 1,250 - Read the comments on 5 of the competitors' top posts: 5 ×
/post/commentsat 50 = 250
Total: 2,500 credits. That fits inside one month of the Pro plan (10,000 credits for $9.99/month), with room to run it three more times. On the free plan's 500 credits you'd get through steps 1 and 2, or about 10 profile lookups.
Four things bring the real number down:
- Failed and empty calls cost 0. A private or deleted profile doesn't charge you.
- Make reruns resumable. Every call is charged, including a repeat, so have your script skip profiles it already stored.
- Shared-cache hits cost 2 credits. If someone recently fetched the same public page, you get it from our cache.
- Metered calls settle at actual usage. Search pages often come in under their ceiling.
You can check prices without spending anything: GET /v1/endpoints is public, needs no key, and returns every endpoint's price and cache window as JSON. Every data endpoint also accepts dry_run=1, which returns the price range in data.dry_run (credits_min, credits_max) for 0 credits. Your balance is shared with extension exports, and GET /v1/credits shows both kinds of usage. Plans are on the pricing page.
How do I scrape LinkedIn responsibly?
A few rules we'd follow even if nobody enforced them:
- Stick to public data. These endpoints return public profiles, pages and posts. There's no endpoint for private messages or anyone's inbox, and you never hand over a LinkedIn login.
- Know the platform's terms. LinkedIn's User Agreement bans scraping and unauthorized automated access to its services. Using a third-party API means you aren't logging in with your own account, but it doesn't make privacy law go away.
- Collect what the job needs. If you only need job titles for a market map, don't store phone numbers. GDPR and similar laws apply to personal data even when it's public.
- Give people a way out. If you use the data for outreach, honor opt-outs and delete records you no longer need.
This is general guidance, not legal advice. If LinkedIn data is central to your product, talk to a lawyer.
Can an AI agent write this integration?
Yes. The API is plain REST with one header, so coding agents handle it well. Point Claude, Cursor or another agent at the AI agents page, which lists what to hand it. For the broader picture across platforms, see the social media API overview.
FAQ
Do I need a LinkedIn account to use the API?
No. You authenticate with your InsightSocial API key only. Your own LinkedIn account is never connected, so it can't be flagged for automation.
Can I get email addresses from LinkedIn profiles?
Only what a member chooses to show publicly. /v1/linkedin/profile/contact returns the contact details on the profile, such as websites, phone numbers and a Twitter handle. Most members don't publish an email, and we don't guess one.
How many LinkedIn profiles can I pull per month?
At 50 credits per profile, the free plan's 500 credits covers about 10 (plus your 10 free calls), and Pro's 10,000 covers about 200. For more, buy credit packs, which never expire. Rate limits allow 60 requests per minute per key.
Why does people search return rounded follower counts?
Search rows carry the figure LinkedIn displays in results, which is rounded. Add include=profile to get the exact count on each row. author.followers_approximate tells you which kind you have.
What happens when a call fails?
You aren't charged. The response has success: false, credits_used: 0 and an error.type to branch on. Retry only transient types such as RATE_LIMITED or SERVICE_UNAVAILABLE. The rest fail the same way until you change the request.
Is this the same data as the Chrome extension?
It's the same LinkedIn and the same credit balance, but a different way in. The extension captures what's on your screen as you browse. The API fetches public pages on request from your server. Use the extension for one-off exports and the API for anything scheduled or built into a product.