Skip to Content
ProvidersLinkedIn (Organic — Pages & Profiles)

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/linkedinLinkedInService
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 URL

API runs on port 3003.


OAuth Flow

Scopes

rw_organization_admin w_organization_social

Callback URL

{API_BASE_URL}/tenant/v1/channel-connect/linkedin/callback

Steps

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.comhttp://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/json

Exception: getCurrentFollowersCount uses /v2/networkSizes/ (not /rest/) and omits the LinkedIn-Version header — the v2 endpoint rejects CompanyFollowedByMember when 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=1743724800000

LinkedInService 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[]>

  1. GET /rest/organizationAcls?q=roleAssignee&role=ADMINISTRATOR&state=APPROVED — lists org URNs the user administers.
  2. For each URN, fetches GET /rest/organizations/{numericId}?fields=localizedName to resolve the display name.

Returns Array<{ organization: string; organizationInfo: { name: string } }>.

organizationInfo.name contains the resolved name — NOT organizationInfo.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=CompanyFollowedByMember

Uses /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 followerStatistics returns 404 in v202601. Must use organizationalEntityFollowerStatistics.

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/organizationPageStatistics

Returns 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

PurposeEndpointScopes neededNotes
Follower count (current)GET /v2/networkSizes/{urn}?edgeType=CompanyFollowedByMemberrw_organization_adminUse /v2/, no LinkedIn-Version header
Follower gain over timeGET /rest/organizationalEntityFollowerStatisticsrw_organization_adminv202601 name; old: followerStatistics (404)
Post impressions/clicks per dayGET /rest/organizationalEntityShareStatisticsrw_organization_adminUse for “Content Performance”
Page views/clicksGET /rest/organizationPageStatistics+ “Community Management API” productReturns 404 without the LinkedIn product
Create postPOST /rest/postsw_organization_socialPost ID in x-restli-id header
List administered orgsGET /rest/organizationAclsrw_organization_adminUsed 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:

  1. Calls getCurrentFollowersCount for current + previous-period totals
  2. Calls getFollowersGainStatistics (DAY granularity) for per-day new follower counts
  3. 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 @CompanyName format in copy

Test Coverage

Unit tests: packages/providers/linkedin/src/__tests__/linkedin.test.ts

TestWhat it checks
authorize() builds correct URLresponse_type=code, scope, state params present
authorizeCallback() exchanges codePOSTs to /oauth/v2/accessToken; maps access_tokenOAuthInfo
getPages() fetches org namesCalls /organizationAcls then /organizations/{id}; returns organizationInfo.name
createPost() sends correct payloadauthor, commentary, visibility=PUBLIC, lifecycleState=PUBLISHED
createPost() extracts ID from headerMocks x-restli-id header
LinkedIn versioning headers sentAll methods include LinkedIn-Version: 202601 and X-Restli-Protocol-Version: 2.0.0

© 2026 Leadmetrics — Internal use only