Three LinkedIn scraping jobs in Python with no session cookie anywhere: profiles, company pages and job listings. Every request below was executed on 27 July 2026 against Chocodata and the numbers are that run.
HTTP 200 in 3.07s
fields: 12
name: Reid Hoffman
headline: Co-Founder, Executive Board Chair
current_company: Manas AI
followers: 2782185
TL;DR
linkedin/profiletakes a profile URL and returns 12 top-level fields in about 3 seconds, with nodataenvelope to unwrap.linkedin/companyreturns 13 fields of firmographics, includingemployee_countat 16,850 for Stripe against a bandedcompany_sizeof 5,001-10,000.linkedin/jobsearchreturned 10 live postings forbackend engineerin the United Kingdom in 1.47s, each with a directurl.specialtiesis a comma string on some company pages andNoneon others, so.split(",")raisesAttributeErrorunless you guard it.
Why is it hard to scrape LinkedIn?
LinkedIn treats anonymous traffic as hostile: most of a profile sits behind an auth wall, class names are hashed and rotate so saved selectors rot within weeks, and rate limits attach to the account rather than the IP. The expensive one is HTTP 999, the non-standard code LinkedIn returns to suspected automation, because retry logic written for 429 does not recognise it and keeps hammering until the identity is burned. That mismatch costs more debugging time than the markup churn.
Prerequisites
- A free Chocodata API key, taken from the dashboard after sign-up. Free tier, no card.
-
Python 3.9+ and
requests. Tested here on Python 3.13.7 with requests 2.34.2, July 2026. Other languages hit the same endpoints, but every snippet below is Python.
python -m venv .venv && . .venv/bin/activate
pip install requests
- The LinkedIn URL you want to scrape. A profile URL copied from the address bar is enough to start. No LinkedIn account is involved at any point.
Fetch a profile from its URL
To scrape a LinkedIn profile, send the profile URL as url and read the fields straight off the response. There is no wrapper object.
1. Send the URL and assert on a field, not the status
import time
import requests
BASE = "https://api.chocodata.com/api/v1"
API_KEY = "YOUR_API_KEY"
URL = "https://www.linkedin.com/in/reidhoffman"
r = requests.get(f"{BASE}/linkedin/profile",
params={"url": URL, "api_key": API_KEY}, timeout=90)
r.raise_for_status()
profile = r.json()
assert profile.get("name"), "empty payload behind a 200"
print("fields:", len(profile))
for k in ("id", "name", "headline", "location", "current_company", "followers", "connections"):
print(f"{k:>16}: {profile[k]}")
fields: 12
id: reidhoffman
name: Reid Hoffman
headline: Co-Founder, Executive Board Chair
location: United States
current_company: Manas AI
followers: 2782185
connections: 500+
2. Iterate the experience and education lists
print("experience:", len(profile["experience"]), "| education:", len(profile["education"]))
for e in profile["experience"][:6]:
print(" ", e["company"])
for s in profile["education"][:4]:
print(" ", s["school"])
experience: 13 | education: 6
Manas AI
Inflection AI
Greylock
Village Global
Microsoft
Aurora
Università degli Studi di Perugia
University of Oulu
Babson College
Oxford University
3. Flatten the nested lists into one CSV row
import csv
row = {
"id": profile["id"],
"name": profile["name"],
"headline": profile["headline"],
"location": profile["location"],
"current_company": profile["current_company"],
"followers": profile["followers"],
"companies": "|".join(e["company"] for e in profile["experience"]),
"schools": "|".join(s["school"] for s in profile["education"]),
}
with open("profiles.csv", "w", newline="", encoding="utf-8") as fh:
w = csv.DictWriter(fh, fieldnames=list(row))
w.writeheader()
w.writerow(row)
print(len(row["companies"].split("|")), "companies flattened")
13 companies flattened
Pipe-joining keeps one profile on one row, which matters once you enrich thousands of them. Company names pulled out of experience are the handles the next section takes.
Fetch a company page by handle
To scrape a LinkedIn company page, pass the handle as company. The response carries the firmographics a profile does not.
1. Request the page and print the identity fields
r = requests.get(f"{BASE}/linkedin/company",
params={"company": "stripe", "api_key": API_KEY}, timeout=90)
r.raise_for_status()
company = r.json()
print("fields:", len(company))
for k in ("id", "name", "industry", "founded", "headquarters"):
print(f"{k:>14}: {company[k]}")
fields: 13
id: stripe
name: Stripe
industry: Technology, Information and Internet
founded: 2010
headquarters: South San Francisco, California
2. Read the size and reach signals
print(f'employee_count : {company["employee_count"]:,}')
print(f'company_size : {company["company_size"]}')
print(f'followers : {company["followers"]:,}')
print(f'website : {company["website"]}')
employee_count : 16,850
company_size : 5,001-10,000 employees
followers : 1,591,183
website : https://stripe.com
employee_count is the number of profiles listing that page as their employer, so it drifts a few people between calls and is not the same measure as the banded company_size.
3. Batch several handles into one table
def company_row(handle):
r = requests.get(f"{BASE}/linkedin/company",
params={"company": handle, "api_key": API_KEY}, timeout=90)
r.raise_for_status()
return r.json()
for handle in ("stripe", "spotify", "microsoft"):
d = company_row(handle)
print(f'{d["name"]:<10}{d["employee_count"]:>9,}{d["followers"]:>12,} {d["industry"][:34]}')
Stripe 16,850 1,591,183 Technology, Information and Intern
Spotify 19,179 4,628,644 Musicians
Microsoft 233,255 28,719,471 Software Development
Note Spotify's industry reading as Musicians, which is the value on the page rather than a parsing artefact, so normalise industry yourself if you plan to group on it. Hiring volume is the other size signal, and that comes from the jobs endpoint.
Search jobs by keyword and location
To scrape LinkedIn jobs, pass keywords and location. The response is a ranked list of live postings, each with a direct URL.
1. Run the search and inspect the row shape
r = requests.get(f"{BASE}/linkedin/jobsearch",
params={"keywords": "backend engineer",
"location": "United Kingdom",
"api_key": API_KEY}, timeout=90)
r.raise_for_status()
jobs = r.json()
print("results:", len(jobs["results"]))
print("keys:", [k for k in jobs["results"][0] if k != "company_logo"])
results: 10
keys: ['position', 'id', 'job_id', 'title', 'url', 'company', 'company_url', 'location', 'posted_date', 'posted_label', 'salary']
2. Print the listings as a fixed-width table
for j in jobs["results"][:6]:
print(f'{j["position"]:>2} {j["posted_date"]} {j["company"][:22]:<24}{j["title"][:34]}')
1 2026-07-08 Happl Backend Engineer
2 2026-07-19 Deliveroo Software Engineer
3 2026-06-17 Conduct Backend Engineer
4 2026-07-19 MAI UK Backend Engineer
5 2026-07-24 Midjourney Backend Engineer, Core Systems
6 2026-07-16 nPlan Backend Engineer
3. Filter to fresh postings by date
CUTOFF = "2026-07-01"
fresh = [j for j in jobs["results"] if j["posted_date"] >= CUTOFF]
fresh.sort(key=lambda j: j["posted_date"], reverse=True)
print(f"{len(fresh)} of {len(jobs['results'])} posted since {CUTOFF}")
for j in fresh[:5]:
print(f' {j["posted_date"]} {j["posted_label"]:<12}{j["company"][:20]}')
6 of 10 posted since 2026-07-01
2026-07-24 2 days ago Midjourney
2026-07-21 6 days ago Thought Machine
2026-07-19 1 week ago Deliveroo
2026-07-19 1 week ago MAI UK
2026-07-16 1 week ago nPlan
posted_date is an ISO string, so string comparison sorts it correctly with no datetime parsing. Position 1 in the raw response was dated 2026-07-08 while position 5 was 2026-07-24, so rank is not recency.
The part that breaks
Optional nested fields, not the requests. specialties came back as a 23-item comma string for microsoft and as None for stripe, and founded did the opposite, None for Microsoft and 2010 for Stripe. Calling a string method on either one raises:
Traceback (most recent call last):
File "scrape.py", line 57, in <module>
tags = company["specialties"].split(",")
^^^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'split'
Coalesce before you touch it, and the same guard covers every optional field on the page:
raw = company.get("specialties") or ""
tags = [t.strip() for t in raw.split(",") if t.strip()]
Three more shapes worth knowing. Inside experience, only company was populated on both profiles tested, with title and date_range unset, so build on company names rather than job titles. connections came back 500+ for reidhoffman and None for williamhgates, so it is a display string and not a number. And salary was None on all 10 United Kingdom job rows, because most public postings do not publish a figure, which makes posted_date and company the only fields worth filtering on.
Full script
"""LinkedIn profile, company and job scraper. Python 3.13.7, requests 2.34.2."""
import csv
import sys
import time
import requests
BASE = "https://api.chocodata.com/api/v1"
API_KEY = "YOUR_API_KEY"
def call(path, params, timeout=90, tries=3):
"""One GET, retried, so a batch run survives a dropped response."""
params = dict(params, api_key=API_KEY)
for attempt in range(tries):
r = requests.get(f"{BASE}/{path}", params=params, timeout=timeout)
if r.ok:
return r.json()
time.sleep(2)
r.raise_for_status()
def profile(url):
p = call("linkedin/profile", {"url": url})
if not p.get("name"):
raise RuntimeError(f"empty payload for {url}")
return {
"id": p["id"],
"name": p["name"],
"headline": p["headline"],
"location": p["location"],
"current_company": p["current_company"],
"followers": p["followers"],
"companies": "|".join(e["company"] for e in p.get("experience") or []),
"schools": "|".join(s["school"] for s in p.get("education") or []),
}
def company(handle):
c = call("linkedin/company", {"company": handle})
raw = c.get("specialties") or ""
return {
"id": c["id"],
"name": c["name"],
"industry": c["industry"],
"founded": c.get("founded"),
"headquarters": c["headquarters"],
"employee_count": c["employee_count"],
"company_size": c["company_size"],
"followers": c["followers"],
"specialties": [t.strip() for t in raw.split(",") if t.strip()],
}
def jobs(keywords, location, since=None):
rows = call("linkedin/jobsearch",
{"keywords": keywords, "location": location})["results"]
if since:
rows = [j for j in rows if j["posted_date"] >= since]
rows.sort(key=lambda j: j["posted_date"], reverse=True)
return rows
def main():
p = profile("https://www.linkedin.com/in/reidhoffman")
print(f'{p["name"]} | {p["current_company"]} | {p["followers"]:,} followers')
with open("profiles.csv", "w", newline="", encoding="utf-8") as fh:
w = csv.DictWriter(fh, fieldnames=list(p))
w.writeheader()
w.writerow(p)
for handle in ("stripe", "spotify", "microsoft"):
c = company(handle)
print(f'{c["name"]:<10}{c["employee_count"]:>9,} {len(c["specialties"])} specialties')
rows = jobs("backend engineer", "United Kingdom", since="2026-07-01")
print(f"{len(rows)} fresh postings")
for j in rows[:5]:
print(f' {j["posted_date"]} {j["company"][:20]:<22}{j["url"]}')
if __name__ == "__main__":
sys.exit(main())
Summary
Three LinkedIn jobs run off one request shape and no login: a profile URL returns 12 top-level fields with experience and education as nested lists, a company handle returns 13 fields of firmographics, and a keyword plus a location returns ranked live postings with direct URLs. What the payload does not carry is anything gated behind a session, so this covers public research and lead enrichment rather than connection graphs. The habit that saves the most time is coalescing every optional field with or "" before you touch it, because specialties, founded, connections, salary and the nested job titles are all populated inconsistently from page to page.
FAQ
Do I need a LinkedIn login to run this?
No, every request here sends a URL or a handle plus an API key, with no session cookie and no account involved.
Why is specialties sometimes None?
Not every company page fills that section, so Stripe returned None while Microsoft returned a 23-item comma string, and calling .split(",") unguarded raises AttributeError.
Does the jobs endpoint return salary?
Salary came back None on all ten United Kingdom rows, because most public LinkedIn postings do not publish a figure.










Top comments (0)