Automate LinkedIn Posts with Python: OAuth and Posting [2026 Guide]

Automate LinkedIn Posts with Python

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.

πŸš€ Live Demo of the Automated Output:
πŸ“’ 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.

1
Go to developer.linkedin.com and log in.
2
Click Create App. Enter a name (e.g., Portfolio Poster), associate your company page, and upload a logo.
3
Open the Auth tab to copy your Client ID and Client Secret.
4
In OAuth 2.0 settings, add your redirect URI: http://localhost:8080/callback.
5
Open the Products tab and request access to Share on LinkedIn (unlocks w_member_social) and Sign In with LinkedIn using OpenID Connect.
⚠️ The Trailing Slash Trap: If your redirect URI in the developer portal is http://localhost:8080/callback, but your Python callback server handles redirects at http://localhost:8080/callback/ (with a trailing slash), LinkedIn will fail with a redirect_uri_mismatch error. 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 the w_member_social scope, and every posting request will fail with a cryptic 403 Unauthorized response.

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. The http://localhost:8080/callback redirect 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 to linkedin_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 add localhost as a redirect URI in production β€” your LinkedIn Developer App should register localhost:8080 only 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 after Roadmap

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:

1
Trigger: The workflow fires manually via workflow_dispatch (or can be scheduled via cron). It runs on ubuntu-latest with contents: write permissions.
2
Secrets Injection: The workflow injects all LinkedIn credentials (LINKEDIN_ACCESS_TOKEN, LINKEDIN_AUTHOR_URN, etc.) from GitHub repository secrets into the environment. No localhost, no OAuth flow β€” just the stored token values.
3
Article Publisher: The workflow runs 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.
4
Social Notification: Once an article is published, 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.
5
IndexNow Ping: After a 90-second delay for Vercel deployment, the workflow pings Bing’s IndexNow API with the new article URL so it gets indexed immediately.

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.