Install
openclaw skills install @scavio-ai/linkedin-scraper-apiPull LinkedIn person and company profiles, their posts, job listings and post comments as structured JSON. 9 endpoints from 1 to 30 credits, four of them paginated, for prospecting, recruiting, and market research.
openclaw skills install @scavio-ai/linkedin-scraper-apiLook up LinkedIn people and companies, read their posts, search job listings, and read a single job or post with its comments. All endpoints return structured JSON.
Use this skill when the user asks to:
Note: LinkedIn upstream can be slow. Set a client timeout of at least 60 seconds and be ready to retry.
Get a free API key at scavio.dev (50 free credits to get started, no card required):
export SCAVIO_API_KEY=sk_live_your_key
An agent running this skill without SCAVIO_API_KEY set will get 401 on every
call below. The whole path from nothing to a working key is self-serve:
When the balance runs out the API answers 402 with a JSON body carrying
billing_url. Topping up needs no code change - the same key keeps working.
The smallest purchase is 2,500 credits for $25, and monthly plans work out
cheaper per credit if the usage is steady rather than one-off.
Every request is a POST with a JSON body and:
Authorization: Bearer $SCAVIO_API_KEY
Base URL: https://api.scavio.dev. All paths are under /api/v1/linkedin. Cost is not uniform - check the table before planning a run.
| Endpoint | Credits | What it returns |
|---|---|---|
POST /api/v1/linkedin/person | 1 | Full profile: about, experience, education, links |
POST /api/v1/linkedin/person/about | 1 | Just the narrative sections of a profile |
POST /api/v1/linkedin/person/posts | 10 per page | A member's posts, or the posts they commented on or reacted to. 50 per page, paginated |
POST /api/v1/linkedin/company | 1 | Company profile, locations, related companies |
POST /api/v1/linkedin/company/posts | 10 per page | A company's posts, 50 per page, paginated |
POST /api/v1/linkedin/search/jobs | 10 per page | Job listings by keyword and location, 25 per page, paginated |
POST /api/v1/linkedin/job | 30 | Full detail for one job listing |
POST /api/v1/linkedin/post | 1 | Full detail for one post |
POST /api/v1/linkedin/post/comments | 10 per page | Comments on a post, paginated by page number |
/job is by far the most expensive call here. Job search already returns title, company, location, posted time, workplace type and salary for every hit, so only call /job for listings the user has actually picked out - never in a loop over search results.
Every reference param also accepts a full LinkedIn URL. {"username": "williamhgates"} and {"url": "https://www.linkedin.com/in/williamhgates/"} are equivalent, and the same holds for company, job_id and post_id.
The upstream data provider withdrew five datasets. These paths still exist but always return HTTP 410 with {"code": "endpoint_retired", "reason": ...} and are never billed. Do not retry them, and never describe the data as temporarily unavailable - it is gone.
| Retired | Closest substitute |
|---|---|
/person/contact | None. Public contact info is no longer available. |
/company/people | /company returns featured_employees, a sample of 4-6 staff. |
/company/jobs | /search/jobs with the company name as search. |
/search/people | None. |
/search/posts | None. |
/linkedin/person with username (the vanity handle) or url. Use /linkedin/person/about when you only need the narrative sections, and /linkedin/person/posts for their feed - type selects whether that feed is their own posts, the posts they commented on, or the posts they reacted to./linkedin/company with company (a slug like microsoft) or url. Use /linkedin/company/posts for its feed./linkedin/search/jobs with a search keyword and optional location, then /linkedin/job with a job_id from the results for full detail./linkedin/post with a post_id (bare id or activity urn), and /linkedin/post/comments with the same id for its comment thread.Four endpoints paginate, in two different styles. Every page is a separate call and costs 10 credits, so decide up front how many pages the user actually needs. The other five (/person, /person/about, /company, /job, /post) return a single object and take no paging parameter.
Cursor style - /person/posts, /company/posts, /search/jobs. The response carries has_more and next_cursor. While has_more is true, send next_cursor back verbatim as cursor on the next call; stop when has_more is false, at which point next_cursor is null. The cursor is opaque - it is not an offset and must not be parsed, constructed or incremented - and it belongs to the endpoint and the query that produced it, so never move one between endpoints or reuse it after changing username, company, search or type. A cursor the API cannot read is not an error: it silently restarts at page one, so a mangled cursor shows up as a paging loop that never advances.
Page sizes are 50 posts for the two feeds and 25 listings for job search.
Page-number style - /post/comments. Pass a 1-based page. Two upstream quirks mean you cannot use page size to detect the end: the page size varies (10, 10, 8 and 9 comments were observed on a single thread), so a short page does not mean the last page, and total is only returned on page 1 - it is null on every later page, so there is no running count to compare against. The only reliable stop signal is an empty page. Keep incrementing until a page comes back with no comments; has_more says exactly that (this page had at least one comment) and next_page carries the number to ask for.
/search/jobs pages, but is not a snapshot. Upstream rotates its result set, so pages overlap slightly and two identical searches return different listings. Paginate it to widen coverage, dedupe by job id across pages, and never present the union as the complete set of matching jobs or diff two runs to infer new postings.
/person, /person/about)| Parameter | Type | Default | Description |
|---|---|---|---|
username | string | one of | Public identifier (vanity handle), e.g. williamhgates |
url | string | one of | Full LinkedIn profile URL |
/person/posts)| Parameter | Type | Default | Description |
|---|---|---|---|
username | string | one of | Public identifier (vanity handle) |
url | string | one of | Full LinkedIn profile URL |
type | string | posts | Which feed: posts (the member's own), comments (posts they commented on) or reactions (posts they reacted to). Any other value is a 400. |
cursor | string | -- | Opaque cursor: the previous response's next_cursor. Omit for page 1. |
/company)| Parameter | Type | Default | Description |
|---|---|---|---|
company | string | one of | Company universal name (slug), e.g. microsoft |
url | string | one of | Full LinkedIn company URL |
/company/posts)| Parameter | Type | Default | Description |
|---|---|---|---|
company | string | one of | Company universal name (slug) |
url | string | one of | Full LinkedIn company URL |
cursor | string | -- | Opaque cursor: the previous response's next_cursor. Omit for page 1. |
/search/jobs)| Parameter | Type | Default | Description |
|---|---|---|---|
search | string | required | Keyword, e.g. software engineer |
location | string | -- | Geographic filter, e.g. United States. Omit to search everywhere. |
cursor | string | -- | Opaque cursor: the previous response's next_cursor. Omit for page 1. |
/job)| Parameter | Type | Default | Description |
|---|---|---|---|
job_id | string | one of | Job listing id, e.g. 4415427228 |
url | string | one of | Full LinkedIn job URL |
/post) and post comments (/post/comments)| Parameter | Type | Default | Description |
|---|---|---|---|
post_id | string | one of | Post id or activity urn, e.g. 7488618410256523265 |
url | string | one of | Full LinkedIn post URL |
page | number | 1 | (comments only) 1-based page. Page size varies, so page until a page comes back empty. |
import requests
BASE = "https://api.scavio.dev"
# Your key from https://scavio.dev. Load it from your environment or secret
# store in real code - keep it out of source control.
API_KEY = "sk_your_key_here"
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
# 1. A person's profile, with experience and education inline
person = requests.post(f"{BASE}/api/v1/linkedin/person", headers=HEADERS,
json={"username": "williamhgates"}).json()
# 2. Their recent posts (a full URL works just as well as a handle)
posts = requests.post(f"{BASE}/api/v1/linkedin/person/posts", headers=HEADERS,
json={"url": "https://www.linkedin.com/in/williamhgates/"}).json()
# 2b. The posts they reacted to instead of the ones they wrote
reactions = requests.post(f"{BASE}/api/v1/linkedin/person/posts", headers=HEADERS,
json={"username": "williamhgates", "type": "reactions"}).json()
# 3. A company profile
company = requests.post(f"{BASE}/api/v1/linkedin/company", headers=HEADERS,
json={"company": "microsoft"}).json()
# 4. Jobs at that company: search by name, then pull one listing in full
found = requests.post(f"{BASE}/api/v1/linkedin/search/jobs", headers=HEADERS,
json={"search": "Microsoft software engineer", "location": "United States"}).json()
job = requests.post(f"{BASE}/api/v1/linkedin/job", headers=HEADERS,
json={"job_id": found["data"]["data"][0]["id"]}).json()
# 5. A post and page 1 of its comments
comments = requests.post(f"{BASE}/api/v1/linkedin/post/comments", headers=HEADERS,
json={"post_id": "7488618410256523265", "page": 1}).json()
Cursor paging, capped so it cannot run away with the user's credits. next_cursor
is echoed back untouched; each page is 10 credits.
def paged(path, body, max_pages=3):
"""Walk a cursor-paginated LinkedIn endpoint. 10 credits per page."""
cursor, out = None, []
for _ in range(max_pages):
payload = {**body, **({"cursor": cursor} if cursor else {})}
data = requests.post(f"{BASE}/api/v1/linkedin/{path}",
headers=HEADERS, json=payload).json()["data"]
out += data["data"]
if not data["has_more"]:
break
cursor = data["next_cursor"]
return out
feed = paged("person/posts", {"username": "williamhgates"})
jobs = paged("search/jobs", {"search": "software engineer", "location": "United States"})
# Job search rotates upstream, so pages overlap - dedupe by id.
jobs = list({j["id"]: j for j in jobs}.values())
Comment paging is by page number, and only an empty page means the end - page
size varies and total is on page 1 only.
def all_comments(post_id, max_pages=5):
out, total = [], None
for page in range(1, max_pages + 1):
data = requests.post(f"{BASE}/api/v1/linkedin/post/comments", headers=HEADERS,
json={"post_id": post_id, "page": page}).json()["data"]
if page == 1:
total = data["total"] # null on every later page
if not data["data"]:
break # the only reliable stop signal
out += data["data"]
return out, total
Every response uses the envelope { data, response_time, credits_used, credits_remaining }. Key data fields per endpoint:
id, public_identifier, first_name, last_name, full_name, headline, about, location, city, country_code, avatar, banner, follower_count, connection_count, current_company{name,company_id,title}, experiences[], educations[], education_summary, honors_and_awards[], bio_links[], people_also_viewed[], similar_profiles[], url.about, headline, education_summary, experiences[], educations[], honors_and_awards[], bio_links[].data[] (id, share_urn, text, url, created_at, posted_relative, is_reshare, num_comments, num_reposts, reactions{total,likes,appreciations,empathy,interests,praises,entertainments}, images[], article, author), count, has_more, next_cursor (null on the last page). 50 per page.id, universal_name, name, description, about, website, industries[], specialties[], company_size, organization_type, employee_count, follower_count, headquarters, locations[], country_codes[], logo, cover, featured_employees[], similar_companies[], affiliated_companies[], recent_updates[], url.data[] (id, title, url, company, company_url, company_logo, location, posted_at, workplace_type, salary), count, has_more, next_cursor (null on the last page). 25 per page.id, title, url, description, location, employment_type, experience_level, job_functions[], industries[], benefits[], skills[], is_remote, is_closed, expires_at, posted_at, applicant_count, view_count, salary, company{id,name,url,employee_count,employee_range,follower_count,headquarters{...}}.id, post_type, title, headline, text, url, created_at, hashtags[], embedded_links[], images[], videos[], num_likes, num_comments, tagged_companies[], tagged_people[], top_comments[], author{public_identifier,full_name,avatar,url,follower_count,post_count}.data[] (text, url, created_at, is_pinned, author{urn,full_name,headline,avatar,url}, replies[]), page, total (page 1 only, null afterwards), has_more (this page carried at least one comment), next_page (null when the page was empty).An experience entry has two shapes. A single role carries company plus its own dates. Several roles at the same employer are grouped: title holds the company name and each role sits in positions[] with its own title and dates.
{
"data": {
"full_name": "Bill Gates",
"headline": "Chair, Gates Foundation and Founder, Breakthrough Energy",
"public_identifier": "williamhgates",
"follower_count": 40550634,
"current_company": { "name": "Gates Foundation", "title": "Co-chair" },
"experiences": [
{
"title": "Co-chair",
"company": "Gates Foundation",
"start_date": "2000",
"positions": []
}
],
"education_summary": "Harvard University"
},
"credits_used": 1,
"credits_remaining": 999
}
/job is 30. A ten-page job search is 100 credits, so cap any paging loop and tell the user the credit cost you intend to spend before you start./person already contains the about text, experience and education - do not call /person/about as well for the same member, it is the same upstream fetch billed twice.has_more false for the three cursor endpoints, an empty data[] for /post/comments. Never stop on a short page of comments and never use total as a page budget - it is present on page 1 only.next_cursor back unchanged; do not parse, build or increment one, and do not carry a cursor across endpoints or across a changed username, company, search or type. An unreadable cursor silently returns page one instead of erroring, which looks like a loop that never advances./search/jobs rotates its result set upstream, so pages overlap and a repeated search returns different listings. Dedupe by job id, do not present the pages you walked as an exhaustive list, and do not diff two runs to infer new postings.410 is permanent. Report the data as unavailable and move on rather than retrying or substituting a guess.400 means an invalid or missing parameter (e.g. neither username nor url, or a type outside posts / comments / reactions) - fix and retry. A bad cursor is the exception: it does not 400, it quietly returns page one.401 means the API key is invalid or missing. Check SCAVIO_API_KEY.404 on /job means the listing has no detail record upstream. Not billed. Roughly one job id in five returned by /search/jobs answers this way - expired and delisted postings linger in search after their detail page is gone. Skip that id and move to the next; retrying will not produce a record.410 means the endpoint was retired upstream. Permanent - do not retry. Read reason in the body.429 means rate or usage limit exceeded. Wait before retrying. See rate limits.502 / 503 mean upstream is temporarily unavailable - wait a few seconds and retry, up to a few times.SCAVIO_API_KEY is not set, prompt the user to export it before continuing.langchain-scavio has no LinkedIn tool - use the Scavio SDK directly (it handles the auth header, and next_cursor is passed straight back as cursor):
pip install scavio==0.15.0
from scavio import ScavioClient
client = ScavioClient() # reads SCAVIO_API_KEY
profile = client.linkedin.person(username="williamhgates")
feed = client.linkedin.person_posts(username="williamhgates", type="reactions")
more = client.linkedin.person_posts(username="williamhgates", cursor=feed["data"]["next_cursor"])
jobs = client.linkedin.search_jobs("software engineer", location="United States")