Uncategorized

Publishing to LinkedIn Without Their API: A Browser Automation Story

November 27, 20254 min read

I just spent the better part of a day making a bot pretend to be me on LinkedIn. Not because I’m lazy (okay, partly), but because LinkedIn’s official API is… let’s just say it has opinions about what you can and can’t do.

The goal was simple: let users publish posts to LinkedIn from our app. The reality? LinkedIn’s API requires company verification, explicit approval, and even then, you can only post as company pages, not personal profiles. For a product where developers want to share their commits as personal updates, that’s a non-starter.

So I did what any reasonable developer would do: I automated a browser to click buttons like a human would.

The Cookie Heist

The approach is almost embarrassingly straightforward. I built a browser extension that steals your LinkedIn session cookies when you click “connect.” Not in a sketchy way—you explicitly authorize it—but yeah, we’re basically lifting your active session and replaying it later.

typescript
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. Please ensure you are logged into LinkedIn.',
  );
}

The li_at cookie is LinkedIn’s session token. Get that, and you can make authenticated requests. The extension captures it along with your browser’s user agent, stores it encrypted in the database, and we’re off to the races.

Playwright vs. LinkedIn’s UI

Here’s where it gets fun. LinkedIn changes their UI constantly. Like, I’m talking selector classes that look like someone smashed their keyboard: .share-box-feed-entry__closed-share-box. And they change them.

I ended up writing a selector gauntlet—try ten different ways to find the “Start a post” button:

typescript
const startPostSelectors = [
  '[placeholder="Start a post"]',
  'div[data-placeholder="Start a post"]',
  '.share-box-feed-entry__top-bar',
  '.share-box-feed-entry__closed-share-box',
  '.share-creation-state__text-field',
  'div[role="button"]:has-text("Start a post")',
  // ... 4 more
];
let startPostButton = null;
for (const selector of startPostSelectors) {
  try {
    startPostButton = await page.waitForSelector(selector, { timeout: 2000 });
    if (startPostButton && await startPostButton.isVisible()) {
      break;
    }
  } catch {
    continue;
  }
}

It’s ugly. It’s fragile. It works.

The Part That Broke

Typing the post content took three attempts to get right. First try: just page.fill() the textarea. Didn’t work—LinkedIn needs to see actual keypress events or it won’t enable the Post button.

Second try: page.type() with a delay between characters. Better, but the UI would sometimes lose focus and the text would end up… nowhere. Just gone.

Third try (the one that worked): force focus, clear any existing text, wait for the editor to be ready, then type with delays AND explicitly trigger input events:

typescript
await textArea.evaluate((el: any) => {
  if (el.innerText) el.innerText = '';
  if (el.textContent) el.textContent = '';
});
await page.waitForTimeout(500);
await textArea.focus();
await page.waitForTimeout(300);
await textArea.type(request.content, { delay: 50 });
await page.waitForTimeout(500);

Those waitForTimeout calls? Pure cargo-culting at this point. I don’t know exactly why 500ms works better than 300ms, but it does, and I’ve made peace with that.

The Paranoia Layer

LinkedIn’s bot detection is decent. They look for:

  • Unrealistic typing speeds (hence the 50ms delay per character)
  • Missing user agents
  • Cookie/IP mismatches
  • Suspicious timing patterns

I’m not trying to bypass security here—users are authenticating legitimately through the extension. But the automation still needs to look human enough that LinkedIn doesn’t freak out and invalidate the session.

The browser runs in non-headless mode during development so I can watch it work. In production, I’m honestly not sure if headless will survive long-term. Might need to run headed browsers in a VNC environment. Future me’s problem.

What I’m Not Sure About

Session expiry is tracked by the li_at cookie’s expiration timestamp, but I don’t actually know if LinkedIn invalidates sessions earlier than that. The code checks it:

typescript
if (credentials.expiresAt && new Date(credentials.expiresAt) < new Date()) {
  return {
    success: false,
    error: 'LinkedIn session has expired. Please reconnect via the browser extension.',
  };
}

But I suspect LinkedIn might kill sessions based on IP changes, long inactivity, or who knows what else. We'll find out when users start reporting failures.

Why This Feels Wrong But Isn't

There's a voice in my head saying "this is going to break constantly" and "LinkedIn will hate this." Both probably true.

But here's the thing: users own their accounts. They're authenticating. They're choosing what to post. We're just automating the clicking because LinkedIn won't let us use an API for personal posts.

Is it fragile? Absolutely. Will I be updating those selectors every few months? Probably. But it ships the feature users actually need, and sometimes that's what matters.

The full diff is 893 lines of "please don't break" energy, but it works. I watched it click through the LinkedIn UI, type out a post, and hit publish. Felt like watching a Rube Goldberg machine succeed.

Now I wait for the first bug report.

Share this post
Aniefon Umanah

Aniefon Umanah

Full Stack Engineer with 7+ years of experience. I write about software development, architecture patterns, and lessons learned from building real-world applications.