LinkedIn API: publish and pull analytics over the wire
SkillDocs & knowledgeUse 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`).
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the LinkedIn API: publish and pull analytics over the wire skill
What this skill tells your AI
The instructions your AI receives, as published by ericrisco/rsc-harness in skills/linkedin-api/SKILL.md and read by ahel’s review.
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.
- Create a developer app, associate it with a company page (required to request org products).
- 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.
- Share on LinkedIn → grants
- 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:
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:
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.
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}(thesubfrom 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:
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):
Linkedin-Version: 202605
X-Restli-Protocol-Version: 2.0.0
Text post
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:
"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:
"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)
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
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.
---
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.
Signals
- GitHub stars
- 82
- Forks
- 3
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
linkedin-api- Source
- github.com/ericrisco/rsc-harness