---
name: linkedin-api
description: "Use when wiring an app to LinkedIn's organic Posts / Community Management API — completing 3-legged OAuth, publishing text/article/document posts to a member profile or company page, or pulling impressions/engagement/follower stats and logging them as a durable feedback record. Triggers: 'connect our app to the LinkedIn Posts API', 'publish to our company page programmatically', 'pull last month's impressions for our org posts', 'my /v2/ugcPosts call returns 403', 'unauthorized scope w_organization_social at consent', 'log post performance into the wiki', 'publica a la pàgina de LinkedIn per API', 'conectar la API de LinkedIn y traer las impresiones'. NOT writing the post copy (that is linkedin-content), planning cadence (that is linkedin-strategy), or DM sequences (that is linkedin-outreach)."
tags: [linkedin, social-api, oauth, community-management-api, analytics, connector]
recommends: [api-connector-builder, webhooks, analytics, knowledge-ops]
origin: risco
---

# LinkedIn API: publish and pull analytics over the wire

You own the **transport**. Auth headers, scopes, URNs, request shapes, rate-limit handling, token refresh, and the write that turns post performance into a durable feedback log. You do **not** decide what to post or why.

**Decision line:** if the question is *which HTTP call, which header, which scope, which URN, how do I store the numbers* → you. If it is about the *meaning* of the content → a sibling.

- Words, hooks, CTA for one post → `linkedin-content` (planned sibling).
- Cadence, pillars, audience plan → `linkedin-strategy` (planned sibling).
- The multi-slide PDF artifact → `linkedin-carousels` (planned sibling) builds it; you only upload the resulting document URN.
- DMs, connection requests, sequences → `linkedin-outreach` (planned sibling).
- Generic OAuth2 / webhook scaffolding with no LinkedIn specifics → `../api-connector-builder/SKILL.md`, `../webhooks/SKILL.md`.

## Prereqs: app + products + scopes

You configure this on the LinkedIn Developer portal once, before any code runs.

1. Create a developer app, associate it with a company page (required to request org products).
2. On the **Products** tab, request the product that grants the scopes you need:
   - **Share on LinkedIn** → grants `w_member_social` (default-grantable, post as the member).
   - **Community Management API** / **Marketing Developer Platform (MDP)** → grants the organization scopes and member-read. These require approval — the app sits in a review queue.
3. Add your exact **redirect URI** under Auth. It must match the one in the authorize URL byte-for-byte or the callback fails.

| Scope | Grants | Product needed | Page-role requirement |
|---|---|---|---|
| `w_member_social` | post/comment/like as the authed member | Share on LinkedIn | none |
| `r_member_social` | read the member's own posts | restricted — approved apps only | none |
| `w_organization_social` | post on behalf of an org | Community Management / MDP | ADMINISTRATOR, CONTENT_ADMIN, or DIRECT_SPONSORED_CONTENT_POSTER on the page |
| `r_organization_social` | read org posts/stats | Community Management / MDP | same role set |

> "unauthorized scope `w_organization_social`" at the consent screen does not mean a typo — it means the app's Community Management / MDP product is **not yet approved**. The fix is product approval, not code.

## Step 1 — 3-legged OAuth

Authorization-code flow. Two endpoints:

```text
GET  https://www.linkedin.com/oauth/v2/authorization   # send the user here
POST https://www.linkedin.com/oauth/v2/accessToken     # exchange code -> token
```

Send the user to authorize with `response_type=code`, your `client_id`, `redirect_uri`, a CSRF `state`, and space-separated `scope`. On callback, exchange the `code`:

```bash
curl -X POST https://www.linkedin.com/oauth/v2/accessToken \
  -d grant_type=authorization_code \
  -d code="$AUTH_CODE" \
  -d redirect_uri="$LINKEDIN_REDIRECT_URI" \
  -d client_id="$LINKEDIN_CLIENT_ID" \
  -d client_secret="$LINKEDIN_CLIENT_SECRET"
```

Access tokens are short-lived (~60 days). Marketing / Community apps can be issued **refresh tokens** — store the refresh token and exchange it (`grant_type=refresh_token`) before expiry. Why: re-prompting consent on every expiry breaks unattended publishing and looks broken to the user.

```text
Bad:  client_secret = "WPL_AP1.abc..."            # committed in source
Good: client_secret = process.env.LINKEDIN_CLIENT_SECRET   # injected at runtime, file gitignored
```

Never write the token or secret into a tracked file. `scripts/verify.sh` greps for committed `client_secret=` and `AQED`-style token literals and fails the build if it finds one.

## Step 2 — resolve the author URN

Every post needs an `author`. Two shapes:

- Member: `urn:li:person:{id}` (the `sub` from the OpenID userinfo / token introspection).
- Organization: `urn:li:organization:{id}` (the numeric page id).

Before publishing as an org, confirm the authed member holds an eligible **page role** (ADMINISTRATOR / CONTENT_ADMIN / DIRECT_SPONSORED_CONTENT_POSTER). Why: with the scope granted but no role, the publish still returns **403 ACCESS_DENIED** — the scope authorizes the app, the role authorizes the person.

## Step 3 — publish

Current organic endpoint: `POST https://api.linkedin.com/rest/posts`. The legacy `/v2/ugcPosts` and `/v2/shares` are deprecated — migrate to the versioned `/rest/posts`. Pin the version **once**:

```bash
LINKEDIN_VERSION=202605   # latest; 202505 is sunset — pinning a sunset version breaks
```

Every versioned call carries **two** headers (omit either and you get 426/400):

```text
Linkedin-Version: 202605
X-Restli-Protocol-Version: 2.0.0
```

### Text post

