Everything verified live between 2026-08-30 and 08-31 against one account, cross-read against
eight reverse-engineering repos. ✅ executed here · 📖 documented elsewhere, not executed ·
❓ unverified or contradicted.
refs/ contains eight upstream reverse-engineering repos. They already document the
mechanics well, and this file does not restate them. What follows is: pointers to the best
existing source, the places where we contradict it and why, and the findings that exist in no
ref repo. §10 maps which repo is a snapshot of which client, so you know how far to trust each.
Single best reference: refs/linkedin-toolkit/references/endpoints.md — §1 auth, §10 the
profile call and its graph walk, §11 the named Q-* gotcha catalogue, §13 the only measured
rate table anywhere. Read that first; read this for what it gets wrong or lacks.
Read: refs/linkedin-toolkit/references/endpoints.md §1 · refs/linkedin-internal-api/docs/01-AUTH-AND-COOKIES.md
Two cookies (li_at = session, JSESSIONID = CSRF), csrf-token header, restli version, the
normalized accept. No browser is needed for the calls — it only mints the cookies, and
/feed/ must be loaded because that is what sets JSESSIONID.
linkedin-toolkit §11 Q-CSRF-EXACT insists csrf-token must equal JSESSIONID
"byte-for-byte, including any quotes". That is wrong. Every call in this project stripped
them and returned 200. Two independent confirmations:
linkedin-internal-api,linkedin-relayandlinkedin-apiall strip them.linkedin-toolkitcontradicts itself — its own §14 cookie-extraction regex strips the quotes thatQ-CSRF-EXACTsays to keep.
It must match the browser and OS that minted the cookies. Cookies minted in Chrome-on-Windows
presented with a macOS UA is a device mismatch on every request. Chrome's UA Reduction freezes
minor/build/patch to 0.0.0 and pins Windows to NT 10.0 / macOS to 10_15_7, so those
literals are correct rather than stale. Brave sends a Chrome UA — it does not identify
itself. No ref repo treats the UA as session-scoped; they all hardcode one.
x-li-track / x-li-page-instance / x-li-deviceId are not needed for Voyager reads. The
one primary source has x-li-track commented out. An unjustified header is as likely to be a
fingerprint mismatch as a fix.
The refs contradict each other because each is a snapshot of a different generation (§10):
| generation | shape | status |
|---|---|---|
identity/profiles/{id}/profileView |
one monolithic JSON | ✅ HTTP 410 — retired |
identity/dash/profiles?q=memberIdentity&decorationId=… |
normalised {data, included[]} |
✅ 200 — use this |
graphql voyagerIdentityDashProfileComponents |
SDUI render tree, one call per section | ✅ 200, long tail only |
profileViewreturning 410 kills the profile path oftomquirk/linkedin-api, the most-starred client in the ecosystem — and the three repos that inherit from it. Verified by us;linkedin-mcpindependently marks it@deprecated … 410 (verified live 2026-06-13).
Read: refs/linkedin-toolkit/references/endpoints.md §10 — it documents this exact call,
the {data, included[]} shape, Q-POSITION-GRAPH-WALK, Q-MULTIPLE-PROFILES and the geo/date
handling. That section is correct and we add nothing to it.
GET /voyager/api/identity/dash/profiles
?q=memberIdentity&memberIdentity=<slug | bare ACoAA… id> # full urn: → 400
&decorationId=com.linkedin.voyager.dash.deco.identity.profile.FullProfileWithEntities-101
-101and-109both work — keep both as rotation fallbacks.FullProfile-76is a lighter live alternative (seen in real client traffic).- A decoration is a server-side FIELD RECIPE. "Absent from this response" never means "Voyager cannot supply it" — §7 lists what other resources carry.
- ~92 KB / ~104 records on a populated profile. Collection refs on the subject
Profile:
*profilePositionGroups *profileEducations *profileSkills *profileCertifications
*profileLanguages *profileCourses *profileProjects *profileHonors *profilePublications
*profilePatents *profileOrganizations *profileVolunteerExperiences *profileTestScores
*profileTreasuryMediaProfile <- featured media; the ONLY collection seen `unresolved`
*profileVideoPreview <- paging WITHOUT a `total` (see §5)
*profileRingStatusCollection <- paging WITHOUT a `total` (see §5)
Profile-level location is NOT locationName — that is null on every fixture. Resolve
geoLocation["*geo"] in included[] → Geo.defaultLocalizedName. 📖 Not our finding —
refs/linkedin-toolkit/references/endpoints.md:527 states this rule verbatim; it is repeated
here only because omitting it caused a rebuild to emit null for every profile's location
("San Francisco, California, United States"). location.countryCode is separate.
(Position-level location IS Position.locationName, and is absent on some positions.)
photoFilterTypeis the photo editing filter, NOT a badge. Observed values across nine profiles:ORIGINAL,STUDIO,None.⚠️ Exact path:profilePicture.photoFilterEditInfo.photoFilterType. It is NOT onprofilePicturedirectly — verified null there on every fixture. The wrong path readsundefinedand never errors.⚠️ DISPROVEN 2026-08-31 — this file previously claimedphotoFilterTypecarries the#OpenToWork/#Hiringbanners. It does not. Two profiles verified to display those frames both returnedphotoFilterType: "ORIGINAL", so a test against it can never fire and every profile received a confidentfalse— the API asserting "not open to work" about people who are. Searching every saved payload foropenToWork,jobSeeker,memberBadges,photoFrameandframeTypereturns zero hits, whileshowPremiumSubscriberBadgeandshouldShowSourceOfHireBadgeARE present: the badge namespace exists in this decoration and simply has no open-to-work member. TheisOpenToWork/isHiringfields were removed rather than left emitting a permanent null. Do not reintroduce them from this field.urn:li:fsd_memberRelationship:<profileId>→memberRelationshipUnion.<branch>.memberDistancegives connection degree in the same response — when present. It is absent on 3 of 5 fixtures, so treat null as "not supplied", never as "out of network".enrich=socialsupplies it more reliably vianormalizedProfiles.distance.⚠️ A response also carriesMemberRelationshiprecords for other people (7 of 8 on one fixture) — an unwarned sibling ofQ-MULTIPLE-PROFILES. Key on the subject's own id.
Read: refs/linkedin-toolkit/references/endpoints.md §11 — the named Q-* catalogue
(Q-NORMALIZED-RESOLVE, Q-POSITION-GRAPH-WALK, Q-MULTIPLE-PROFILES, Q-ATTRIBUTED-TEXT,
Q-DATA-DOUBLE-WRAP, Q-VARIABLES-ENCODING, Q-MEMBERIDENTITY-NO-FULL-URN,
Q-PROFILE-403-PER-PROFILE). All correct, all still apply. Do not re-derive them.
One addition of ours: components live in included[], not under the section root. Walking
only data.data.<root> returns zero entities — the same normalized-resolve pattern, but it
catches people out on the components surface specifically.
Verified by grep against refs/. Genuinely absent there (0 hits): positionHeld,
NATIVE_OR_BILINGUAL, photoFilterType, memberDistance, pagedListComponent, VIEW_DETAILS.
Company.universalName is in linkedin-mcp/src/types.ts and linkedin-api/linkedin_api/linkedin.py
(16 files in refs/ mention it), and the vectorImage rootUrl + widest-artifact rule is in
linkedin-mcp/src/browser/normalize.ts. An earlier version of this file claimed the whole block
was novel — an over-broad "verified by grep" is exactly the failure this wrapper format exists to
prevent.
Organization.positionHeld NOT `position` — the wrong name yields silent nulls
Language.proficiency an ENUM ("NATIVE_OR_BILINGUAL"), not the UI string
Course.number the course code, e.g. "FIN 374C"
Position.locationName ABSENT on some positions — sampling ONE record to infer
the schema is how this was missed
occupation["*profileEducation"] → Education.schoolName = institution for courses/orgs
Company.url the LinkedIn PAGE, not the company website
Company.universalName the slug (📖 in refs — not our finding)
Company.industry { "*urn:li:fsd_industry:43": "urn:…" } — take the VALUE
images (📖 vectorImage rule is in refs; the DUAL SHAPE below is ours)
displayImageReference | displayImage, and either
{vectorImage: rootUrl + widest artifact} OR a plain {url}
(LinkedIn defaults). Handling only the first returns null
for anyone using a default image.
company detail spread across SEVERAL records — a Company (name, description)
and an OrganizationalPage-shaped one (websiteUrl, foundedOn).
Merge every record matching the slug; selecting one loses data.
Every collection resolves to a CollectionResponse whose shape tells you which state it is in:
| state | rule | note |
|---|---|---|
| genuinely empty | paging.total === 0 |
|
| complete | total === returned |
|
| truncated | total > returned and returned >= paging.count (the cap) |
more exist → paginate |
| unresolved | total > returned and returned < cap |
failed decoration, not "no data" |
paging with NO total — *profileVideoPreview and
*profileRingStatusCollection, present on all five fixtures. They match no row above; treat a
missing total as unknown and skip them rather than assigning a state.
total exists, it is the ONLY discriminator. An earlier version of this table claimed the
*elements (starred) vs elements (unstarred) key told you whether a collection was empty.
That is wrong, and this project's own fixture disproves it: unresolved-media's
*profileTreasuryMediaProfile is unstarred "elements": [] with paging.total: 10 — i.e. ten
records exist and none were returned. The star only indicates whether members arrived as URN
references; it says nothing about emptiness. A parser built on the star ships a broken
unresolved detector.
paging.count IS the cap, and it is in every CollectionResponse — read it rather than
hardcoding. The "10 for position groups, 20 for most others" figures below are what it happens to
contain today, not constants.
refs/linkedin-internal-api/docs/27-JOBS.md states as a hard rule
that paging.count must never be read because "it echoes the page size we sent". That is true
for the jobs-search route it describes and wrong here — profile collections are not paged by
us. A dated snapshot, correct in its own context, harmful in this one.
*profilePositionGroups, 20 for most others. Nothing in the payload says
"truncated" — compare paging.total against what arrived or you return a partial profile as a
complete one. Note the cap is on position groups, not roles: 7 groups can yield 10 positions.
GET /voyager/api/graphql
?queryId=voyagerIdentityDashProfileComponents.942ee340539e7b43ee193df3d6ec4be2
&variables=(pagedListComponent:<ENCODED_URN>,start:10,count:10)
pagedListComponent, NOT pagedListComponentUrn despite the query being
named …ByPagedListComponentUrn. Wrong name → HTTP 200 carrying
data.errors[].message: "Variable 'pagedListComponent' has coerced Null value…" — read the body,
the error names what it wants.
The URN is CONSTRUCTIBLE — no discovery call needed:
urn:li:fsd_profilePagedListComponent:(<memberId>,<SECTION>_VIEW_DETAILS,<scope>,NONE,en_US)
scope = urn:li:fsd_profile:<memberId> (experience, education, most)
= urn:li:fsd_profileTabSection:ALL_SKILLS (skills)
Sections seen: EXPERIENCE EDUCATION SKILLS COURSES LANGUAGES HONORS_AND_AWARDS LICENSES_AND_CERTIFICATIONS ORGANIZATIONS INTERESTS (each _VIEW_DETAILS).
✅ Verified: experience start:10,count:10 returned exactly the 2 roles missing from a
truncated first page. No reference repo implements this.
Read: refs/linkedin-relay/docs/research/R3-linkedin-surface.md §2 — the fullest write-up
of the variables=(…) literal syntax: (k:v,k2:v2) = object, List(a,b) = array, a | b = OR,
arbitrary nesting. Also linkedin-toolkit §11 Q-VARIABLES-ENCODING.
The one thing worth repeating because it silently 400s: structural ( ) : , stay raw, but
the same characters inside a value must be percent-encoded. JS encodeURIComponent leaves
( and ) alone, so encode them explicitly:
encodeURIComponent(v).replace(/\(/g, '%28').replace(/\)/g, '%29')LinkedIn's GraphQL errors name the variable they want. Two problems were solved this way after guessing had failed:
Variable 'pagedListComponent' has coerced Null value for NonNull type 'String!'
Variable 'viewerPermissions' … Expected type 'Map' but was 'String'
A 200 can also carry data.errors — the status code lies. Always parse the body.
| want | where |
|---|---|
| follower count, connection distance, badges, contact | identity/normalizedProfiles/<id> → keys include distance, *followingInfo, *badges, confirmedEmailAddresses, confirmedPhoneNumbers ✅ 200 observed, body never saved |
| interests, skill endorsements | ProfileComponents sectionType:interests / skills ✅ 23 rows / 14 skills parsed |
| connection count (e.g. "500+") | only the rendered document — display text, not an API field |
| company website | voyagerOrganizationDashCompanies, +1 per company |
| contact info | legacy REST is 410; ProfileContactInfoById = 8aa5843dfcd1e81a06db3a87fb2e0c20 (Android catalogue) 📖 never called |
A queryId is a server-side persisted-query id, not a client credential. /voyager/api/ is
one backend, so any registered hash resolves for any caller. But the hash identifies a field
selection, and web and Android select different fields:
| web | Android | |
|---|---|---|
ProfileComponentsBySectionType |
86824295e1093fb0f5acdd8d57213aaa |
ab68d8cbfe2835a1f1e2ac6c2646c2c0 |
required accept |
…normalized+json+2.1 |
application/json |
| response | {data, included[]} |
{data} — inlined, no side-table |
✅ Measured, same hash, only accept changed: web accept → HTTP 500
"A record in the included list does not have a type… logoResolutionResult"; Android accept →
200. The 500 is not hash rejection — the query ran and the web serializer could not type an
Android-shaped record.
Compatibility is per-hash: 942ee340… (pagination) is APK-derived and works under the web
accept. Take the strings, not the fingerprint — never copy Android's UA/x-li-track, which
would contradict a browser-minted cookie. accept is content negotiation, not identity.
Why it matters: web queryIds must be captured from live logged-in traffic one at a time; the APK ships all 481 statically. Decompile once — the cheapest answer to hash rotation.
The web client mints queryIds at runtime via getGraphQLQueryId() against its webpack bundle, so
they are pinned to LinkedIn's deployed build. Decoration -<n> suffixes rotate the same way.
Cadence unknown.
⇒ Hashes are config with provenance + capturedAt, never literals in request logic. A 400
naming an unknown decoration is a drift signal, not a bug. profileView's 410 is the
cautionary tale: it silently invalidated the ecosystem's most popular client.
The eight reference repos disagree with each other constantly. They are not wrong so much as snapshots of different clients at different dates, and knowing which is which turns a contradiction into a date-stamp.
LinkedIn has four surfaces in play:
| surface | shape | notes |
|---|---|---|
legacy REST identity/profiles/… |
inline decoration=(field,field,…) |
being retired — profileView is 410 |
dash REST identity/dash/profiles?q=… |
named decorationId=…-<n> |
current; what we use |
dash GraphQL graphql?queryId=<name>.<32hex> |
persisted queries, hash rotates | current; sections, pagination, company |
SDUI /flagship-web/rsc-action/… |
protobuf-JSON, page-bound headers | newer UI areas; not requests-friendly |
| repo | derived from | client | dated | what it is good for | where it misleads |
|---|---|---|---|---|---|
| linkedin-cli | Android APK v4.1.1209, decompiled | Android | 2026 | 481 queryIds statically — names + hashes, no session needed | hashes are Android field selections: some need accept: application/json (§8). Its UA impersonates a Galaxy S22 |
| linkedin-internal-api | live web capture (CDP crawler + click-and-record) | web | 2026-07 | 130 endpoints, the auth model, honest verified/inferred labels (the Q-* catalogue is linkedin-toolkit's, not this repo's) |
write-heavy; its read docs assume the SDUI/section route |
| linkedin-mcp | live web capture | web | verified 2026-06-13 | dash/profiles finders, FullProfile-76, TS types (websiteUrl, followerCount) |
labels 148b1aeb… as "company" — it is actually guide-entry-points |
| linkedin-toolkit | web, browser + headless modes | web | 2026-04-25 | the only measured rate-limit table (§ LEARNED-02) | claims csrf-token must keep its quotes — contradicted by every call we made |
| linkedin-api (tomquirk) | legacy web REST | web legacy | PyPI 2.3.1, Nov 2024 | the inline-decoration syntax; the Rest.li variables grammar |
its entire profile path is 410. Four repos inherit from it |
| linkedin-relay | design doc plus a released CLI (v3.0.3) | web | 2026-08 | the best written analysis of the surface, rate limits and legal exposure; its README already states the zero-retry / cooldown-as-a-file design we arrived at independently | earlier drafts of these docs called it "research only, never built" — wrong, it ships code |
| linkedin_scraper | Selenium DOM scraping | browser DOM | — | — | not API-based at all; skimmed only |
| linkedin-voyager_api | Ruby, legacy Voyager | web legacy | — | — | same legacy generation as linkedin-api; skimmed only |
Honest note: the first six were read in depth.
linkedin_scraperandlinkedin-voyager_apiwere skimmed — their generation is inferred from structure, not audited.
- Need a queryId?
linkedin-cli(static, no session) → then checkacceptper §8. - Need to know if a route still works? Check the date. Anything tracing to
linkedin-apiis a Nov-2024 legacy snapshot and probably 410. - Need parsing behaviour?
linkedin-internal-api'sQ-*catalogue, cross-checked againstlinkedin-mcp's TypeScript types. - Need numbers? Only
linkedin-toolkit§13 measured anything. Everything else is lore. - Two repos disagree? The later web capture usually wins — but verify, because a name can
be wrong in a live capture too (
148b1aeb…was captured correctly and labelled wrongly).
The route none of them use: the web client's own JS bundle registers every query as
{kind:"query", id:"<resource>.<32hex>", name:"<slug>"}. That is 933 queries with
human-readable names, from static.licdn.com, with no cookies and no API calls — strictly
better than both traffic capture and APK decompilation for finding a hash.