LinkedIn API: publish and pull analytics over the wire

SkillDocs & knowledge

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

Available today. Use it from your connected AI after setup.

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.

  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.
ScopeGrantsProduct neededPage-role requirement
w_member_socialpost/comment/like as the authed memberShare on LinkedInnone
r_member_socialread the member's own postsrestricted — approved apps onlynone
w_organization_socialpost on behalf of an orgCommunity Management / MDPADMINISTRATOR, CONTENT_ADMIN, or DIRECT_SPONSORED_CONTENT_POSTER on the page
r_organization_socialread org posts/statsCommunity Management / MDPsame 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} (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:

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:12345urn%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-patternWhy it breaksDo instead
Pinning Linkedin-Version: 202505That version is sunset; calls start failing on cutoverPin 202605 in one constant; bump deliberately
Sending only Linkedin-Version, dropping X-Restli-Protocol-VersionVersioned endpoints reject the request (426/400)Always send both headers together
Expecting the API to scrape an article URLPosts API never fetches OG tags; you get a bare linkSet source/title/description + an uploaded Image URN
Reading the new post id from the response bodyThe URN is in x-restli-id, body has no idRead the x-restli-id response header
Treating share statistics as total reachThey are organic only, 12-month windowPull sponsored from the ads stats endpoints separately
Un-encoded URN in the URL path/queryThe colons break routing → 400/404URL-encode every URN argument
Committing the token or client_secretLeaked credential, instant revokeRead from env; gitignore token files
Re-prompting consent on every token expiryBreaks unattended jobs, looks brokenStore the refresh token, refresh before ~60-day expiry
Granted scope but member has no page rolePublish still returns 403 ACCESS_DENIEDVerify ADMINISTRATOR/CONTENT_ADMIN/DSC role first
Polling analytics in a tight loop429 TOO_MANY_REQUESTS throttleBack 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