```bash
curl -X POST https://api.linkedin.com/rest/posts \
  -H "Authorization: Bearer $LINKEDIN_TOKEN" \
  -H "Linkedin-Version: $LINKEDIN_VERSION" \
  -H "X-Restli-Protocol-Version: 2.0.0" \
  -H "Content-Type: application/json" \
  -d '{
    "author": "urn:li:organization:12345",
    "commentary": "Shipping notes for this week.",
    "visibility": "PUBLIC",
    "distribution": {
      "feedDistribution": "MAIN_FEED",
      "targetEntities": [],
      "thirdPartyDistributionChannels": []
    },
    "lifecycleState": "PUBLISHED",
    "isReshareDisabledByAuthor": false
  }'
```

Success is **201**, and the new post URN comes back in the **`x-restli-id` response header** (`urn:li:share:{id}` or `urn:li:ugcPost:{id}`) — not the body. Capture that header; you need the URN to pull analytics later.

### Article (link share) post

The Posts API does **NOT scrape the URL**. You set the preview fields yourself, and the `thumbnail` must be an **Image URN** you uploaded via the Images API first:

```jsonc
"content": {
  "article": {
    "source": "https://example.com/post",
    "title": "Our launch writeup",
    "description": "What we shipped and why",
    "thumbnail": "urn:li:image:C5..."
  }
}
```

### Document post

Upload the file via the **Documents API** to get `urn:li:document:{id}`, then reference it:

```jsonc
"content": { "media": { "id": "urn:li:document:C4..." } }
```

(Images → Images API → `urn:li:image:`; videos → Videos API → `urn:li:video:`.) Full reshare / multi-image / poll payloads and the retrieval finders live in `references/api-reference.md`.

## Step 4 — pull analytics

### Share statistics (post-level engagement)

```text
GET https://api.linkedin.com/rest/organizationalEntityShareStatistics
    ?q=organizationalEntity
    &organizationalEntity=urn%3Ali%3Aorganization%3A12345
```

**Organic only** (sponsored excluded), **rolling 12-month window**. Omit `timeIntervals` for lifetime; include it for a time-bound slice. Returns `impressionCount`, `uniqueImpressionsCount`, `clickCount`, `likeCount`, `commentCount`, `shareCount`, and `engagement` (engagement rate).

### Follower statistics

```text
GET https://api.linkedin.com/rest/organizationalEntityFollowerStatistics
```

Lifetime or time-bound follower counts, segmented by facet (geo, industry, seniority, function, staff size), covering **paid + organic** followers.

> URNs in any URL path or query **must be URL-encoded**: `urn:li:organization:12345` → `urn%3Ali%3Aorganization%3A12345`. An un-encoded colon silently breaks the route.

## Step 5 — ingest performance into the wiki

This is the deliverable that makes the skill durable: after pulling stats, write one file per post under `02-DOCS/wiki/linkedin/`. Later content decisions get grounded in what actually worked, not vibes.

Filename: the post id slug, e.g. `02-DOCS/wiki/linkedin/share-7012345678901234567.md`.

```markdown
---
post_urn: "urn:li:share:7012345678901234567"
author: "urn:li:organization:12345"
captured_at: "2026-06-02T10:00:00Z"
impressions: 4821
unique_impressions: 4102
clicks: 213
likes: 96
comments: 14
shares: 8
engagement: 0.0683
---

Carousel on OAuth pitfalls. Highest unique-impression post this month;
CTR ~4.4%. The "common 403 causes" hook outperformed the plain howto.
```

Keep the front-matter keys exactly as above — `verify.sh` checks that `post_urn`, `captured_at`, `impressions`, and `engagement` are present in every file under that directory.

## Anti-patterns

| Anti-pattern | Why it breaks | Do instead |
|---|---|---|
| Pinning `Linkedin-Version: 202505` | That version is sunset; calls start failing on cutover | Pin `202605` in one constant; bump deliberately |
| Sending only `Linkedin-Version`, dropping `X-Restli-Protocol-Version` | Versioned endpoints reject the request (426/400) | Always send both headers together |
| Expecting the API to scrape an article URL | Posts API never fetches OG tags; you get a bare link | Set `source`/`title`/`description` + an uploaded Image URN |
| Reading the new post id from the response body | The URN is in `x-restli-id`, body has no id | Read the `x-restli-id` response header |
| Treating share statistics as total reach | They are **organic only**, 12-month window | Pull sponsored from the ads stats endpoints separately |
| Un-encoded URN in the URL path/query | The colons break routing → 400/404 | URL-encode every URN argument |
| Committing the token or `client_secret` | Leaked credential, instant revoke | Read from env; gitignore token files |
| Re-prompting consent on every token expiry | Breaks unattended jobs, looks broken | Store the refresh token, refresh before ~60-day expiry |
| Granted scope but member has no page role | Publish still returns 403 ACCESS_DENIED | Verify ADMINISTRATOR/CONTENT_ADMIN/DSC role first |
| Polling analytics in a tight loop | `429 TOO_MANY_REQUESTS` throttle | Back off and batch; schedule, don't hammer |

## Verify

Run `scripts/verify.sh [TARGET_DIR]` (default current dir). It is read-only and static — no network. It confirms each `02-DOCS/wiki/linkedin/*.md` carries the required front-matter keys, and fails on any committed token / client secret in the tree. A clean or not-yet-run target passes with a NOTE.

## References

`references/api-reference.md` — full curl bodies (text, article, document, reshare, multi-image, poll), post retrieval finders, the complete analytics query params and response schemas for both statistics endpoints, the HTTP error-code catalog (400/401/403/404/409/422/429/500/503), URL-encoding and query-tunneling rules, and the version-deprecation note.
