API documentation

Base URL — every example below works against either; swap the host:

http://localhost:3000                                         # local dev
https://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.

GET /profile

Fetch and normalize a LinkedIn profile.

Query param Required Description
url yes A LinkedIn profile URL. Accepts https://www.linkedin.com/in/<id>/, locale subdomains (in.linkedin.com), trailing paths, and query strings.
full no Also fetches the complete skills and experience lists — see the callout below. Off by default.

Against the local dev server:

curl 'http://localhost:3000/profile?url=https://www.linkedin.com/in/williamhgates/'
curl 'http://localhost:3000/profile?url=https://www.linkedin.com/in/williamhgates/&full=1'

Against the live deployment — same responses, no local setup needed:

curl 'https://linkedin-profile-api-production-3c84.up.railway.app/profile?url=https://www.linkedin.com/in/williamhgates/'

Successful responses are cached in-memory for ~1 hour (meta.cached tells you which you got). full=1 and the default are cached separately.

Example response (abridged — full shape in Response schema below):

{
  "profile": {
    "name": "Reid Hoffman",
    "headline": "Co-Founder, LinkedIn, Manas AI & Inflection AI...",
    "location": "United States",
    "experience": [{ "title": "Partner", "company": "Greylock", "current": true }],
    "skills": ["Entrepreneurship", "Venture Capital", "..."],
    "careerBreaks": []
  },
  "meta": { "fetchedAt": "2026-08-31T12:00:00.000Z", "cached": false, "source": "linkedin-voyager" }
}

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:

{
  "data": {
    "paging": { "count": 100, "start": 0, "total": 47 },
    "*elements": ["urn:li:fsd_skill:(...,1)", "urn:li:fsd_skill:(...,2)"]
  },
  "included": [
    { "entityUrn": "urn:li:fsd_skill:(...,107)", "name": "Career Development" },
    { "entityUrn": "urn:li:fsd_skill:(...,115)", "name": "Venture Philanthropy" }
  ]
}

GET voyager/api/identity/dash/profilePositions?q=viewee&profileUrn=<urn>&count=100:

{
  "data": {
    "paging": { "count": 100, "start": 0, "total": 33 },
    "*elements": ["urn:li:fsd_profilePosition:(...,2564122178)", "..."]
  },
  "included": [
    {
      "entityUrn": "urn:li:fsd_profilePosition:(...,2564122178)",
      "title": "Co-Founder, Executive Board Chair",
      "companyName": "Manas AI",
      "companyUrn": "urn:li:fsd_company:106067845",
      "dateRange": { "start": { "month": 1, "year": 2025 } }
    }
  ]
}

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):

{
  "entityComponent": {
    "titleV2": { "text": { "text": "Personal goal pursuit" } },
    "subtitle": { "text": "Career Break" },
    "caption": { "text": "Feb 2025 - Jun 2025 · 5 mos" },
    "subComponents": { "components": [{ "components": { "textComponent":
      { "text": { "text": "Took time out to travel and train..." } } } }] }
  }
}

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.

GET /profile/raw

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):

{
  "data": { "*elements": ["urn:li:fsd_profile:ACoAAAAABL0B..."] },
  "included": [
    {
      "entityUrn": "urn:li:fsd_profile:ACoAAAAABL0B...",
      "firstName": "Reid",
      "lastName": "Hoffman",
      "*profileSkills": "urn:li:collectionResponse:...",
      "$type": "com.linkedin.voyager.dash.identity.profile.Profile"
    },
    {
      "entityUrn": "urn:li:fsd_company:1009",
      "name": "Greylock",
      "$type": "com.linkedin.voyager.dash.organization.Company"
    }
  ]
}

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:

GET /profile/sample

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:

{ "meta": { "cached": false, "source": "cached-fixture" } }

GET /health

Liveness check. Does not call LinkedIn.

{ "status": "ok", "uptimeSeconds": 42, "credentialsConfigured": true }

GET /

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>.

GET /api

Service metadata and the endpoint list, as JSON.

{
  "service": "linkedin-profile-api",
  "description": "Returns a LinkedIn profile as structured JSON.",
  "endpoints": {
    "GET /profile?url=<linkedin profile url>": "Fetch and normalize a profile.",
    "GET /profile?url=<…>&full=1": "Also fetch the complete skills/experience lists.",
    "...": "..."
  },
  "documentation": "https://github.com/arun-dev-des/linkedin-profile-api"
}

Errors

All errors share one envelope:

