LinkedIn scraper API
pineapi is a LinkedIn scraper API that takes a set of filters (job title, location, industry, company headcount, seniority, school, years of experience and about thirty more) and returns every matching profile as 53 structured fields plus 16 nested collections. $0.012 per profile delivered, $0.12 minimum per search. No LinkedIn account, no session cookie and no proxies of your own: authentication is a bearer token, and a search that fails is not billed.
Start a search, then read the results
pineapi runs a search as a job because a real search takes minutes, not milliseconds: poll every 5 seconds for the first minute, then every 20 seconds. You start one, then read it when it is done.
# 1. Create a run. Keep this key and body unchanged for retries.
IDEMPOTENCY_KEY=$(uuidgen)
curl --fail-with-body $PINEAPI_BASE_URL/v1/profile-searches \
-H "Authorization: Bearer $PINEAPI_KEY" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-H "Content-Type: application/json" \
-d '{"current_job_titles":["Head of Talent"],"locations":["Spain"],"max_items":100}'
# HTTP 202: { "run_id": "<uuid>", "status": "queued", ... }
# 2. Copy run_id from that response. Poll until succeeded or partial.
RUN_ID='<uuid>'
curl --fail-with-body "$PINEAPI_BASE_URL/v1/runs/$RUN_ID" \
-H "Authorization: Bearer $PINEAPI_KEY"
# queued / starting / running / processing are in progress.
# failed / cancelled are terminal: inspect failure before retrying.
# 3. Download profiles after success. Follow next_cursor while has_more is true.
curl --fail-with-body "$PINEAPI_BASE_URL/v1/runs/$RUN_ID/profiles?limit=200" \
-H "Authorization: Bearer $PINEAPI_KEY"
# { "data": [...], "has_more": false, "next_cursor": null }import { randomUUID } from 'node:crypto';
const base = process.env.PINEAPI_BASE_URL + '/v1';
const auth = { Authorization: `Bearer ${process.env.PINEAPI_KEY}` };
async function json(path, options = {}) {
const response = await fetch(base + path, { ...options,
headers: { ...auth, ...options.headers }, signal: AbortSignal.timeout(30_000) });
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
return response.json();
}
// Persist this key and body if your application retries the creation request.
const key = randomUUID();
const { run_id } = await json('/profile-searches', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': key },
body: JSON.stringify({ current_job_titles: ['Head of Talent'], locations: ['Spain'], max_items: 100 }),
});
const pending = new Set(['queued', 'starting', 'running', 'processing']);
const started = Date.now();
const deadline = Date.now() + 30 * 60_000;
let run;
do {
if (Date.now() > deadline) throw new Error(`Polling timeout; resume with run_id ${run_id}`);
await new Promise(resolve => setTimeout(resolve, Date.now() - started < 60_000 ? 5_000 : 20_000));
run = await json(`/runs/${run_id}`);
} while (pending.has(run.status));
if (!['succeeded', 'partial'].includes(run.status)) throw new Error(JSON.stringify(run.failure ?? run.status));
let cursor;
do {
const query = new URLSearchParams({ limit: '200' });
if (cursor) query.set('cursor', cursor);
const page = await json(`/runs/${run_id}/profiles?${query}`);
for (const profile of page.data) console.log(profile.full_name);
cursor = page.has_more ? page.next_cursor : null;
} while (cursor);import os, time, uuid, requests
base = os.environ["PINEAPI_BASE_URL"].rstrip("/") + "/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['PINEAPI_KEY']}"
def get(path, **kwargs):
response = session.get(base + path, timeout=30, **kwargs)
response.raise_for_status()
return response.json()
# Keep this key and the same body if retrying the creation request.
key = str(uuid.uuid4())
response = session.post(base + "/profile-searches", timeout=30,
headers={"Idempotency-Key": key},
json={"current_job_titles": ["Head of Talent"], "locations": ["Spain"], "max_items": 100})
response.raise_for_status()
run_id = response.json()["run_id"]
started = time.monotonic()
deadline = started + 1800
while True:
if time.monotonic() > deadline:
raise TimeoutError(f"Resume polling run_id {run_id}")
run = get(f"/runs/{run_id}")
if run["status"] not in {"queued", "starting", "running", "processing"}:
break
time.sleep(5 if time.monotonic() - started < 60 else 20)
if run["status"] not in {"succeeded", "partial"}:
raise RuntimeError(run.get("failure") or run["status"])
params = {"limit": 200}
while True:
page = get(f"/runs/{run_id}/profiles", params=params)
for profile in page["data"]:
print(profile["full_name"])
if not page["has_more"]:
break
params["cursor"] = page["next_cursor"]Authentication is a bearer token and nothing else. pineapi runs no OAuth handshake, because an agent that has to complete a browser redirect before its first call is an agent that gives up and picks something else. Creation requires an Idempotency-Key header of up to 255 characters. Reuse the key and unchanged request on retries. Optional webhook_url must be a public HTTPS URL: it receives a signed event containing data.run metadata, not profiles. Customer access to the webhook signing secret is not yet implemented; use authenticated polling as the source of truth. Fetch profiles separately and follow next_cursor while has_more is true. Respect Retry-After on HTTP 429; the examples stop on HTTP errors so your application can apply its retry policy.
The whole reference as one file: auth, request, full response, error codes and price, with nothing to cross-reference.
Two ways to ask for profiles
pineapi implements two kinds of input and returns the identical 53-field response for both.
Search by filters
Describe who you are looking for: job titles, locations, industries, schools, company headcount, seniority, years of experience and around thirty more. Every matching profile comes back.
Lookup by profile URL
POST /v1/profile-lookups accepts linkedin_urls with 1 to 2,000 profile URLs. URLs are normalized and deduplicated within the request. Same response schema and per-profile price; no search minimum or block-of-25 requirement.
The computed fields for tenure, promotions and truncation are returned on every profile. They are not filterable in the request in v1.
How many profiles to ask for
pineapi accepts max_items between 25 and 2,000, in multiples of 25, defaulting to 100. That is not an arbitrary rule: LinkedIn paginates search results in blocks of 25, and a partial block costs exactly what a full one costs.
| Parameter | Rule |
|---|---|
| max_items | Minimum 25, maximum 2,000, in multiples of 25. Defaults to 100. |
| start_page | Optional pointer into the LinkedIn results. See pagination below before you use it. |
| webhook_url | Optional public HTTPS URL. Receives a signed event with run metadata; fetch profiles separately. |
| Idempotency-Key header | Required for creation. Up to 255 characters; reuse with the unchanged body on retries. |
| Depth | Single tier. There is no cheap mode and no deep mode to pick between. |
A month of 50,000 profiles is at least 25 searches, since one search caps at 2,000. There is no cheap mode and no deep mode to choose between: there is one tier, and it returns everything.
Why the official LinkedIn API will not give you profile data
LinkedIn's official API does not return other people's profiles. The Sign In with LinkedIn and Share on LinkedIn products return the authenticated member's own name, headline and picture, and the Marketing and Talent APIs sit behind partner programmes with commercial review, headcount requirements and an approved use case.
That mismatch is why most searches for "LinkedIn API" are looking for an unofficial one: the official documentation answers a question nobody asked. pineapi exists for the other question: describe who you are looking for, and get those people as structured data.
pineapi reads what LinkedIn shows publicly. It is not an official LinkedIn product, it is not affiliated with LinkedIn, and customers must establish a lawful basis for their collection and use of personal data.
The response schema, field by field
pineapi returns 53 top-level fields on every profile, plus 16 nested collections. Every field is listed below with its type and what it contains.
Profile fields
Generated from the API OpenAPI schema. Nullable values can be absent from the source profile.
idstring (uuid)Id
ordinalintegerOrdinal
public_identifierstring | nullPublic Identifier
linkedin_urlstringLinkedin Url
requested_urlstring | nullRequested Url
first_namestring | nullFirst Name
last_namestring | nullLast Name
full_namestring | nullFull Name
headlinestring | nullHeadline
aboutstring | nullAbout
location_textstring | nullLocation Text
location_citystring | nullLocation City
location_statestring | nullLocation State
location_region_codestring | nullLocation Region Code
location_countrystring | nullLocation Country
location_country_fullstring | nullLocation Country Full
location_country_codestring | nullLocation Country Code
open_to_workboolean | nullOpen To Work
hiringboolean | nullHiring
premiumboolean | nullPremium
influencerboolean | nullInfluencer
creatorboolean | nullCreator
verifiedboolean | nullVerified
open_profileboolean | nullOpen Profile
memorializedboolean | nullMemorialized
connections_countinteger | nullConnections Count
follower_countinteger | nullFollower Count
registered_atstring (date-time) | nullRegistered At
profile_languagestring | nullProfile Language
profile_countrystring | nullProfile Country
profile_picture_urlstring | nullProfile Picture Url
photo_urlstring | nullPhoto Url
cover_picture_urlstring | nullCover Picture Url
current_titlestring | nullCurrent Title
current_company_namestring | nullCurrent Company Name
current_company_linkedin_urlstring | nullCurrent Company Linkedin Url
current_position_started_onstring (date) | nullCurrent Position Started On
total_months_experienceinteger | nullTotal Months Experience
months_at_current_companyinteger | nullMonths At Current Company
months_in_current_roleinteger | nullMonths In Current Role
promoted_at_current_companybooleanPromoted At Current Company
promotions_countintegerPromotions Count
companies_countintegerCompanies Count
avg_months_per_companyinteger | nullAvg Months Per Company
linkedin_member_sincestring (date) | nullLinkedin Member Since
skills_flatstring[]Skills Flat
languages_flatstring[]Languages Flat
school_namesstring[]School Names
company_namesstring[]Company Names
section_totalsobject | nullSection Totals
experience_truncatedbooleanExperience Truncated
skills_truncatedbooleanSkills Truncated
scraped_atstring (date-time)Scraped At
16 nested collections
education[]array of objectsEducation
certifications[]array of objectsCertifications
languages[]array of objectsLanguages
skills[]array of objectsSkills
projects[]array of objectsProjects
publications[]array of objectsPublications
courses[]array of objectsCourses
honors[]array of objectsHonors
volunteering[]array of objectsVolunteering
recommendations[]array of objectsRecommendations
interests[]array of objectsInterests
patents[]array of objectsPatents
organizations[]array of objectsOrganizations
websites[]array of objectsWebsites
top_skills[]array of objectsTop Skills
experiences[]array of objectsExperiences
ordinal · position · company_name · company_linkedin_url · employment_type · workplace_type · location · description · started_on · ended_on · is_current · duration_months · duration_text · skills
Field names are a contract. pineapi versions the schema under /v1/ and does not rename or remove a field inside a version, because pipelines and agents cache the shape of a response and a silent rename breaks them quietly. Profile responses do not include email addresses.
When a profile is incomplete, the profile says so
40% of LinkedIn profiles come back with truncated work history and 68% with truncated skills. LinkedIn reports a section's true item count separately from the items it actually serves, and the two frequently disagree.
The consequence is arithmetic. Any years-of-experience figure computed from a scraped profile is systematically under-counted on four profiles in ten, so downstream calculations need to check the truncation flags.
pineapi returns experience_truncated and skills_truncated as booleans, and section_totals with the count LinkedIn claimed, so you can tell a genuinely short career from a profile that arrived clipped.
Filters that LinkedIn silently answers with nothing
LinkedIn's search stops returning results past a boolean complexity limit. Combine groups with AND or NOT and go beyond 9 OR alternatives and the search returns an empty set with no error, no warning and no indication that the query was the problem.
pineapi validates the query before running it and rejects it with HTTP 422, invalid_parameter and an explanation, rather than running a search that was never going to match anything and billing you the $0.12 minimum for it.
Asynchronous runs and terminal states
Searches take minutes and lookups usually take seconds. These observations are not an SLA. Poll until succeeded, partial, failed or cancelled; HTTP 200 on the status endpoint does not mean the run succeeded.
Latency is driven by result size more than anything else, so plan around the size you request rather than an average.
Pagination, and why one big search beats five small ones
start_page is a pointer into a LinkedIn search results page, not a stable cursor. LinkedIn re-ranks and re-shuffles results over time, so the same search run twice does not return the same profiles in the same order.
pineapi measured about 30% overlap between consecutive paginated calls on the same search. Since billing counts profiles delivered and duplicates are billed, paging through a large result set costs more and returns less of it.
Request a large result in a single search with a high max_items instead. One search for 500 profiles is cheaper and more complete than five searches for 100.
Errors, retries and what is billed
pineapi bills $0.012 per profile delivered, with a $0.12 minimum on any search that runs. Every error carries a machine-readable code, a sentence saying what went wrong and the fix, so an agent can correct itself without a human reading the log.
| Code | HTTP | What happened | What to do | Billed |
|---|---|---|---|---|
idempotency_key_required | 400 | The creation request has no Idempotency-Key header. | Send a unique key (a UUID works); reuse the same key and body when retrying. | No new charge |
invalid_api_key | 401 | The API key is missing, invalid or revoked. | Send a valid bearer token. | No new charge |
insufficient_scope | 403 | The key cannot perform this operation. | Use runs:write for creation/cancellation and runs:read for reads. | No new charge |
invalid_parameter | 422 | A field or filter is invalid, including max_items or an overly complex search_query. | Read error.param and error.message; correct the request before retrying. | No new charge |
insufficient_balance | 402 | Available credit does not cover the run reservation. | Top up the account before creating the run. | No new charge |
run_not_found | 404 | The run does not exist, belongs to another account, or has been deleted. | Check the run_id and retention window. | No new charge |
idempotency_key_reused | 409 | The same key was reused for a different request. | Use a new key for new work; keep the original key for an unchanged retry. | No new charge |
run_not_cancellable | 409 | The run has already finished. | Only cancel a run that has not finished. | No new charge |
rate_limited | 429 | The account token bucket is exhausted. | Wait for the Retry-After header duration before retrying. | No new charge |
too_many_queued_runs | 429 | The account queue is full. | Wait for queued work to start; respect Retry-After. | No new charge |
request_too_large | 413 | The request body exceeds 1,000,000 bytes. | Reduce the request body. | No new charge |
service_unavailable | 503 | The service could not complete this HTTP request in time. | Retry shortly; preserve Idempotency-Key on creation requests. | No new charge |
Transient failures are retried inside the search, so HTTP success when reading a run does not mean the run succeeded. Inspect status and failure: failed and cancelled are terminal and not billed. A partial run returns the profiles that were delivered. Retry a failed run as new work only after inspecting the reason.
How long results stay available
pineapi keeps search results available through the API and as CSV for 30 days. Re-downloading inside that window is free and is not billed again.
After 30 days the results are deleted. Store anything you need to keep.
Get an API key
Tell pineapi roughly what volume you are planning and what you are building. We can help you choose search or lookup and explain the onboarding steps. There is no sandbox or free test key.
pineapi bills $0.012 per profile delivered with a $0.12 minimum per search, so the first thing worth testing is a search you would actually run. All 53 fields come back on every profile.
LinkedIn scraper API questions
What is the best LinkedIn scraper API?
The right one returns the fields you need at a price you can predict without asking for your LinkedIn account. pineapi returns 53 fields plus 16 nested collections for $0.012 per profile delivered, needs no cookie, has no subscription, so a 500-profile month costs what 500 profiles cost.
How do I scrape LinkedIn profiles programmatically?
Describe who you want with filters, start a search with one POST, then read the result when it is done or receive a webhook with the completed run metadata. With pineapi, create a run with a bearer token and Idempotency-Key, poll its status, then download its profiles, with no headless browser to run, no proxy pool to rent and no login to keep alive. The complete request is on this page.
Is there a LinkedIn scraper that does not need cookies or an account?
Yes. pineapi requires no LinkedIn account, no session cookie and no browser extension, Authentication uses a bearer token issued to your account. You remain responsible for lawful use of the returned personal data.
What fields can I get from a LinkedIn profile?
53 top-level fields plus 16 nested collections: identity, headline and about, parsed location down to country code, status badges, follower and connection counts, current role, computed tenure and promotion counts, flattened skills and schools, truncation flags, and arrays for experience, education, skills, certifications, languages, projects, publications, patents and more. Email addresses are not returned.
How long does a search take?
Searches take minutes; lookups usually take seconds. Poll every 5 seconds for the first minute, then every 20 seconds. Resume with the same run_id if your client stops waiting.
Does the official LinkedIn API return other people’s profiles?
No. The official API returns the authenticated member’s own basic profile, and the richer Talent and Marketing APIs sit behind partner programmes with commercial review. There is no official endpoint that takes a set of filters and returns the people who match, which is why unofficial APIs exist.
What am I billed when a search returns nothing?
The $0.12 search minimum, because the search ran and completed. An empty result means no profile matched your filters, not that anything failed. A search that actually fails is not billed at all, and neither is a query pineapi rejects as too complex before running it.
Can I filter on the computed fields like promotions or tenure?
Not in v1. total_months_experience, months_in_current_role, promotions_count and the truncation flags are computed on every profile pineapi returns, so you can filter on them yourself after the fact, but they are not accepted as search filters in the request.
How long do you keep search results?
30 days, through the API and as CSV, with free re-downloads inside that window. After 30 days pineapi deletes them. Anything you need for longer belongs in your own storage.
Will the response schema change?
Not inside a version. pineapi versions the schema under /v1/ and does not rename or remove a field within it. New fields can appear; existing ones keep their names and types, because agents and pipelines cache the shape of a response.
Last updated 2026-10-19