How to post to LinkedIn with the APIin Python (and Node.js)
The self-serve path on the official API, end to end: the app, the OAuth code flow, the member URN, a text post, an image post, escaping, the version header, and the 60-day token. Every endpoint, header and body below matches the LinkedIn integration OpenTweet publishes with.
Last updated: September 2026
7-day free trial. Cancel anytime.
What this covers
Posting to your own personal profile with the w_member_social scope, which needs no partner review. Posting as a Company Page needs the Community Management API, which requires LinkedIn review, and is not covered here.What you need first
Create a LinkedIn developer app tied to a Page
Create the app in the LinkedIn Developer Portal. LinkedIn requires every app to be associated with a LinkedIn Page, so you need one even though your posts will go to your personal profile, not the Page.
Add the two self-serve products
Share on LinkedIn, which grants w_member_social, and Sign In with LinkedIn using OpenID Connect, which grants openid and profile. Neither needs partner review.
Register the redirect URL
The redirect_uri your code sends has to match one registered on the app, character for character, including the trailing slash or its absence.
Copy the client ID and client secret
The secret is only used server-side, in the token exchange. Keep it out of browser code and out of your repository.
| Scope | Comes from | What it gives you |
|---|---|---|
| openid | Sign In with LinkedIn using OpenID Connect | The sign-in itself |
| profile | Sign In with LinkedIn using OpenID Connect | GET /v2/userinfo, which returns the member id as sub |
| w_member_social | Share on LinkedIn | Creating posts and uploading images as that member |
Request all three together: openid profile w_member_social.
1. The OAuth authorization code flow
Send the member to LinkedIn, let them approve, and trade the returned code for an access token on your server. The token exchange is a form-encoded POST, not JSON.
import os
import secrets
from urllib.parse import urlencode
import requests
CLIENT_ID = os.environ["LINKEDIN_CLIENT_ID"]
CLIENT_SECRET = os.environ["LINKEDIN_CLIENT_SECRET"]
REDIRECT_URI = "https://yourapp.example/linkedin/callback"
# 1. Send the member here. Keep state and compare it on the way back.
state = secrets.token_urlsafe(24)
authorize_url = "https://www.linkedin.com/oauth/v2/authorization?" + urlencode({
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"state": state,
"scope": "openid profile w_member_social",
})
# 2. LinkedIn redirects to REDIRECT_URI?code=...&state=...
def exchange_code(code):
r = requests.post(
"https://www.linkedin.com/oauth/v2/accessToken",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
},
timeout=15,
)
r.raise_for_status()
token = r.json()
return token["access_token"], token["expires_in"]import { randomUUID } from 'node:crypto';
const CLIENT_ID = process.env.LINKEDIN_CLIENT_ID;
const CLIENT_SECRET = process.env.LINKEDIN_CLIENT_SECRET;
const REDIRECT_URI = 'https://yourapp.example/linkedin/callback';
const state = randomUUID();
const authorizeUrl = new URL('https://www.linkedin.com/oauth/v2/authorization');
authorizeUrl.search = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
state,
scope: 'openid profile w_member_social',
}).toString();
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,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
const data = await res.json();
if (!res.ok || !data.access_token) {
throw new Error('Token exchange failed: ' + (data.error_description || res.status));
}
return { accessToken: data.access_token, expiresIn: data.expires_in };
}expires_in is the token lifetime in seconds. Store the expiry next to the token, because there is no refresh token to fall back on. More on that in step 7.
2. Get the member URN
Every post names an author. For a personal profile that is urn:li:person: followed by the sub field from the OpenID userinfo endpoint.
def member_urn(access_token):
r = requests.get(
"https://api.linkedin.com/v2/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
)
r.raise_for_status()
return "urn:li:person:" + r.json()["sub"]async function memberUrn(accessToken) {
const res = await fetch('https://api.linkedin.com/v2/userinfo', {
headers: { Authorization: 'Bearer ' + accessToken },
});
if (!res.ok) throw new Error('userinfo failed: HTTP ' + res.status);
const { sub } = await res.json();
return 'urn:li:person:' + sub;
}3. Publish a text post
One POST to https://api.linkedin.com/rest/posts with two headers besides the token: LinkedIn-Version: 202608 and X-Restli-Protocol-Version: 2.0.0. The new post URN, such as urn:li:share:7300000000000000000, comes back in the x-restli-id response header.
import re
API = "https://api.linkedin.com"
LINKEDIN_VERSION = "202608"
def li_headers(access_token):
return {
"Authorization": f"Bearer {access_token}",
"LinkedIn-Version": LINKEDIN_VERSION,
"X-Restli-Protocol-Version": "2.0.0",
"Content-Type": "application/json",
}
RESERVED = re.compile(r"[\\|{}@\[\]()<>#*_~]")
def escape_little_text(text):
return RESERVED.sub(lambda m: "\\" + m.group(0), text)
def create_post(access_token, author_urn, text, content=None):
body = {
"author": author_urn,
"commentary": escape_little_text(text),
"visibility": "PUBLIC",
"distribution": {
"feedDistribution": "MAIN_FEED",
"targetEntities": [],
"thirdPartyDistributionChannels": [],
},
"lifecycleState": "PUBLISHED",
"isReshareDisabledByAuthor": False,
}
if content:
body["content"] = content
r = requests.post(API + "/rest/posts", headers=li_headers(access_token), json=body, timeout=30)
r.raise_for_status()
return r.headers.get("x-restli-id")
urn = create_post(token, author, "We moved deploys to Tuesday mornings (finally).")
print("https://www.linkedin.com/feed/update/" + urn + "/")const API = 'https://api.linkedin.com';
const LINKEDIN_VERSION = '202608';
function liHeaders(accessToken) {
return {
Authorization: 'Bearer ' + accessToken,
'LinkedIn-Version': LINKEDIN_VERSION,
'X-Restli-Protocol-Version': '2.0.0',
'Content-Type': 'application/json',
};
}
const escapeLittleText = (text) => text.replace(/[\\|{}@[\]()<>#*_~]/g, (c) => '\\' + c);
async function createPost(accessToken, authorUrn, text, content) {
const body = {
author: authorUrn,
commentary: escapeLittleText(text),
visibility: 'PUBLIC',
distribution: { feedDistribution: 'MAIN_FEED', targetEntities: [], thirdPartyDistributionChannels: [] },
lifecycleState: 'PUBLISHED',
isReshareDisabledByAuthor: false,
};
if (content) body.content = content;
const res = await fetch(API + '/rest/posts', {
method: 'POST',
headers: liHeaders(accessToken),
body: JSON.stringify(body),
});
if (!res.ok) throw new Error('LinkedIn ' + res.status + ': ' + (await res.text()));
return res.headers.get('x-restli-id');
}Never retry a post that returned 2xx
Once/rest/posts succeeds the post is live. If reading x-restli-id fails after that, log it and move on. Retrying publishes the same post a second time.4. Publish an image post
Three calls: initializeUpload with the author as owner, a PUT of the bytes to the returned uploadUrl, then the post with the returned image URN. One image goes in content.media, two or more in content.multiImage.
def upload_image(access_token, owner_urn, path, mime):
init = requests.post(
API + "/rest/images?action=initializeUpload",
headers=li_headers(access_token),
json={"initializeUploadRequest": {"owner": owner_urn}},
timeout=30,
)
init.raise_for_status()
value = init.json()["value"]
with open(path, "rb") as f:
put = requests.put(
value["uploadUrl"],
headers={"Authorization": f"Bearer {access_token}", "Content-Type": mime},
data=f.read(),
timeout=60,
)
put.raise_for_status()
return value["image"]
# One image
image = upload_image(token, author, "chart.png", "image/png")
create_post(token, author, "Q3 in one chart.", content={"media": {"id": image}})
# Two or more images
images = [upload_image(token, author, p, "image/png") for p in ["before.png", "after.png"]]
create_post(token, author, "Before and after.",
content={"multiImage": {"images": [{"id": i} for i in images]}})import { readFile } from 'node:fs/promises';
async function uploadImage(accessToken, ownerUrn, path, mime) {
const init = await fetch(API + '/rest/images?action=initializeUpload', {
method: 'POST',
headers: liHeaders(accessToken),
body: JSON.stringify({ initializeUploadRequest: { owner: ownerUrn } }),
});
if (!init.ok) throw new Error('initializeUpload ' + init.status + ': ' + (await init.text()));
const { value } = await init.json();
const put = await fetch(value.uploadUrl, {
method: 'PUT',
headers: { Authorization: 'Bearer ' + accessToken, 'Content-Type': mime },
body: await readFile(path),
});
if (!put.ok) throw new Error('Image upload ' + put.status);
return value.image;
}
const image = await uploadImage(token, author, 'chart.png', 'image/png');
await createPost(token, author, 'Q3 in one chart.', { media: { id: image } });
const images = [
await uploadImage(token, author, 'before.png', 'image/png'),
await uploadImage(token, author, 'after.png', 'image/png'),
];
await createPost(token, author, 'Before and after.', {
multiImage: { images: images.map((id) => ({ id })) },
});JPEG, PNG and GIF upload as they are. Convert anything else, such as WebP or HEIC, before the PUT. If the post call fails after the uploads, nothing is public: the uploaded images are unattached and nobody sees them.
5. Escape the commentary
LinkedIn reads commentary as little text format, where these characters are markup: \ | { } @ [ ] ( ) < > # * _ ~. Left bare, a parenthesis or an underscore can swallow the rest of the post.
You write: Shipped v2 (finally) #buildinpublic @opentweet
You send: Shipped v2 \(finally\) \#buildinpublic \@opentweet
It shows: Shipped v2 (finally) #buildinpublic @opentweetThe escape_little_text function in step 3 puts a backslash in front of every reserved character, so the post reads exactly as written. The trade-off is that #hashtags and @mentions show as plain text rather than links. URLs in the text still work as links.
6. The version header, and what 426 means
Every call under /rest/ must carry LinkedIn-Version in YYYYMM form. There is no default, LinkedIn does not apply the latest version for you. The OAuth endpoints and /v2/userinfo work without it.
// No LinkedIn-Version header
{
"status": 400,
"code": "VERSION_MISSING",
"message": "A version must be present. Please specify a version by adding the Linkedin-Version header."
}
// A version that has been sunset
{
"status": 426,
"code": "NONEXISTENT_VERSION",
"message": "Requested version yyyymmdd is not active"
}LinkedIn releases a version every month and supports each one for at least a year. After that, calls pinned to it fail with 426. Version 202508, for example, was sunset on August 17, 2026. Keep the version in one constant, as LINKEDIN_VERSION is above, and bump it once a year.
7. The 60-day token, and which errors to retry
Self-serve apps get a 60-day access token and no refresh token. When it runs out, the only fix is the member signing in again, so plan the reconnect before the date instead of finding out from a failed post.
| Status | What it means | What to do |
|---|---|---|
| 401 | The token expired or was revoked | Send the member through sign-in again. Retrying cannot fix it. |
| 426 | LinkedIn-Version names a sunset version | Bump the header to a current YYYYMM value |
| 429 | Rate limit | Wait a few minutes, then retry |
| 5xx | LinkedIn is not answering | Retry later |
| 403 and other 4xx | LinkedIn refused this request | Read the message and fix the app or the body. The same call will fail again. |
Scheduled posts need the most care: a post queued for day 61 on a day-1 token will fail. Check the stored expiry before you queue anything.
Or do it in one call
OpenTweet holds the LinkedIn connection, so your code sends one POST to https://opentweet.io/api/v1/posts with "platforms": ["linkedin"]. Add "x" and "bluesky" to the same list to post there too.
import requests
r = requests.post(
"https://opentweet.io/api/v1/posts",
headers={"Authorization": "Bearer ot_your_key"},
json={
"text": "We moved deploys to Tuesday mornings (finally).",
"platforms": ["linkedin"],
"scheduled_date": "2026-10-01T09:00:00Z",
},
timeout=30,
)
print(r.json())
r = requests.post(
"https://opentweet.io/api/v1/posts",
headers={"Authorization": "Bearer ot_your_key"},
json={
"text": "Release 2.4 is out. Notes in the changelog.",
"platforms": ["x", "linkedin"],
"publish_now": True,
},
timeout=60,
)
print(r.json()["posts"][0]["results"])const res = await fetch('https://opentweet.io/api/v1/posts', {
method: 'POST',
headers: { Authorization: 'Bearer ot_your_key', 'Content-Type': 'application/json' },
body: JSON.stringify({
text: 'Release 2.4 is out. Notes in the changelog.',
platforms: ['x', 'bluesky', 'linkedin'],
publish_now: true,
}),
});
const { posts } = await res.json();
console.log(posts[0].results);[
{
"platform": "x",
"status": "published",
"post_id": "1967000000000000000",
"url": "https://x.com/you/status/1967000000000000000"
},
{
"platform": "linkedin",
"status": "published",
"post_id": "urn:li:share:7300000000000000000",
"url": "https://www.linkedin.com/feed/update/urn:li:share:7300000000000000000/"
}
]scheduled_date queues the post and OpenTweet publishes it at that time, so nothing of yours has to stay running. With more than one LinkedIn account connected, pass linkedin_account_id to choose which one. From an AI agent, the same thing is opentweet_create_tweet with a platforms list on the hosted MCP server at https://mcp.opentweet.io/mcp.
Your own code against the OpenTweet API
Both publish through the same official LinkedIn API. The difference is who maintains the rest.
| Your own code | OpenTweet API | |
|---|---|---|
| Developer app tied to a Page | Yours to create and keep | Not needed |
| OAuth code and token storage | Yours to write and secure | Sign in with LinkedIn once in Settings |
| Member URN, image upload, escaping | Three more calls and a regex | Handled |
| LinkedIn-Version bumps | Yours, before each sunset | Handled |
| Scheduling | Your own queue and a process that stays up | scheduled_date on the same call |
| X and Bluesky | Two more integrations | Add them to platforms |
| Every 60 days | The member signs in again | One-click reconnect, and OpenTweet emails you |
OpenTweet on LinkedIn does
- Posts to your personal profile, up to 3,000 characters
- Text and images
- Schedules, or publishes now
- Turns a thread into one post
- X and Bluesky from the same call
It does not
- Post to Company Pages
- Post video, yet
- Make hashtags clickable. They publish as plain text
- Read LinkedIn analytics, comments or DMs
- Send connection requests
From $11.99 a month, with LinkedIn on every plan. Read the cross-posting docs.
Frequently asked questions
Can I post to LinkedIn with the API without partner approval?
Yes, to your own personal profile. A self-serve app with the Share on LinkedIn and Sign In with LinkedIn using OpenID Connect products gets the w_member_social scope, which is enough to publish text and image posts through /rest/posts. The app has to be tied to a LinkedIn Page. Posting as a Company Page needs the Community Management API, which requires LinkedIn review.
Should I use /v2/ugcPosts or /rest/posts?
Use /rest/posts for new code. The older /v2/ugcPosts endpoint still exists, but /rest/posts is the versioned Posts API. OpenTweet publishes through /rest/posts and /rest/images from a self-serve app at LinkedIn-Version 202608, including multi-image posts.
Why does LinkedIn return 426 NONEXISTENT_VERSION?
The LinkedIn-Version header names a version that has been sunset. LinkedIn releases a version every month and supports each for at least a year, then calls that use it fail with 426. Change the header to a current YYYYMM value. A missing header fails differently, with 400 VERSION_MISSING.
How do I post an image to LinkedIn with the API?
Three calls. POST /rest/images?action=initializeUpload with the author URN as owner, PUT the image bytes to the uploadUrl it returns, then create the post with content: { media: { id } } using the image URN it returned. For two or more images use content: { multiImage: { images: [{ id }, ...] } }.
How long does a LinkedIn access token last?
Self-serve apps get a 60-day access token and no refresh token. When it runs out, the member has to go through the sign-in again. Store the expiry from expires_in and prompt a reconnect before it passes, and treat a 401 from LinkedIn as a reconnect, not something to retry.
Why are my hashtags not clickable?
Because the commentary is escaped. LinkedIn reads commentary as "little text format", where characters like #, @, parentheses and underscores are markup. Escaping every reserved character keeps the text exactly as written, and the cost is that hashtags and @mentions arrive as plain text.
How do I get the member URN to post as?
Call GET https://api.linkedin.com/v2/userinfo with the access token. The sub field is the member id, and the author URN is urn:li:person: followed by that id. It needs the openid and profile scopes, which is why they sit next to w_member_social.
Can I skip the OAuth code and the developer app?
Yes, if you post through a service that holds the LinkedIn connection. With OpenTweet you sign in with LinkedIn once in Settings, and your code sends one POST to https://opentweet.io/api/v1/posts with "platforms": ["linkedin"], which can also include X and Bluesky. Plans start at $11.99 a month with LinkedIn on every plan.
Related
Partner approval, the 60-day token, the docs, and LinkedIn from an AI agent.
Can I post to LinkedIn without partner approval?
What the self-serve products cover, and what still needs LinkedIn review.
Why the LinkedIn token expires in 60 days
No refresh token on self-serve apps, and what that means for anything scheduled.
Cross-posting docs
The platforms parameter, the results array, and per-network limits on the OpenTweet API.
LinkedIn scheduler
Scheduling LinkedIn profile posts from one queue with X and Bluesky.
LinkedIn MCP server
The same posting from an AI agent, on the official API with OAuth.
LinkedIn MCP servers compared
Official API against browser session, and which ones can publish and schedule.
Is there an official LinkedIn MCP server?
What LinkedIn publishes for agents, and what the third-party servers do instead.
Is scheduling LinkedIn posts safe?
Where the official API ends and account risk begins.
Post to LinkedIn, X and Bluesky in one call
Sign in with LinkedIn once, then send "platforms": ["linkedin"] from Python, Node or an agent. From $11.99 a month.