Post to LinkedIn via API: Posts API Guide
You publish to LinkedIn with POST https://api.linkedin.com/rest/posts, authorized by an OAuth token with the w_member_social scope (posting as a member) or w_organization_social (posting as a company page). Every call also needs a Linkedin-Version header in YYYYMM format and X-Restli-Protocol-Version: 2.0.0. The older /v2/ugcPosts endpoint that most tutorials show is the legacy path.
Last reviewed: October 18, 2026. Checked against LinkedIn's Posts API documentation on that date.
If you are here because you hit LinkedIn's platform-side posting caps rather than its developer API, our LinkedIn scheduling limits guide covers that separately. This post is about building against the API directly.
Longer answer: if you learned LinkedIn's API a few years ago, or you copied a Stack Overflow snippet from 2022, you are building against an endpoint LinkedIn has been quietly walking away from. The old /v2/ugcPosts call still works for some integrations, but it has been flagged legacy since 2023 and none of LinkedIn's new documentation leads with it anymore. The current path is the Posts API, and it comes with a couple of gotchas that will cost you an afternoon if nobody warns you first: a version header LinkedIn silently requires on every call, and a link-preview behavior that quietly disappeared.
What changed: ugcPosts vs the new Posts API
/v2/ugcPosts was LinkedIn's general-purpose "create a piece of user-generated content" endpoint. It worked for text, images, video and articles, but the request shape was awkward (nested specificContent blocks keyed by content type) and LinkedIn stopped extending it.
POST /rest/posts is the replacement. It is flatter, it is the endpoint LinkedIn's current docs point you to for text, image, video, article, document and poll posts, and it is where new features land first. If you are starting a fresh integration in 2026, build against /rest/posts. If you inherited an integration built on ugcPosts, budget time to migrate it, since legacy endpoints on LinkedIn tend to lose support attention well before they get a hard shutoff date.
For comparison, this is the legacy ugcPosts call for the same text post. It shows the nested specificContent shape that the Posts API flattened:
curl -X POST https://api.linkedin.com/v2/ugcPosts \
-H "Authorization: Bearer {access_token}" \
-H "X-Restli-Protocol-Version: 2.0.0" \
-H "Content-Type: application/json" \
-d '{
"author": "urn:li:person:{person_id}",
"lifecycleState": "PUBLISHED",
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": { "text": "Shipped a new feature today." },
"shareMediaCategory": "NONE"
}
},
"visibility": { "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC" }
}'
Note there is no Linkedin-Version header on this one. That is part of why it is easy to miss that it is the old path.
Getting access (Share on LinkedIn product, review tiers)
Before you can call anything, you need a LinkedIn developer app with the Share on LinkedIn product added. This is the product that grants the w_member_social scope for posting on a member's behalf.
For member posts, add the Share on LinkedIn product and request w_member_social. For organization posts, LinkedIn's Posts API documentation requires w_organization_social and the authenticated member must have an allowed company-page role. Confirm the products and access available to your app in LinkedIn's developer portal before you build; the requirements differ by posting context.
Auth: scopes and tokens
For posting as an individual member, request the w_member_social scope during the OAuth authorization step. Once a member has authorized your app, you exchange the authorization code for an access token the normal OAuth 2.0 way:
POST https://www.linkedin.com/oauth/v2/accessToken
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code={authorization_code}
&client_id={your_client_id}
&client_secret={your_client_secret}
&redirect_uri={your_redirect_uri}
The resulting access token is tied to that member's urn:li:person:{id}, which you get from the member's profile lookup and which you will need in every post you publish for them.
For posting to an organization page, the scope is w_organization_social instead, and the authorizing user must be an admin of that page. More on that below.
Your first post: POST /rest/posts walkthrough
Here is a minimal text post from a member account:
curl -X POST https://api.linkedin.com/rest/posts \
-H "Authorization: Bearer {access_token}" \
-H "Linkedin-Version: 202609" \
-H "X-Restli-Protocol-Version: 2.0.0" \
-H "Content-Type: application/json" \
-d '{
"author": "urn:li:person:{person_id}",
"commentary": "Shipped a new feature today. Here is what changed and why.",
"visibility": "PUBLIC",
"distribution": {
"feedDistribution": "MAIN_FEED",
"targetEntities": [],
"thirdPartyDistributionChannels": []
},
"lifecycleState": "PUBLISHED",
"isReshareDisabledByAuthor": false
}'
A successful call returns a 201 Created with an x-restli-id response header holding the new post's URN. Save that URN if you plan to fetch analytics or delete the post later.
Working sample: OAuth consent to a published post
This is a complete, dependency-free Node.js 22 script that covers the whole member-post path: build the consent URL, exchange the code for a token, look up the member's ID, and publish. The scopes are w_member_social (to post) plus openid and profile (so the /v2/userinfo call can return the member ID that goes into the urn:li:person: author). Your app needs the Share on LinkedIn product for the first, and Sign In with LinkedIn using OpenID Connect for the other two.
Syntax-checked with node --check, then run end to end against a local mock of LinkedIn's three endpoints that asserted the form body, the headers and the JSON payload. It has not been run against LinkedIn's live service, because that needs your app credentials, so expect to adjust the version pin and redirect URI for your own app.
Set the three environment variables from your app's Auth tab, run node post-to-linkedin.mjs url, open the printed URL, approve, and copy the code parameter from the redirect.
// post-to-linkedin.mjs (Node 22+, no dependencies)
// node post-to-linkedin.mjs url print the consent URL
// node post-to-linkedin.mjs post <code> "Post text" exchange the code and publish
import { randomBytes } from "node:crypto";
const { LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET, LINKEDIN_REDIRECT_URI } = process.env;
const LINKEDIN_VERSION = "202609"; // YYYYMM; pin a version LinkedIn still supports
const SCOPES = ["openid", "profile", "w_member_social"];
function consentUrl() {
const url = new URL("https://www.linkedin.com/oauth/v2/authorization");
url.search = new URLSearchParams({
response_type: "code",
client_id: LINKEDIN_CLIENT_ID,
redirect_uri: LINKEDIN_REDIRECT_URI,
state: randomBytes(16).toString("hex"), // store it and compare on the redirect
scope: SCOPES.join(" "),
});
return url.toString();
}
async function json(res) {
const body = await res.text();
if (!res.ok) throw new Error(`LinkedIn returned HTTP ${res.status}: ${body}`);
return body ? JSON.parse(body) : {};
}
async function exchangeCode(code) {
const res = await fetch("https://www.linkedin.com/oauth/v2/accessToken", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: LINKEDIN_CLIENT_ID,
client_secret: LINKEDIN_CLIENT_SECRET,
redirect_uri: LINKEDIN_REDIRECT_URI,
}),
});
return (await json(res)).access_token;
}
async function memberUrn(token) {
// Needs the openid and profile scopes; "sub" is the member id.
const res = await fetch("https://api.linkedin.com/v2/userinfo", {
headers: { authorization: `Bearer ${token}` },
});
return `urn:li:person:${(await json(res)).sub}`;
}
async function createPost(token, author, text) {
const res = await fetch("https://api.linkedin.com/rest/posts", {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"linkedin-version": LINKEDIN_VERSION,
"x-restli-protocol-version": "2.0.0",
"content-type": "application/json",
},
body: JSON.stringify({
author,
commentary: text,
visibility: "PUBLIC",
distribution: {
feedDistribution: "MAIN_FEED",
targetEntities: [],
thirdPartyDistributionChannels: [],
},
lifecycleState: "PUBLISHED",
isReshareDisabledByAuthor: false,
}),
});
await json(res); // throws on any non-2xx
return res.headers.get("x-restli-id"); // the new post's URN (HTTP 201)
}
const [command, code, text] = process.argv.slice(2);
if (command === "url") {
console.log(consentUrl());
} else if (command === "post" && code && text) {
const token = await exchangeCode(code);
const postUrn = await createPost(token, await memberUrn(token), text);
console.log(`Published: ${postUrn}`);
} else {
console.error('Usage: node post-to-linkedin.mjs url | post <code> "text"');
process.exitCode = 1;
}
Then publish:
node post-to-linkedin.mjs post "{code_from_redirect}" "Shipped a new feature today."
A successful run prints the new post's URN from the x-restli-id header. Authorization codes are short-lived and single-use, so run both steps back to back. For a company page, request w_organization_social in SCOPES and pass urn:li:organization:{org_id} as the author instead of the member URN.
Schedule your first post free with TimeToPost and put these strategies to work.
Posting as an organization
Posting on behalf of a company page is nearly the same call, with two differences. The author field points at the organization's URN instead of a person's, and the authorizing token needs w_organization_social plus a qualifying role on that page (LinkedIn lists ADMINISTRATOR, DIRECT_SPONSORED_CONTENT_POSTER and CONTENT_ADMIN):
curl -X POST https://api.linkedin.com/rest/posts \
-H "Authorization: Bearer {access_token}" \
-H "Linkedin-Version: 202609" \
-H "X-Restli-Protocol-Version: 2.0.0" \
-H "Content-Type: application/json" \
-d '{
"author": "urn:li:organization:{org_id}",
"commentary": "We just published our Q3 roadmap update.",
"visibility": "PUBLIC",
"distribution": {
"feedDistribution": "MAIN_FEED",
"targetEntities": [],
"thirdPartyDistributionChannels": []
},
"lifecycleState": "PUBLISHED",
"isReshareDisabledByAuthor": false
}'
If the token belongs to someone who is not a designated admin on that organization page, LinkedIn returns an authorization error, not a helpful one. Confirm page-admin status in the LinkedIn UI before you spend an hour debugging your headers.
The version header trap
This is the one that catches almost everyone the first time. Every call to /rest/posts, and to most of LinkedIn's REST endpoints under the newer versioning scheme, requires a Linkedin-Version header in YYYYMM format, for example 202609 for September 2026. Miss it, and the call fails or silently falls back to different default behavior depending on the endpoint, which is worse than a clean error because it can look like it half-worked.
Two practical rules: pin the version header to a specific month rather than always chasing the newest one, so a LinkedIn-side change does not break your integration without warning, and re-test against a newer version deliberately on your own schedule rather than LinkedIn's. Pinned versions do not last forever: LinkedIn's docs currently warn that Marketing version 202511 will be sunset on November 16, 2026, so plan to move your pin forward roughly once a year. Treat it the same way you would treat a Stripe API version pin.
If you are building a multi-platform poster and comparing how much version and header discipline each platform demands, our X (Twitter) API guide covers the same tradeoffs on that side.
Why your link previews disappeared
If you migrated from ugcPosts and your posts used to show a rich link card automatically, and now they show up as bare text with a raw URL, you have hit the most common Posts API surprise. The new API does not auto-scrape a shared URL and generate a preview card for you the way the old flow sometimes appeared to. You have to build the article card yourself, explicitly, in the request body:
curl -X POST https://api.linkedin.com/rest/posts \
-H "Authorization: Bearer {access_token}" \
-H "Linkedin-Version: 202609" \
-H "X-Restli-Protocol-Version: 2.0.0" \
-H "Content-Type: application/json" \
-d '{
"author": "urn:li:person:{person_id}",
"commentary": "New post on the blog: what changed in the LinkedIn API this year.",
"visibility": "PUBLIC",
"distribution": {
"feedDistribution": "MAIN_FEED",
"targetEntities": [],
"thirdPartyDistributionChannels": []
},
"content": {
"article": {
"source": "https://example.com/blog/post-slug",
"title": "What Changed in the LinkedIn API This Year",
"description": "A walkthrough of the Posts API migration."
}
},
"lifecycleState": "PUBLISHED",
"isReshareDisabledByAuthor": false
}'
Add the content.article block and the card renders. Leave it out and post a bare URL in commentary, and you get plain text with a link in it. This is not a bug you can work around with formatting. It is the API asking you to be explicit about what used to happen implicitly.
Rate limits and token lifetimes
Token lifetime and refresh availability depend on the LinkedIn product and OAuth flow; follow LinkedIn's OAuth documentation and store credentials according to the access granted to your app. LinkedIn's Posts API documentation documents rate-limit errors but does not publish an exhaustive per-member daily posting quota, so handle 429 Too Many Requests, honor retry guidance, and avoid designing around an undocumented daily ceiling.
Scheduling on top of the API
LinkedIn's Posts API has no native scheduledAt field. There is no way to hand LinkedIn a post and a future timestamp and have their servers hold it. Scheduling is something you build on your own side: store the draft, hold it in a queue, and fire the POST /rest/posts call yourself at the right moment with a scheduler process, cron job, or a platform that handles it for you. TimeToPost publishes to X. If you want to time those posts against when your own audience actually engages rather than a generic best-practice slot, our best time to post on LinkedIn breakdown is the place to start, and the character counter will keep your commentary field inside LinkedIn's 3,000-character cap before you ever hit send.
When the API is not worth it
Calling /rest/posts is the easy part. The real cost is everything around it: registering and getting a LinkedIn app approved for the products you need, an OAuth flow with token storage, a version pin you have to keep moving, your own queue for scheduling, and handling 429 and 403 responses. Posting as a company page adds a role requirement on top.
If you publish a handful of posts a week from one account, that is more machinery than the job deserves. LinkedIn's own composer can schedule a post natively, and that is the right tool for a single profile or page. Build against the API directly when LinkedIn posting is a feature of a product you are shipping, or when you need to publish from your own system on your own triggers.
If what you want is the calendar rather than the plumbing, TimeToPost has a LinkedIn connector that handles the OAuth, token storage and version pin for you, so scheduled posts live next to your other networks. Availability depends on your workspace, so check Settings โ Integrations to see whether LinkedIn is connectable before relying on it.
FAQ
Do I need LinkedIn's Marketing Developer Platform to just post updates?
No. Posting through Share on LinkedIn only requires the Share on LinkedIn product and w_member_social or w_organization_social, not the full partner marketing program.
Can I still use ugcPosts if my integration already works?
It has been flagged legacy since 2023 and still functions in some integrations today, but LinkedIn's current documentation and new features are built around /rest/posts, so migrating sooner is safer than waiting for a deprecation notice.
What happens if I forget the Linkedin-Version header? Calls typically fail outright or behave inconsistently depending on the endpoint. Always send it, and pin it to a specific month you control.
Why does my link show as plain text instead of a card?
Because the Posts API does not auto-generate link previews. You have to include an explicit content.article block with a source, title and description in the request body.
Can one access token post to both my personal profile and my company page?
No. Personal posts need a token scoped with w_member_social and the person's URN as author. Organization posts need w_organization_social, admin rights on the page, and the organization's URN as author. They are separate authorization contexts even if the same human approved both.
Is there a sandbox to test posts without going live? LinkedIn does not offer a dedicated sandbox for the Posts API. Most developers test against a personal or team-owned page set to a restricted visibility level before pointing the integration at production accounts.