How-to guide

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

ScopeComes fromWhat 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.

oauth.py
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"]
oauth.mjs (Node 18+)
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.

python
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"]
node.js
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.

post_text.py
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 + "/")
post_text.mjs
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.

post_image.py
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]}})
post_image.mjs
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.

escaping
You write:   Shipped v2 (finally) #buildinpublic @opentweet
You send:    Shipped v2 \(finally\) \#buildinpublic \@opentweet
It shows:    Shipped v2 (finally) #buildinpublic @opentweet

The 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.

the two version errors (LinkedIn's documented responses)
// 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.

StatusWhat it meansWhat 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.

opentweet.py
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"])
opentweet.mjs
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);
posts[0].results, trimmed
[
  {
    "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 codeOpenTweet 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.

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.