LinkedIn (Organic — Pages & Profiles)
Category: Social Publishing
Integration type: Tenant OAuth — stored in ConnectedChannel table (tokenInfo encrypted with PROVIDER_CONFIG_ENCRYPTION_KEY)
Package: packages/providers/linkedin → LinkedInService
External API: LinkedIn REST API (https://api.linkedin.com/rest) — version 202601
Purpose
LinkedIn integration enables organic content publishing to a tenant’s LinkedIn Company Page. The Social Post Worker pushes posts via the LinkedIn Posts API. The platform also reads Page Analytics — impressions, clicks, follower growth — for the channel analytics screen and Report Writer.
Required env vars (API server)
LINKEDIN_APP_ID=<client id from LinkedIn developer portal>
LINKEDIN_APP_SECRET=<client secret>
PROVIDER_CONFIG_ENCRYPTION_KEY=<64-char hex — shared key for all provider token encryption>
API_BASE_URL=http://localhost:3003 # used to build OAuth callback URLAPI runs on port 3003.
OAuth Flow
Scopes
rw_organization_admin w_organization_socialCallback URL
{API_BASE_URL}/tenant/v1/channel-connect/linkedin/callbackSteps
1. GET /tenant/v1/channel-connect/linkedin/connect?id={channelId}
→ returns { url: "https://www.linkedin.com/oauth/v2/authorization?..." }
2. Dashboard opens the URL in a popup window (620×720).
3. User logs in to LinkedIn and authorises.
4. LinkedIn redirects to callback:
GET /tenant/v1/channel-connect/linkedin/callback?code=...&state={channelId}
→ exchanges code for token, saves tokenInfo (isConnected=false), redirects to page/select.
5. GET /tenant/v1/channel-connect/linkedin/page/select?state={channelId}
→ API fetches org list from LinkedIn, renders HTML page-selection UI in the popup.
6. User selects an organisation page and submits the form:
POST /tenant/v1/channel-connect/linkedin/page/select
body: state={channelId}&selectedId=urn:li:organization:XXXXX
→ saves subChannelInfo { id: URN, title: orgName }, sets isConnected=true, redirects to /channel/close.
7. Popup window closes; dashboard poll detects popup.closed, checks
GET /tenant/v1/channels/{channelId} → isConnected=true → onConnected().CORS note
The OAuth redirect chain goes https://www.linkedin.com → http://localhost:3003 (cross-scheme). Browsers send Origin: null on this redirect. The CORS middleware in apps/api/src/lib/fastify-setup.ts explicitly allows origin === "null" for this reason.
API Headers (all requests)
LinkedIn-Version: 202601
X-Restli-Protocol-Version: 2.0.0
Authorization: Bearer {access_token}
Content-Type: application/jsonException:
getCurrentFollowersCountuses/v2/networkSizes/(not/rest/) and omits theLinkedIn-Versionheader — the v2 endpoint rejectsCompanyFollowedByMemberwhen that header is present.
Complex param format for query strings (v202601)
LinkedIn REST API v202601 uses Restli 2.0 complex-param notation for nested query parameters. Do NOT use dot-notation.
Correct:
timeIntervals=(timeGranularityType:DAY,timeRange:(start:1743724800000,end:1744329599999))Wrong (returns QUERY_PARAM_NOT_ALLOWED):
timeIntervals.timeGranularityType=DAY&timeIntervals.timeRange.start=1743724800000LinkedInService methods
authorize(callbackUrl, state): string
Builds the OAuth authorization URL.
authorizeCallback(code, callbackUrl): Promise<OAuthInfo>
Exchanges the authorization code for an access token via POST /oauth/v2/accessToken.
getPages(token): Promise<LinkedInPage[]>
GET /rest/organizationAcls?q=roleAssignee&role=ADMINISTRATOR&state=APPROVED— lists org URNs the user administers.- For each URN, fetches
GET /rest/organizations/{numericId}?fields=localizedNameto resolve the display name.
Returns Array<{ organization: string; organizationInfo: { name: string } }>.
organizationInfo.namecontains the resolved name — NOTorganizationInfo.localizedName.
createPost(organizationId, post, token): Promise<{ id: string }>
POST /rest/posts — creates a post on the organization page. Post ID is extracted from the x-restli-id response header.
getCurrentFollowersCount(organizationUrn, token): Promise<number>
GET /v2/networkSizes/{encodedUrn}?edgeType=CompanyFollowedByMemberUses /v2/ (not /rest/) without the LinkedIn-Version header. The REST v202601 endpoint rejects CompanyFollowedByMember with a 400.
getFollowersGainStatistics(organizationUrn, startDate, endDate, granularity, token)
GET /rest/organizationalEntityFollowerStatistics
?q=organizationalEntity
&organizationalEntity={encodedUrn}
&timeIntervals=(timeGranularityType:DAY,timeRange:(start:X,end:Y))Note: old endpoint name
followerStatisticsreturns 404 in v202601. Must useorganizationalEntityFollowerStatistics.
getShareStatistics(organizationUrn, startDate, endDate, granularity, token)
GET /rest/organizationalEntityShareStatistics
?q=organizationalEntity
&organizationalEntity={encodedUrn}
&timeIntervals=(timeGranularityType:DAY,timeRange:(start:X,end:Y))Returns daily impressions, clicks, likes, comments, shares, and engagement for posts published by the organization. Works with the standard rw_organization_admin w_organization_social scopes.
This is the correct data source for the “Content Performance” chart — use this instead of organizationPageStatistics.
getPageStatistics(organizationUrn, startDate, endDate, granularity, token)
GET /rest/organizationPageStatisticsReturns page-level view counts. Requires “Community Management API” or “Marketing Developer Platform” LinkedIn app product — returns 404 without it. The analytics route catches this gracefully and falls back to empty data.
Endpoint reference table
| Purpose | Endpoint | Scopes needed | Notes |
|---|---|---|---|
| Follower count (current) | GET /v2/networkSizes/{urn}?edgeType=CompanyFollowedByMember | rw_organization_admin | Use /v2/, no LinkedIn-Version header |
| Follower gain over time | GET /rest/organizationalEntityFollowerStatistics | rw_organization_admin | v202601 name; old: followerStatistics (404) |
| Post impressions/clicks per day | GET /rest/organizationalEntityShareStatistics | rw_organization_admin | Use for “Content Performance” |
| Page views/clicks | GET /rest/organizationPageStatistics | + “Community Management API” product | Returns 404 without the LinkedIn product |
| Create post | POST /rest/posts | w_organization_social | Post ID in x-restli-id header |
| List administered orgs | GET /rest/organizationAcls | rw_organization_admin | Used in OAuth page-select step |
Data stored in ConnectedChannel
// tokenInfo (encrypted JSON, stored as AES-256-GCM: iv:authTag:ciphertext hex)
{
accessToken: string;
refreshToken?: string;
expireOn?: string; // ISO date — LinkedIn tokens expire in 60 days
scope?: string;
}
// subChannelInfo (plain JSON)
{
id: "urn:li:organization:91426063", // full LinkedIn URN
title: "Leadmetrics", // human-readable org name
}Analytics screen (LinkedInChannelDetail.tsx)
Located at apps/dashboard/src/app/(dashboard)/channels/[id]/LinkedInChannelDetail.tsx.
Calls GET /tenant/v1/channels/{channelId}/analytics?from=YYYY-MM-DD&to=YYYY-MM-DD.
The API handler in apps/api/src/routers/channels.ts:
- Calls
getCurrentFollowersCountfor current + previous-period totals - Calls
getFollowersGainStatistics(DAY granularity) for per-day new follower counts - Calls
getShareStatistics(DAY granularity) for per-day impressions + clicks
Returns:
{
"period": { "from": "2026-04-04", "to": "2026-04-10" },
"stats": {
"followers": { "current": 7117, "newInPeriod": 17, "previousPeriod": 7100, "deltaPercent": 0.24 },
"impressions": { "current": 3000, "previous": 2464, "deltaPercent": 21.88 },
"clicks": { "current": 267, "previous": 131, "deltaPercent": 103.82 }
},
"followerGrowth": [{ "date": "2026-04-04", "followers": 7100, "newFollowers": 3 }, ...],
"contentPerformance": [{ "date": "2026-04-04", "impressions": 895, "clicks": 45 }, ...]
}Theme: All colors use Tailwind theme tokens (bg-background, bg-card, text-foreground, text-muted-foreground, etc.) — no hardcoded hex values.
Reconnect flow
If a channel becomes inactive (token expired, permission revoked), the user can reconnect via the ⋯ menu on the Disconnected tab without deleting the channel record. The reconnect flow is identical to the initial connect flow — it reuses the existing channelId as the OAuth state parameter.
LinkedIn Content Best Practices (Agent Context)
- Optimal post length: 1,300–2,000 characters for thought leadership
- Use line breaks liberally (LinkedIn compresses walls of text)
- 3–5 hashtags max
- Tag company pages with
@CompanyNameformat in copy
Test Coverage
Unit tests: packages/providers/linkedin/src/__tests__/linkedin.test.ts
| Test | What it checks |
|---|---|
authorize() builds correct URL | response_type=code, scope, state params present |
authorizeCallback() exchanges code | POSTs to /oauth/v2/accessToken; maps access_token → OAuthInfo |
getPages() fetches org names | Calls /organizationAcls then /organizations/{id}; returns organizationInfo.name |
createPost() sends correct payload | author, commentary, visibility=PUBLIC, lifecycleState=PUBLISHED |
createPost() extracts ID from header | Mocks x-restli-id header |
| LinkedIn versioning headers sent | All methods include LinkedIn-Version: 202601 and X-Restli-Protocol-Version: 2.0.0 |
Related
- LinkedIn Ads Provider — paid LinkedIn campaigns
- Social Post Pipeline — end-to-end post flow
- Channel Connect Router — OAuth routes