A reverse-engineered LinkedIn Profile API
Paste a LinkedIn profile URL, get the profile back as structured JSON — name, headline, location, experience, education, skills, certifications, languages, images. One authenticated request to LinkedIn's own internal API. No browser automation, no HTML scraping.
Try it → · Sample response · Source on GitHub
- Node + Express on Railway — the hosted API
- Single Voyager API call per lookup, then a pure-function transform
- Provenance verified against the LinkedIn Android app's compiled code
- Offline-tested normalizer — every real-payload edge case has a test
What it does
GET /profile?url=<linkedin profile url> returns:
{
"profile": {
"name": "Reid Hoffman",
"headline": "Co-Founder, LinkedIn, Manas AI & Inflection AI...",
"location": "United States",
"about": "…",
"experience": [
{ "title": "Partner", "company": "Greylock",
"startDate": "2009-11", "endDate": null, "current": true, "description": "…" }
],
"education": [
{ "school": "Stanford University", "degree": "B.S.", "fieldOfStudy": "Symbolic Systems" }
],
"skills": [ "Entrepreneurship", "Venture Capital", "Strategy" ],
"certifications": [ ],
"languages": [ ],
"featured": [ { "title": "Blitzscaling: Book Trailer", "provider": "SlideShare" } ],
"images": { "profilePicture": "https://media.licdn.com/…", "backgroundImage": "…" }
},
"meta": { "fetchedAt": "…", "cached": false, "source": "linkedin-voyager" }
}Full field reference and error codes are on the API page. Every profile endpoint that was found — and why the single call above covers almost all of them — is on the endpoint map.
Architecture
src/linkedin/client.js— the authenticated VoyagerGETs (profile, plus the skills finder for?full=1), with upstream401/403/404/410/429/999mapped onto clean API statuses.src/linkedin/normalize.js— resolves LinkedIn's URN-linked entity graph into a clean tree. Pure function, no I/O, tested entirely offline against a real captured payload — including every edge case that payload contains (nulllocationNameresolved via a Geo lookup, all-null Geo stubs, placeholder school names, unsorted positions, missing company logos). A line-by-line walkthrough of the request itself is on The request.src/server.js— Express: the/profileroute, an always-available/profile/sample, a per-IP rate limiter, and a short-lived response cache.
Approach — how the endpoint was reverse-engineered
LinkedIn's apps are backed by an internal API known as Voyager
(https://www.linkedin.com/voyager/api/...). Profile data lives in a resource
called identityDashProfiles. The goal was to confirm that a specific, stable,
first-party way of calling that resource exists — not a fragile side path.
The story
The first working script came from asking an LLM to write one. It failed to
run. Told it that, and it came back with a different endpoint path and a
different decorationId — and that version worked, returning a real raw
response.
That was the start of the problem, not the end of it. A response coming back doesn't mean the URL is genuine, stable, or first-party — an LLM can hand back something that happens to resolve today and quietly breaks next week, or that was never a real LinkedIn client pattern to begin with, just a plausible-looking guess that got lucky once. There was no way to trust it without verifying where it actually came from, independently of what the LLM said.
The first check was the browser itself: log into LinkedIn for real, open
DevTools, load a profile and its full experience list, and watch the Network
tab for a voyager/api call that matched what the script was hitting.
There wasn't one — loading a profile makes zero such calls (see the
website check below for exactly what that showed).
That null result was itself the answer: LinkedIn's website doesn't call this
resource from the client anymore, so no browser capture could ever confirm
or deny the script's URL either way. It was a dead end, and the only
direction left to go.
With the browser ruled out, the only independent source left was LinkedIn's
own Android app. Downloaded the APK, unzipped it, and grepped the
compiled code for the exact names the script was using —
identityDashProfilesByMemberIdentity, FullProfileWithEntities-107, the
whole decorationId mechanism (full walkthrough in the Android app
check below). They were there, verbatim — not
invented, not a coincidence, sitting inside LinkedIn's own shipped client.
That's the point where decorationId stopped being "a value an LLM gave me"
and became a real, versioned concept the Android app itself depends on.
That confirmed the one call this project actually makes. But once it was returning real data, two more gaps showed up, and both got closed the same way — back to the APK, not back to the LLM:
- Skills and experience came back capped at 20 and 10 position groups,
no matter how much the profile actually had. Rather than guess at a fix,
the same APK was grepped again, this time for sibling finders on the same
resource, and each candidate was live-tested.
profileSkills?q=vieweeandprofilePositions?q=viewee(both withcount=100) were real and both honored the higher count — those became the two completion calls behind?full=1, detailed in the?full=1callout above and indocs/endpoint-map.md. - Career breaks weren't in the profile data at all — not capped, just
absent. The entity graph has no
CareerBreaktype to grep for, so the resource-sibling approach didn't apply; the APK does list career break as a real profile section, just not one backed by a Rest.li resource, which is what sent the search to LinkedIn's GraphQL side instead — a persisted query, also confirmed against the same APK, that renders the section as display text rather than structured data. That trail, and the one non-obvious header it takes to get a usable response out of it, is written up in full in the career-breaks callout above anddocs/endpoint-map.mdrather than repeated here.
How it was verified
Two independent sources, checked August 2026. The first came up empty; the second is what actually confirmed everything above.
1. The website check
Inspected with Chrome DevTools while loading a profile and its full experience
list. Reproduce: log in, open a profile, DevTools → Network, filter voyager,
reload, then open …/in/<id>/details/experience/.
- LinkedIn's website now uses a server-driven UI — pages are rendered on
LinkedIn's servers and delivered as finished HTML. The browser makes no
voyager/apicall for profile content; the data never reaches the client as JSON. This is the dead end described in the story above — confirmed, not assumed. - The one profile-identity request the browser still makes is a GraphQL gateway
call whose response is a two-field stub, typed
com.linkedin.voyager.dash.identity.profile.Profile— the same backend resource this project calls, via a different transport. Not enough to confirm the full request on its own, but a second data point pointing the same direction as what the APK later confirmed outright.
So the website rules out browser capture as a source, but doesn't rule out the resource itself — it just means a direct call is the only non-scraping option left to verify.
2. The Android app check
com.linkedin.android version 4.1.1239 — APK unpacked, compiled code searched
for string constants. Reproduce:
unzip -o com.linkedin.android*.apk -d apk
strings apk/classes*.dex | grep -E \
'FullProfileWithEntities|FullProfileByMemberIdentity|identityDashProfilesByMemberIdentity|deco\.identity\.profile'Full walkthrough with expected output: docs/apk-provenance.md.
Present in the app, verbatim:
| Constant | Meaning |
|---|---|
identityDashProfilesByMemberIdentity |
The finder this project uses (q=memberIdentity) |
com.linkedin.voyager.dash.deco.identity.profile.FullProfileWithEntities-107 |
The response projection ("decoration") this project uses |
FullProfileByMemberIdentity, FullProfileWithEntitiesV2 |
Related finder / projection names |
voyagerIdentityDashProfiles.<hash> (×~35) |
Persisted GraphQL queries against the same resource |
Conclusion: the mobile app uses the identical identityDashProfiles resource
over a Rest.li binding with a decorationId. Calling it via q=memberIdentity
with a FullProfileWithEntities decoration is a genuine, current first-party
LinkedIn client pattern.
The same grep-then-live-test pass, repeated later against sibling strings
in the same APK, is also what turned up profileSkills/profilePositions
(the ?full=1 completion calls) and the persisted GraphQL query behind
career breaks — none of those three needed a second trip to the website
check, since the website had already been ruled out as a source.
Provenance → code
The single request is implemented twice — once in
fetch_profile.py (fetch_profile_view), the original spike
that established the endpoint, and once in
src/linkedin/client.js, the port the service runs.
A line-by-line, beginner-friendly walkthrough of the request is in
docs/how-the-fetch-works.md.
Every non-obvious value maps to something observed above:
Value in fetch_profile.py |
Line | Backed by |
|---|---|---|
https://www.linkedin.com/voyager/api/identity/dash/profiles |
L64 | voyager/api/ + identity/profiles path fragments in the APK |
q=memberIdentity |
L66 | APK constant identityDashProfilesByMemberIdentity; web GraphQL variables=(memberIdentity:…) |
decorationId (DECORATION_ID, default …FullProfileWithEntities-107) |
L33, L68 | APK constant com.linkedin.voyager.dash.deco.identity.profile.FullProfileWithEntities-107 |
csrf-token: <JSESSIONID> |
L73 | web capture: the csrf-token header equals the JSESSIONID cookie value on every Voyager call |
x-restli-protocol-version: 2.0.0 |
L74 | sent by every Voyager call in the web capture |
Accept: application/vnd.linkedin.normalized+json+2.1 |
L76 | Accept header used by LinkedIn's own web client for this resource |