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 Voyager GETs (profile, plus the skills finder for ?full=1), with upstream 401/403/404/410/429/999 mapped 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 (null locationName resolved 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 /profile route, 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=viewee and profilePositions?q=viewee (both with count=100) were real and both honored the higher count — those became the two completion calls behind ?full=1, detailed in the ?full=1 callout above and in docs/endpoint-map.md.
  • Career breaks weren't in the profile data at all — not capped, just absent. The entity graph has no CareerBreak type 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 and docs/endpoint-map.md rather 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/api call 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