mcpbeat Sign in

Linkedin API Agent Skill

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

6k tokens
context cost
the whole folder, loaded on every use
5
files
ships runnable scripts
0
copies elsewhere
how many repositories repackaged it
105
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/ericrisco/rsc-harness --skill linkedin-api

What comes with it

13 427 bytes besides the instruction
evals/README.md
evals/cases.yaml
references/api-reference.md
scripts/verify.sh

The instruction itself

15 sections, as written by the author

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.

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

How to use it

Copy the folder

Take ericrisco/linkedin-api from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.