The LinkedIn API for posting is notoriously restricted. You need to apply for partnership access, and even then, you're limited to organization pages. For individual developer accounts trying to automate their own posts? You're out of luck.
So I built a workaround: using browser cookies and Playwright to automate publishing to LinkedIn as if you're using the actual website.
The Core Problem
LinkedIn's official API won't let you post to personal profiles. But every developer knows that where there's a browser session, there's a way. The challenge is capturing that session in a way that's both secure and reliable.
The Cookie Capture Strategy
Instead of storing passwords or dealing with OAuth flows that don't grant the permissions we need, I'm capturing the active browser session:
interface LinkedInExtensionConnectInput {
userId: string;
cookies: LinkedInCookie[];
userAgent: string;
}
The key cookies we need are li_at (LinkedIn authentication token) and JSESSIONID. These are captured from an active browser session via a Chrome extension, then stored for automation:
const liAtCookie = cookies.find((c) => c.name === 'li_at');
const jSessionCookie = cookies.find((c) => c.name === 'JSESSIONID');
if (!liAtCookie || !jSessionCookie) {
throw new BadRequestException(
'Missing required LinkedIn session cookies.'
);
}
The Publishing Flow
Here's where it gets interesting. With Playwright, we can replay that browser session:
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext({
userAgent: credentials.userAgent
});
// Inject the captured cookies
const playwrightCookies = this.convertCookies(credentials.cookies);
await context.addCookies(playwrightCookies);
Now we have a browser that's "logged in" as the user, without ever touching their password.
The LinkedIn UI Challenge
LinkedIn's UI changes frequently, which makes selector-based automation fragile. The solution? Try multiple selectors until one works:
const startPostSelectors = [
'[placeholder="Start a post"]',
'div[data-placeholder="Start a post"]',
'.share-box-feed-entry__top-bar',
'.share-creation-state__text-field',
'div[role="button"]:has-text("Start a post")',
'button:has-text("Start a post")',
];
let startPostButton = null;
for (const selector of startPostSelectors) {
try {
startPostButton = await page.waitForSelector(selector, {
timeout: 2000
});
if (startPostButton && await startPostButton.isVisible()) {
break;
}
} catch {
continue;
}
}
This defensive approach means when LinkedIn tweaks their HTML, we don't immediately break.
Handling Session Expiry
The li_at cookie has an expiration date. We extract and store it:
const expiresAt = liAtCookie.expires
? new Date(liAtCookie.expires * 1000).toISOString()
: undefined;
Then before publishing, we check:
if (credentials.expiresAt && new Date(credentials.expiresAt) < new Date()) {
return {
success: false,
error: 'LinkedIn session has expired. Please reconnect.'
};
}
This prevents failed publish attempts and tells the user exactly what they need to do.
The Tradeoff
This approach sacrifices the elegance of a proper API integration for something that actually works. It's more brittle—LinkedIn could change their UI tomorrow. It requires a headless browser, which means more resources. And users need to reconnect via the extension when their session expires.
But it works. For developers who want to automate their personal LinkedIn presence, that's what matters.
The alternative is manually posting or not posting at all. Given that choice, a little browser automation doesn't seem so bad.
Top comments (0)