Base URL — every example below works against either; swap the host:
http://localhost:3000 # local devhttps://linkedin-profile-api-production-3c84.up.railway.app # live deployment
Every endpoint below calls a real, verified LinkedIn resource — none of it is
guessed. How each one was found: a web capture plus an APK string search,
cross-checked against each other — see
Approach for the
discovery process and Provenance → code for exactly
which APK constant backs which value in the request.
Capped sections. LinkedIn's own projection caps skills at 20,
experience at 10 position groups (a group is the "Company — 3 roles"
block, so a group with several roles can still push the flattened list past
10 entries), and featured at 3 — regardless of how much the profile
actually has. Every cap is reported honestly via meta.partial, whenever
LinkedIn's own paging.total exceeds what was returned.
?full=1 spends one extra upstream request per capped section, in
parallel, only for sections actually capped:
profileSkills?q=viewee&count=100 completes skills,
profilePositions?q=viewee&count=100 completes experience (LinkedIn
honors count=100 on both finders: a rich profile capped at 10 position
groups returns all of them this way). A profile under both caps makes
zero extra requests even with ?full=1 — there's nothing to complete.
Completed experience entries carry
companyLogo/companyUrl/employmentType only for roles the main call had
already resolved.
featured stays capped even with ?full=1 — no completion endpoint
exists for it; profileTreasuryMedia caps at 3 regardless of count,
verified live. meta.partial.featured at least tells you when it's capped.
Full request-by-request breakdown in
docs/endpoint-map.md.
What those two completion calls actually return — both are LinkedIn's
own upstream endpoints, called server-side; a caller of this API never sees
them directly, but this is the real shape mergeSkillsCompletion() /
extractSkillNames() and mergePositionsCompletion() /
extractFullExperience() parse (trimmed to two entries each).
GET voyager/api/identity/dash/profileSkills?q=viewee&profileUrn=<urn>&count=100:
Note what's missing from the position entry: no resolved Company entity,
just a bare companyUrn. That's the whole reason
companyLogo/companyUrl/employmentType come back null for roles
recovered only by this call — see the callout above.
How career breaks are fetched. Not from either call above, or any
Rest.li resource — LinkedIn's entity graph doesn't carry them at all.
Every lookup makes a third, always-on request to the server-driven-UI
GraphQL endpoint that renders the Experience section:
GET voyager/api/graphql ?queryId=<CAREER_BREAK_QUERY_ID> &variables=(profileUrn:<urn>,sectionType:experience)Accept: application/json
Accept: application/json is the whole trick — every other call in this
project asks for application/vnd.linkedin.normalized+json+2.1, but
LinkedIn's own normalized serializer throws a 500 on this particular
query's response. This is the one call that doesn't use it (see
voyagerGet(url, { accept }) in
src/linkedin/client.js).
The response is a component tree of rendered text, not an entity graph
— a career break looks like this (real, anonymized capture):
extractCareerBreaks() walks the tree for
entityComponent nodes whose subtitle.text is exactly "Career Break"
— ordinary roles in the same list carry a company name there instead, so
the two never collide. Best-effort and deliberately so: the persisted query
id is versioned like decorationId and will be retired eventually; if the
call fails for any reason, careerBreaks comes back [] and nothing else
about the lookup is affected. Full write-up, including why this was nearly
missed entirely, in
docs/endpoint-map.md.
Same url parameter. Returns the unprocessed Voyager payload — data plus
the flat included[] array of URN-cross-referenced entities — before
normalization. This is what src/linkedin/normalize.js consumes; the "Full JSON ·
not normalised" tab in the browser UI renders it. Also GET /profile/sample/raw
for the payload behind /profile/sample.
?full=1 (same flag as /profile) merges in the same skills/experience
completion calls, so this view isn't stuck showing the main call's capped
lists while /profile?full=1 shows the complete ones. Skills are merged
faithfully — the capped *profileSkills collection is repointed at every
skill. Experience only gets newly-seen Position entities added to
included[] (searchable in the browser UI's Resolver tab) — the completion
finder returns a flat list with bare companyUrn/employmentTypeUrn, not the
nested PositionGroup shape the main call's decoration returns, so
reconstructing that nesting here would mean inventing group data LinkedIn
never actually sent for this call. See mergeSkillsCompletion() /
mergePositionsCompletion() in
src/linkedin/normalize.js.
Example response (abridged — a real payload runs to ~100 entities):
LinkedIn's own response embeds a little of the credentialed account's
identity in this payload (its connection-degree relationship to people
referenced on the profile) — irrelevant to the person being looked up, and
not something a public, unauthenticated endpoint should hand out. Both raw
endpoints strip it via sanitizeRawPayload() before responding; see
src/linkedin/normalize.js.
Exploring the raw payload: the browser UI's Resolver tab searches this
included[] graph by URN, name, or array index, then lets you follow it —
what an entity points to, what points back to it — with a real breadcrumb and
Back (a history stack, not a "referenced by" shortcut that breaks once an
entity has more than one referrer). List and Diagram are two views onto the
same graph:
Every key on a selected entity is also color-coded against what
normalizeProfile() actually reads off it, so "is this field used, or just
along for the ride?" is answered at a glance:
Returns a real, pre-captured profile normalized through the exact same code path.
Never contacts LinkedIn — always available, even when a live lookup would be
rate-limited or bot-blocked. Useful for verifying the response shape.
?full=1 works here too, and still never contacts LinkedIn: the complete
skills/experience lists are pre-captured fixtures
(fixtures/raw-positions.json, raw-skills.json), merged in the same way a
live ?full=1 lookup merges its upstream completion calls — see
GET /profile above.
Same shape as GET /profile's response — the one difference is meta.source:
Redirects (302) to the showcase site — the "Try it" app plus these docs.
Configurable via SHOWCASE_URL; set it empty to disable the redirect.
The legacy self-contained browser UI still ships with the server at
/index.html — paste a profile URL, see the rendered result, toggle raw
JSON. Deep-linkable: /index.html?url=<linkedin profile url>.
The schema is this project's own design — LinkedIn's raw payload is a flat graph
of ~100 cross-referenced entities; this is the flattened, cleaned view.
Every field is optional. Missing scalars are null; missing lists are [].
Nothing throws on an absent section.
profileUrn is LinkedIn's stable internal id for the member. The public
slug in profileUrl can be changed by its owner; the URN cannot, so it's the
durable key for callers that store results.
badges are always all three booleans — an absent flag means false,
not unknown.
Dates are "YYYY" or "YYYY-MM" strings (LinkedIn rarely provides a day).
A currently-held role has endDate: null and current: true.
meta.partial appears only when LinkedIn returned a capped section
(skills, experience, or featured — see the capped-sections callout under
API documentation), and only the capped keys are
present (a profile with 5 roles and 10 skills would show partial.skills
alone). experience reports returnedGroups/totalGroups rather than
returned/total because LinkedIn's cap is on position groups (the
"Company — 3 roles" block), not the flattened role entries in the response.
volunteerExperience[].cause is LinkedIn's raw enum
("ECONOMIC_EMPOWERMENT", "EDUCATION", …), not humanized — same treatment
as pronouns. Formatting an enum into display text is a UI concern; both
showcase UIs do it client-side.
publications[].authors only includes co-authors LinkedIn's response
happens to resolve to a full Profile entity in the same payload — usually
true for people on LinkedIn, never true for a name-only credit. A co-author
this can't resolve is left out of the array rather than guessed at.
Datacenter-IP bot detection. LinkedIn runs bot-detection on its endpoints
(PerimeterX / HUMAN, reCAPTCHA Enterprise, Cloudflare Bot Management). Calls
from datacenter IP ranges or at volume may be challenged, rate-limited, or
blocked — returned as 503 upstream_blocked. This service is built for
low-volume, on-demand, single-profile lookups. /profile/sample always works.
decorationId is versioned. LinkedIn increments the trailing number
(-107 now, -93 in an earlier capture) as the schema evolves; old versions
eventually 410. Configurable via DECORATION_ID — see
Deployment.
Session lifetime.li_at is long-lived but revoked on password change and
some security events. No refresh flow — cookies are re-extracted manually.
Three sections are capped by LinkedIn's own projection, regardless of how
much the profile actually has: skills (20 max), experience (10
position groups max — a group is the "Company — 3 roles" block, so a
group with several roles can still push the flattened list past 10 entries),
and featured / profileTreasuryMedia (3 max). All three are reported
honestly via meta.partial (skills, experience, featured) whenever
LinkedIn's own paging.total exceeds what was returned — see
Response schema.
Skills and experience get completed; featured doesn't. Pass ?full=1
to have the service spend one extra request per capped section:
profileSkills?q=viewee&count=100 for the whole skills list, and
profilePositions?q=viewee&count=100 for every individual role (LinkedIn
honors count on both finders — verified live: a rich profile capped at 10
position groups returns all 33 roles this way). See
docs/endpoint-map.md. Completed experience entries
carry companyLogo/companyUrl/employmentType only for roles the main
call had already resolved — profilePositions returns bare company URNs,
not inlined Company entities, so roles recovered only by completion get
null for those three fields (title, company, dates, description, location
are unaffected). featured stays capped even with ?full=1:
profileTreasuryMedia is capped at 3 of ~10 even with count=100 —
verified live — so there's no bigger request that would help, and its
response is a different entity shape besides. meta.partial.featured at
least tells you when it's capped.
Career breaks come from LinkedIn's SDUI, and are lower-confidence than
everything else. They are returned, in their own careerBreaks array —
but they are the one part of the response not derived from the entity
graph. No Rest.li resource carries them: not profilePositionGroups, not
profilePositions, not any of the 17 section pointers on the
FullProfileWithEntities decoration, and career break is not one of the six
values employmentTypes returns (all verified against a profile that has
one). They exist only in the server-driven-UI GraphQL response, as rendered
text, so:
dates are parsed from display strings ("Feb 2025 - Jun 2025 · 5 mos")
rather than read from {year, month} records;
it is English-only — the request pins x-li-lang: en_US and the
parser expects English month abbreviations;
the persisted GraphQL query id is versioned like DECORATION_ID and
will eventually be retired — override it with CAREER_BREAK_QUERY_ID.
The lookup is best-effort: if any of that breaks, careerBreaks returns
[] and no entity-derived field is affected. It is a separate array
precisely so display-parsed data never mixes into experience. Full
write-up, including the Accept-header quirk that makes the call work at
all, in docs/endpoint-map.md.
Coverage varies by relationship. Private profiles, out-of-network members,
and some fields depend on the credentialed account's relationship to the target.
Sections a profile hasn't filled in (e.g. languages) come back as [].
Terms of Service. Automated access to LinkedIn is contrary to LinkedIn's
User Agreement. This project is for a technical hiring exercise and educational
purposes.