Use when wiring an app to LinkedIn's organic Posts / Community Management API — 3-legged OAuth, publishing text, article or document posts to a member profile or company page, and pulling impressions, engagement and follower stats into a durable feedback record. NOT writing the post copy (that is `linkedin-content`), NOT planning cadence (that is `linkedin-strategy`), NOT DM sequences (that is `linkedin-outreach`).
Scanned 9/2/2026
Install to Claude Code
npx -y skills add ericrisco/rsc-harness --skill linkedin-api --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Linkedin Api?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ericrisco-linkedin-api)More formats (shields.io, HTML) on the badges page.
---
name: linkedin-api
description: "Use when wiring an app to LinkedIn's organic Posts / Community Management API — 3-legged OAuth, publishing text, article or document posts to a member profile or company page, and pulling impressions, engagement and follower stats into a durable feedback record. NOT writing the post copy (that is `linkedin-content`), NOT planning cadence (that is `linkedin-strategy`), NOT 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
---
type: linkedin-record
title: "Share 7012345678901234567 — OAuth pitfalls carousel"
description: Performance snapshot for one LinkedIn post, pulled from organizationalEntityShareStatistics.
tags: [linkedin, analytics, post-performance]
timestamp: "2026-06-02T10:00:00Z"
topic: linkedin
status: stable
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
---
# Share 7012345678901234567 — OAuth pitfalls carousel
Carousel on OAuth pitfalls. Highest unique-impression post this month;
CTR ~4.4%. The "common 403 causes" hook outperformed the plain howto.
```
`type` is the only required OKF v0.1 field; `title`/`description`/`tags`/`timestamp` are the recommended OKF surface. The DOMAIN keys below them are mandatory and must stay byte-for-byte — `verify.sh` checks that `post_urn`, `captured_at`, `impressions`, and `engagement` are present in every file under that directory. `captured_at` is the API-capture instant (a domain value verify.sh parses); `timestamp` is the OKF last-edit field — keep both. Note these files share `02-DOCS/wiki/linkedin/` with `linkedin-strategy`; using `type: linkedin-record` keeps the two skills' wiki content consistent.
## 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.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!