I Shipped a Full LinkedIn Integration in 5 Hours on a Rainy Saturday
It wasn't hard, it was specific.
It was a gray, rainy Saturday in North Central Washington. The kind of afternoon where the mountains disappear behind low clouds and the only sound is water dripping off the metal roof. Perfect building weather.
I’d been running an automated social posting pipeline for Eisenetics — my solo e-commerce site for rocks, minerals, art, collectibles, and consulting — across Bluesky, Facebook, and Instagram for a few weeks. It was working. Posts went out on schedule, captions were generated with the right voice for each platform, images scaled properly (as of today). But LinkedIn was sitting there, untouched.
LinkedIn matters because I’m pivoting. After months of grinding on the site, I’m looking for remote software engineering work. The platform I built is the portfolio. But nobody on LinkedIn knew it existed.
So I opened Claude Code and got to work.
Hour 1: The OAuth Dance
LinkedIn’s OAuth2 flow is straightforward on paper. Redirect to their authorization URL, get a code back, exchange it for a token. I’ve built this pattern for half a dozen platforms at this point.
The first version worked in about 20 minutes. Redirect, callback, token exchange, store the token in Sanity. Clean.
Then I tried to fetch my profile.
Hour 2: The Endpoint Shell Game
LinkedIn has two ways to get profile data: the OIDC /userinfo endpoint and the REST /v2/me endpoint. The OIDC endpoint requires the openid scope. The REST endpoint requires profile. The posting endpoint requires w_member_social. Three different scopes, three different product provisions in your LinkedIn app settings.
I started with OIDC because that’s what their docs suggest for “Sign In with LinkedIn.” It failed. The openid scope wasn’t provisioned on my app. So I stripped it out and tried /v2/me with just w_member_social. That returned a profile, but with limited fields.
Here’s what nobody tells you: each scope requires you to go into the LinkedIn Developer Portal, find the right “product,” and request access. openid lives under “Sign In with LinkedIn using OpenID Connect.” w_member_social lives under “Share on LinkedIn.” They’re separate products with separate approval flows.
I ended up needing all three scopes — openid profile w_member_social — and had to provision two separate products in the developer portal to get them. Once I figured that out, the callback worked perfectly. Profile data, member ID, access token — all stored.
The key insight: make the profile fetch optional. Wrap it in a try-catch. The token is what matters for posting. If the profile fetch fails, you can still post. Don’t let a nice-to-have break the critical path.
try {
const profileRes = await fetch('https://api.linkedin.com/v2/me', {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (profileRes.ok) {
const profile = await profileRes.json();
linkedinId = profile.id;
}
} catch (_) {
// Profile is optional — token is what matters
}
Hour 3: The Three-Step Image Upload
Most social APIs let you pass an image URL or upload a file in one step. LinkedIn requires three.
Initialize: POST to
/rest/images?action=initializeUploadwith your member URN. LinkedIn returns a presigned upload URL and an image URN.Upload: PUT your image bytes to the presigned URL. Here’s the catch — this request does NOT use your Bearer token. It’s a presigned URL. Adding an auth header actually breaks it.
Post: Include the image URN in your post payload.
If any step fails, you need to decide: fail the whole post, or fall back to text-only? I chose text-only with a warning log. A post without an image is better than no post at all.
The member URN construction tripped me up too. It’s urn:li:person:{memberId} — and that memberId comes from the /v2/me response’s id field. Not sub, not userId. Just id. This is why storing it during the OAuth callback matters.
Hour 4: The Versioning Trap
This is the one that almost got me.
LinkedIn versions their API quarterly. The version goes in a header: LinkedIn-Version: 202504. Not in the URL. Not in a query parameter. A header.
I started with 202401 because that’s what their getting-started docs showed. It worked... sort of. Then I updated to 202501. Also worked. But when I finally got posting working and tested it end-to-end, something felt off. The response format was slightly different from the current docs.
Turns out 202504 was the current active version. The older versions still work but return deprecated response shapes and will eventually stop working entirely. LinkedIn doesn’t loudly fail on old versions — they silently give you stale behavior. That’s worse than an error.
I hardcoded the version as a constant at the top of the file:
const LI_VER = '202504';
One line. One place to update when they roll the next version. I’ll probably forget and debug it for 20 minutes in July.
Hour 5: Wiring It Into the Pipeline
The posting function was done. OAuth was done. Now I needed it to actually work with the rest of the system.
My social pipeline has a content generator that creates platform-specific captions using Claude. Each platform has different rules: Bluesky is casual and short. Instagram is visual-first. Facebook is conversational.
LinkedIn needed its own voice. Here’s what I landed on:
Audience: Professionals, entrepreneurs, potential consulting clients
Voice: Solo founder telling the real story — not corporate LinkedIn-speak
Format: 800-1400 characters, no emojis, no hashtags
Hook: Short punchy opener (LinkedIn truncates at “...see more”)
CTA: Always ends with a link back to eisenetics.com
I added LinkedIn to the scheduling pipeline — 2 posts per day at 9 AM and 5 PM Pacific, which are the optimal windows for professional engagement. Product posts, consulting posts, and content promotion all route through it now.
Then I pre-wrote 34 consulting posts for the next two weeks. 17 services, each with a LinkedIn version and an Instagram version. Zero API credits burned. Just markdown files.
The first scheduled post was set to fire Sunday morning at 9 AM: a Tech Discovery Call promotion. I deployed, cleared the build cache, and watched the logs.
What I Learned
LinkedIn’s API is not hard. It’s just... specific. Every platform has its quirks, but LinkedIn’s are concentrated in three areas: scope provisioning (confusing), image uploads (unnecessarily complex), and API versioning (silently dangerous). Once you understand those three things, the rest is standard REST.
Build for graceful degradation. Profile fetch fails? Post anyway. Image upload fails? Post text-only. API version is stale? It’ll still work for a while. Don’t let perfect be the enemy of shipped.
Version headers are a ticking time bomb. Unlike URL-versioned APIs where you can see the version in every request, header-based versioning is invisible. Set a calendar reminder to check LinkedIn’s API changelog quarterly.
The OAuth scope model is a product provisioning problem, not a code problem. I spent more time in the LinkedIn Developer Portal clicking checkboxes than I did writing OAuth code. If your scopes aren’t working, check your app’s product provisions first.
Five hours. One rainy afternoon. LinkedIn went from “not connected” to “34 posts scheduled, auto-posting live, professional voice tuned, images uploading.”
Test post worked:
Sometimes the best thing about a rainy day is that you can’t go outside.
I’m Brian Eisenberg, solo founder of Eisenetics. I build things at the intersection of technology, land, and commerce from a 40-acre ranch in North Central Washington. If you’re wrangling APIs or building a one-person e-commerce operation, I offer consulting sessions where we solve real problems in real time.



