Automate LinkedIn Posts with Python: OAuth and Posting [2026 Guide]
Every time you publish a technical article, you open a new browser tab, navigate to LinkedIn, copy your title, rewrite a hook, paste a link, add hashtags β and repeat.
This guide eliminates that loop. I'll show you how to write a Python script that authenticates with LinkedIn, stores tokens, and dispatches posts automatically when articles commit.
π’ Freshly Published! I just posted a new educational guide: "The Ultimate 2026 ML Engineering Roadmap" π Summary: We break down the 5 transition steps, LoRA fine-tuning, and career positioning. π Read the full guide here: https://kishnaxai.in/articles/... #MLEngineering #MachineLearning #Python
This is Part 1 of a 2-part series documenting the automated distribution system running live on this portfolio:
- Part 1 (this guide): App portal setup, OAuth 2.0 configuration, token refresh, and the posting script.
- Part 2: Generating context-aware posts using Gemini AI.
Why Automate LinkedIn Posting?
Consistency is the only thing the LinkedIn algorithm consistently rewards. But writing manual social updates during deep coding focus is a tax. Automating the distribution layer means your audience is notified immediately, your formatting is consistent, and you never forget to share.
The Modern LinkedIn API Endpoint
Forget older tutorials referencing /v2/shares or /v2/ugcPosts. Both are deprecated and return 403 errors for new developer apps in 2026. The active endpoint is the LinkedIn REST Posts API at https://api.linkedin.com/rest/posts, which requires a mandatory version header (e.g., LinkedIn-Version: 202602).
Step 1 β Create Your LinkedIn Developer Application
You need a registered developer app to grant your script permission to post.
http://localhost:8080/callback.w_member_social) and Sign In with LinkedIn using OpenID Connect.β οΈ The Trailing Slash Trap: If your redirect URI in the developer portal ishttp://localhost:8080/callback, but your Python callback server handles redirects athttp://localhost:8080/callback/(with a trailing slash), LinkedIn will fail with aredirect_uri_mismatcherror. The URIs must match character-for-character.
β οΈ Share Product Approval Required: You must request the Share on LinkedIn product. If you skip this, your token will not include thew_member_socialscope, and every posting request will fail with a cryptic403 Unauthorizedresponse.
Step 2 β Configure Your .env File
Never hardcode credentials in your scripts. Create a .env file at the root of your project and add all sensitive values there. Make sure .env is included in your .gitignore so it is never committed to source control.
# .env β Never commit this file. Add it to .gitignore immediately.
# LinkedIn OAuth App credentials (from developer.linkedin.com > Auth tab)
LINKEDIN_CLIENT_ID=your_client_id_here
LINKEDIN_CLIENT_SECRET=your_client_secret_here
# The LinkedIn URN of the member who will own the posts.
# Fetch this after OAuth by calling: GET https://api.linkedin.com/v2/userinfo
# It looks like: urn:li:person:AbCdEf12345
LINKEDIN_AUTHOR_URN=urn:li:person:your_person_urn_here
# Access token β populated after your first OAuth flow (see Step 3)
LINKEDIN_ACCESS_TOKEN=your_access_token_here
# Refresh token β only available if your app has the refresh_token scope
LINKEDIN_REFRESH_TOKEN=your_refresh_token_here
# Your site domain for constructing full article URLs
SITE_DOMAIN=https://kishnaxai.in
Load these values in Python using python-dotenv:
from dotenv import load_dotenv
import os
load_dotenv() # reads .env from the current working directory
client_id = os.environ.get("LINKEDIN_CLIENT_ID")
client_secret = os.environ.get("LINKEDIN_CLIENT_SECRET")
author_urn = os.environ.get("LINKEDIN_AUTHOR_URN")
Step 3 β The One-Time OAuth 2.0 Authorization Flow
LinkedIn uses OAuth 2.0 Authorization Code flow. The first time you run your pipeline, you need to authorize your app to act on behalf of your LinkedIn account. This produces an access token valid for 60 days. The flow has two steps: constructing the authorization URL, and then exchanging the returned code for a token.
β οΈ Important β Local Setup Only: The OAuth flow below runs once on your local machine. Thehttp://localhost:8080/callbackredirect URI is only used during this one-time setup to capture the authorization code in your browser. After you exchange the code for a token and save it tolinkedin_token_store.json, GitHub Actions never runs this flow again. It only reads the stored token from your repository secrets or the JSON file. Do not addlocalhostas a redirect URI in production β your LinkedIn Developer App should registerlocalhost:8080only for local developer testing.
Step 3a: Build the Authorization URL and Visit It
Run this script once on your local machine. Copy the printed URL into your browser, approve the app, and LinkedIn will redirect you to http://localhost:8080/callback?code=.... Copy the code value from that URL.
import os
import urllib.parse
from dotenv import load_dotenv
load_dotenv()
CLIENT_ID = os.environ.get("LINKEDIN_CLIENT_ID")
REDIRECT_URI = "http://localhost:8080/callback"
SCOPES = "openid profile w_member_social"
params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": SCOPES,
"state": "linkedin_oauth_init"
}
auth_url = "https://www.linkedin.com/oauth/v2/authorization?" + urllib.parse.urlencode(params)
print("\n--- Visit this URL in your browser to authorize ---")
print(auth_url)
print("\nAfter approving, copy the 'code' value from the redirect URL.")
Step 3b: Exchange the Code for a Token
Once you have the code from the redirect URL, run the exchange script. It will save the token to a local JSON file named linkedin_token_store.json so future runs reuse the stored token automatically.
import os
import json
import time
import requests
from dotenv import load_dotenv
load_dotenv()
CLIENT_ID = os.environ.get("LINKEDIN_CLIENT_ID")
CLIENT_SECRET = os.environ.get("LINKEDIN_CLIENT_SECRET")
REDIRECT_URI = "http://localhost:8080/callback"
# Paste the code value from the redirect URL query string here
AUTH_CODE = "AQR..." # Replace with your actual code
def exchange_code_for_token(client_id, client_secret, redirect_uri, code):
"""Exchanges a one-time authorization code for a 60-day LinkedIn access token."""
response = requests.post(
"https://www.linkedin.com/oauth/v2/accessToken",
data={
"grant_type": "authorization_code",
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=15
)
if response.status_code != 200:
print(f"Token exchange failed: {response.status_code}")
print(response.text)
return None
data = response.json()
token_store = {
"access_token": data.get("access_token"),
"refresh_token": data.get("refresh_token"),
"expires_at": time.time() + data.get("expires_in", 0)
}
with open("linkedin_token_store.json", "w") as f:
json.dump(token_store, f, indent=2)
print("[OAuth] Token exchange successful!")
print(f"Access token: {token_store['access_token'][:30]}...")
print(f"Expires in: {int(data.get('expires_in', 0) / 86400)} days")
return token_store
if __name__ == "__main__":
token_data = exchange_code_for_token(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, AUTH_CODE)
if token_data:
print("\nTokens saved to linkedin_token_store.json")
print("Copy the access_token value into your .env as LINKEDIN_ACCESS_TOKEN.")
After this runs once, your tokens are stored in linkedin_token_store.json. The posting script reads from this file on every run and checks if the token is still valid using the stored expires_at Unix timestamp. If a refresh token is available, it automatically exchanges it for a new token without any human interaction.
Step 4 β Fetching Your LinkedIn Person URN
You need your unique LinkedIn person identifier (URN) to set the author field in each post. After completing the OAuth flow, run this one-time lookup:
import os
import requests
from dotenv import load_dotenv
load_dotenv()
token = os.environ.get("LINKEDIN_ACCESS_TOKEN")
response = requests.get(
"https://api.linkedin.com/v2/userinfo",
headers={"Authorization": f"Bearer {token}"}
)
data = response.json()
print("Name:", data.get("name"))
print("Email:", data.get("email"))
print("LinkedIn Person URN:", f"urn:li:person:{data.get('sub')}")
Copy the printed urn:li:person:... string into your .env file as the value of LINKEDIN_AUTHOR_URN.
Step 5 β The Complete LinkedIn Poster Class
This is the full production-grade posting class we use in the KishnaXAI pipeline. It handles token loading, automatic refresh when a refresh_token is available, constructs the correct payload for the REST Posts API v2, and returns a structured result with the post URN on success.
π₯ The Bug That Wasted Two Hours: The Parentheses Truncation Gotcha
LinkedIn's commentary parser uses parentheses( )for link markdown (e.g.[text](url)). If your post contains a raw opening parenthesis(in the body text (especially right after a quoted string), the API parser gets confused, assumes a broken markdown link, and silently truncates your post at that exact character position. The post will publish successfully with a 201 status code, but only show the first couple of lines!
The Fix: Replace all parentheses in the commentary text with square brackets[ ]before sending the request to LinkedIn.
import os
import json
import time
import datetime
import requests
from dotenv import load_dotenv
load_dotenv()
# Path where OAuth tokens are persisted between runs
LINKEDIN_TOKEN_STORE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "linkedin_token_store.json")
# LinkedIn REST API requires a monthly version header in YYYYMM format.
# Update this value when the API announces breaking changes in its changelog.
LINKEDIN_API_VERSION = "202602"
class SocialPosterAgent:
def __init__(self):
self.client_id = os.environ.get("LINKEDIN_CLIENT_ID")
self.client_secret = os.environ.get("LINKEDIN_CLIENT_SECRET")
self.author_urn = os.environ.get("LINKEDIN_AUTHOR_URN")
# ββ Token Management ββββββββββββββββββββββββββββββββββββββββββββββββββ #
def _load_tokens(self):
"""Loads stored tokens from disk. Falls back to environment variables on first run."""
if os.path.exists(LINKEDIN_TOKEN_STORE):
try:
with open(LINKEDIN_TOKEN_STORE, "r") as f:
return json.load(f)
except Exception:
pass
return {
"access_token": os.environ.get("LINKEDIN_ACCESS_TOKEN"),
"refresh_token": os.environ.get("LINKEDIN_REFRESH_TOKEN"),
"expires_at": 0,
}
def _save_tokens(self, tokens):
"""Persists updated token data back to disk."""
try:
with open(LINKEDIN_TOKEN_STORE, "w") as f:
json.dump(tokens, f, indent=2)
except Exception as e:
print(f"[Poster] Could not save token store: {e}")
def _get_access_token(self):
"""Returns a valid access token. Refreshes automatically if expired and refresh_token exists."""
tokens = self._load_tokens()
# Token still valid for more than 5 minutes β reuse it.
if tokens.get("access_token") and tokens.get("expires_at", 0) > time.time() + 300:
return tokens["access_token"]
# Try to refresh using the stored refresh_token.
if not tokens.get("refresh_token") or not self.client_id or not self.client_secret:
print("[Poster] No refresh token or credentials. Using current access_token as-is.")
return tokens.get("access_token")
resp = requests.post(
"https://www.linkedin.com/oauth/v2/accessToken",
data={
"grant_type": "refresh_token",
"refresh_token": tokens["refresh_token"],
"client_id": self.client_id,
"client_secret": self.client_secret,
},
timeout=30,
)
if resp.status_code != 200:
print(f"[Poster] Token refresh failed ({resp.status_code}). Using cached token.")
return tokens.get("access_token")
data = resp.json()
tokens["access_token"] = data["access_token"]
tokens["expires_at"] = time.time() + data.get("expires_in", 0)
if data.get("refresh_token"):
tokens["refresh_token"] = data["refresh_token"]
self._save_tokens(tokens)
print("[Poster] Token refreshed successfully.")
return tokens["access_token"]
# ββ Posting βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ #
def post_to_linkedin(self, title, summary, url, commentary=None):
"""
Publishes a post to the LinkedIn REST Posts API.
Args:
title (str): Article title. Used in the article card metadata.
summary (str): Article description. Appears in the card preview.
url (str): Full article URL.
commentary (str): Optional pre-formatted post body text. If not supplied,
a minimal fallback commentary is constructed from title + summary.
Returns:
tuple: (success: bool, detail: str) detail is the post URN on success.
"""
if not self.author_urn:
print("[Poster] LINKEDIN_AUTHOR_URN not set. Cannot post.")
return False, "not_configured"
token = self._get_access_token()
if not token:
return False, "no_token"
# If no pre-built commentary is provided, build a clean minimal fallback.
# IMPORTANT: Replace parentheses with brackets in the title to prevent
# the LinkedIn markdown parser from silently truncating the post body.
if not commentary:
clean_title = title.replace("(", "[").replace(")", "]")
commentary = (
f"π’ Freshly Published! π’\n\n"
f"I just posted a new educational guide: \"{clean_title}\".\n\n"
f"π Summary:\n{summary}\n\n"
f"π Read the full guide here:\nπ {url}\n\n"
f"#AI #MachineLearning #Python #SoftwareEngineering"
)
payload = {
"author": self.author_urn,
"commentary": commentary,
"visibility": "PUBLIC",
"distribution": {
"feedDistribution": "MAIN_FEED",
"targetEntities": [],
"thirdPartyDistributionChannels": [],
},
"content": {
"article": {
"source": url,
"title": title[:200], # LinkedIn enforces a 200-char title cap
"description": summary[:300], # and a 300-char description cap
}
},
"lifecycleState": "PUBLISHED",
"isReshareDisabledByAuthor": False,
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"X-Restli-Protocol-Version": "2.0.0",
"LinkedIn-Version": LINKEDIN_API_VERSION, # Required. Update monthly if needed.
}
try:
resp = requests.post(
"https://api.linkedin.com/rest/posts",
headers=headers,
json=payload,
timeout=30
)
except Exception as e:
print(f"[Poster] Network error: {e}")
return False, str(e)
if resp.status_code in (200, 201):
post_urn = resp.headers.get("x-restli-id", "unknown")
print(f"[Poster] Success! Post URN: {post_urn}")
return True, post_urn
print(f"[Poster] Post failed ({resp.status_code}): {resp.text}")
return False, resp.text
# ββ Entry Point βββββββββββββββββββββββββββββββββββββββββββββββββββββββ #
def run(self, title, summary, url, commentary=None):
"""Runs the full posting workflow and returns a structured result dict."""
results = {"timestamp": datetime.datetime.now().isoformat()}
ok, detail = self.post_to_linkedin(title, summary, url, commentary)
results["linkedin"] = {"ok": ok, "detail": detail}
return results
# ββ Direct Test βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ #
if __name__ == "__main__":
agent = SocialPosterAgent()
result = agent.run(
title="The Ultimate 2026 ML Engineering Roadmap [Step-by-Step]",
summary="The 5 vital steps to transition from software development to machine learning.",
url="https://kishnaxai.in/articles/the-ultimate-2026-ml-engineering-roadmap.html"
)
import json
print(json.dumps(result, indent=2))
Step 6 β Understanding the Parentheses Truncation Bug
During the development of this pipeline in 2026, we identified a non-obvious but critical bug: any LinkedIn post commentary containing parentheses in the text was being silently truncated by the API at the exact character position where the opening parenthesis appeared. The posts published with only one or two lines of content instead of the full formatted body.
The root cause is how LinkedInβs internal commentary parser handles its markdown-like syntax. The platform uses the pattern [visible text](link url) for clickable inline links and @[Name](urn:li:person:...) for member mentions. When a bare opening parenthesis ( appears in the text β especially after a quoted word at the end of a line β the parser enters a markdown link matching state and cannot find the closing bracket, causing the rest of the string to be discarded.
Examples of titles that triggered truncation:
"How Much Python Do You Need to Start AI? (2026 Guide)"β truncated after the?"The Ultimate 2026 ML Engineering Roadmap (Step-by-Step)"β truncated afterRoadmap
The fix is a single line of Python applied before any title is inserted into the commentary string:
clean_title = title.replace("(", "[").replace(")", "]")
# "The Ultimate 2026 ML Engineering Roadmap (Step-by-Step)"
# becomes:
# "The Ultimate 2026 ML Engineering Roadmap [Step-by-Step]"
After this substitution, the LinkedIn API publishes the entire post body correctly. The visual difference to readers on LinkedIn is negligible.
Benchmark: Real Posting Results
After implementing and testing this pipeline against the KishnaXAI production articles, here are the results we measured in 2026:
| Article Title | Post Result | API Response Time | Post URN |
|---|---|---|---|
| Rainfall Trends in India Analysis with Python | β Full post published | ~215ms | urn:li:share:7484... |
| The Ultimate 2026 ML Engineering Roadmap [Step-by-Step] | β Full post published | ~212ms | urn:li:share:7484722... |
| How Much Python Do You Need to Start AI? (2026 Guide) β without fix | β Post truncated at parenthesis | β | β |
Integrating with Your Content Pipeline
The LinkedIn poster is not a standalone script β it is a downstream step in a fully autonomous content pipeline. Understanding where it sits in the chain helps you wire it correctly.
In the KishnaXAI pipeline, everything is triggered by a single GitHub Actions workflow. Here is the complete flow from git push to live LinkedIn post:
workflow_dispatch (or can be scheduled via cron). It runs on ubuntu-latest with contents: write permissions.LINKEDIN_ACCESS_TOKEN, LINKEDIN_AUTHOR_URN, etc.) from GitHub repository secrets into the environment. No localhost, no OAuth flow β just the stored token values.python local_scripts/autonomous_pipeline.py, which detects raw draft and unformatted articles, generates their formatting, and publishes them as HTML files to the repository.autonomous_pipeline.py calls SocialNotifier.notify(), which triggers Gemini to generate the post copy and then calls SocialPosterAgent to dispatch it to LinkedIn and Telegram.You can also trigger the social post independently β without a full article publish run β from the Streamlit admin dashboard. A dropdown lets you select any already-published article, review the Gemini-generated copy in a text area, and click a single button to dispatch it to LinkedIn and Telegram.
Continue to Part 2: Making LinkedIn Posts Context-Aware with Gemini AI to see how we replaced the static commentary template with a fully AI-generated, custom post for every article.