{ "error": { "code": "invalid_request", "message": "human-readable explanation" } }
HTTP code When
400 invalid_request url missing or not a LinkedIn profile URL
404 profile_not_found No such profile, or not visible to the credentialed account
429 rate_limited Per-IP limit exceeded (default 30 req/min)
502 upstream_auth_failed LinkedIn rejected the session — cookies expired or mismatched
502 decoration_stale DECORATION_ID version retired by LinkedIn (see Deployment)
503 upstream_blocked LinkedIn rate-limited or served a bot challenge
503 not_configured Server started without LI_AT / JSESSIONID
504 upstream_timeout LinkedIn did not respond within 15 s

Response schema

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.

{
  "profile": {
    "publicId": "reidhoffman",
    "profileUrn": "urn:li:fsd_profile:ACoAAAAABL0B...",
    "profileUrl": "https://www.linkedin.com/in/reidhoffman/",
    "name": "Reid Hoffman",
    "firstName": "Reid",
    "lastName": "Hoffman",
    "headline": "Co-Founder, LinkedIn, Manas AI & Inflection AI. Founding Team, PayPal...",
    "location": "United States",
    "countryCode": "US",
    "industry": "Venture Capital & Private Equity",
    "about": "My current priority is investing in and building with AI...",
    "pronouns": "HE_HIM",
    "badges": { "premium": true, "influencer": true, "creator": true },
    "images": {
      "profilePicture": "https://media.licdn.com/dms/image/v2/...",
      "backgroundImage": "https://media.licdn.com/dms/image/v2/..."
    },
    "experience": [
      {
        "title": "Partner",
        "company": "Greylock",
        "companyUrl": "https://www.linkedin.com/company/greylock-partners/",
        "companyLogo": "https://media.licdn.com/dms/image/v2/...",
        "employmentType": "Part-time",
        "location": "Seattle, WA",
        "startDate": "2009-11",
        "endDate": null,
        "current": true,
        "description": "..."
      }
    ],
    "education": [
      {
        "school": "Stanford University",
        "schoolUrl": "https://www.linkedin.com/school/stanford-university/",
        "schoolLogo": "https://media.licdn.com/dms/image/v2/...",
        "degree": "B.S.",
        "fieldOfStudy": "Symbolic Systems",
        "startDate": "1985",
        "endDate": "1990",
        "current": false,
        "grade": null,
        "activities": "Marshall Scholar, Dinkelspiel Award, Golden Grant...",
        "description": null
      }
    ],
    "skills": ["Entrepreneurship", "Venture Capital", "Product Management"],
    "certifications": [
      {
        "name": "Introduction to Modern Application Development",
        "authority": "NPTEL",
        "licenseNumber": "NPTEL16CS2316170014",
        "url": null,
        "startDate": "2016-09",
        "endDate": "2016-10",
        "current": false
      }
    ],
    "languages": [],
    "featured": [
      { "title": "Resume", "url": "https://...", "provider": "..." }
    ],
    "volunteerExperience": [
      {
        "role": "Chair, Board of Directors",
        "company": "Opportunity@Work",
        "companyUrl": "https://www.linkedin.com/company/opportunity-work/",
        "companyLogo": "https://media.licdn.com/dms/image/v2/...",
        "cause": "ECONOMIC_EMPOWERMENT",
        "startDate": "2016-06",
        "endDate": null,
        "current": true,
        "description": "..."
      }
    ],
    "honors": [
      {
        "title": "Sigillum Magnum",
        "issuer": "University of Bologna",
        "issuedOn": "2023-09",
        "description": "..."
      }
    ],
    "publications": [
      {
        "name": "Superagency",
        "publisher": "Authors Equity",
        "publishedOn": "2025-01",
        "url": "https://www.superagency.ai/",
        "description": "...",
        "authors": [
          { "name": "Reid Hoffman", "profileUrl": "https://www.linkedin.com/in/reidhoffman/" }
        ]
      }
    ],
    "careerBreaks": [
      {
        "type": "Personal goal pursuit",
        "startDate": "2025-02",
        "endDate": "2025-06",
        "current": false,
        "location": null,
        "description": "Took time out to travel and train..."
      }
    ]
  },
  "meta": {
    "fetchedAt": "2026-08-28T12:00:00.000Z",
    "cached": false,
    "source": "linkedin-voyager",
    "partial": {
      "skills": { "returned": 20, "total": 47 },
      "experience": { "returnedGroups": 10, "totalGroups": 32 },
      "featured": { "returned": 3, "total": 10 }
    }
  }
}

Notes:

  • 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.

Known limitations

  • 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.