Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

LinkedIn (linkedin)

LinkedIn publishes as the authenticated member (urn:li:person:<sub>) through the LinkedIn Posts API. Publishing is split into a pending phase (upload media, wait until LinkedIn reports every upload AVAILABLE) and a final create that runs through an arm → confirm → publish handshake, because the Posts API has no idempotency key: an unknown-outcome create must never be retried blindly.

The organization flavour of the same code is linkedin-page. Both providers share one OAuth app, one redirect-URI shape, and the same seven scopes.

  • Rust source: crates/rustiz-providers/src/linkedin.rs
  • Upstream port: linkedin.provider.ts
  • Editor: Normal (plain text; LinkedIn little-text escaping is applied)
  • Connection mode: oauth

Read the gotchas first. The provider requests r_basicprofile and the organization scopes, which only the Community Management API (or the Advertising API, the product named by the Postiz docs) grant. If you only add the self-serve products, the connection fails with not_enough_scopes or LinkedIn rejects the authorization with invalid scope. See Implementation Notes & Gotchas.

Overview

PropertyValue
Identifierlinkedin
Display nameLinkedIn
Connection modeoauth (authorization code, no PKCE)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; replies are posted as comments on the parent post and may only contain text
Pending / async publishingYes. supports_pending = true; post_pending → check_post_status → finalize_post
AnalyticsNo for the member provider (supports_analytics = false; analytics() returns Unsupported). Page analytics live in linkedin-page
Post analyticsNo (supports_post_analytics = false)
MentionsYes, company pages only. supports_mentions = true, mention_format emits @[Name](urn:li:organization:<id>)
is_between_stepsfalse
convert_to_jpegfalse (media is re-encoded inside prepare_media_buffer, not by the engine)
max_concurrent_job8
Character limit (max_length)3000 characters
APILinkedIn REST Posts API, LinkedIn-Version: 202601 (comments use 202306)
OAuth scopesopenid, profile, w_member_social, r_basicprofile, rw_organization_admin, w_organization_social, r_organization_social
Token lifetime60-day access token; programmatic refresh token only if LinkedIn enabled it for the app

Server Environment Variables

required_env() returns exactly two variables:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["LINKEDIN_CLIENT_ID", "LINKEDIN_CLIENT_SECRET"]
}
}

required_env_alternatives() is not overridden. provider_status refuses to generate a login URL when either value is empty.

VariableRequiredDefaultPurpose
LINKEDIN_CLIENT_IDYesnoneLinkedIn app Client ID (Auth tab).
LINKEDIN_CLIENT_SECRETYesnoneLinkedIn app Client Secret (Auth tab).
FRONTEND_URLYes, in practicehttp://localhost:5000Builds the OAuth redirect URI. Trailing slashes are stripped by Config::frontend_url().
LINKEDIN_BASE_URLNohttps://api.linkedin.comOverrides the REST API origin. Trailing slash stripped.
LINKEDIN_OAUTH_BASE_URLNohttps://www.linkedin.comOverrides the authorize/token origin. Paths appended are /oauth/v2/authorization and /oauth/v2/accessToken.

There is no LINKEDIN_PAGE_* variable set: the page provider reads the same two credentials.

Check the deployment with:

rustiz providers --status | jq '.[] | select(.identifier=="linkedin")'

Step-by-Step Developer App Setup

LinkedIn has no “sandbox app” switch. You create one app and request products; the Marketing API products then start in a Development access tier.

  1. Create the app. Open https://www.linkedin.com/developers/apps/new and sign in with the LinkedIn account that administers the company page.

    • App name, LinkedIn Page (required: associate a page you administer), App logo, Legal agreement.
    • A verified page/company is required before LinkedIn will grant the organization products.
  2. Verify the app if the Products tab asks for it (business email verification and page association).

  3. Add the products under Products:

    • Sign In with LinkedIn using OpenID Connect — grants openid, profile (and email, which Rustiz does not request).
    • Share on LinkedIn — grants w_member_social (self-serve, instant).
    • Community Management API — grants r_basicprofile, r_organization_social, rw_organization_admin, w_organization_social. This is the product that makes the full scope set valid. Apply and wait for approval; the app starts in the Development tier.
    • Advertising API is the alternative named in the Postiz guide. Its permission table also includes r_basicprofile, r_organization_social, rw_organization_admin and w_organization_social. Postiz’s docs explicitly recommend requesting it because it is also the path to programmatic refresh tokens.
    • r_basicprofile is a legacy Marketing API permission. The self-serve “Sign In with LinkedIn using OpenID Connect” product grants profile, not r_basicprofile, so adding only the two self-serve products is not enough.
  4. Confirm the granted scopes. Open the app’s Auth tab and check the “OAuth 2.0 scopes” list contains all seven values below. This list, not the requested URL, decides what the token will carry.

  5. Add the redirect URI under Auth → OAuth 2.0 settings → Authorized redirect URLs:

    {FRONTEND_URL}/integrations/social/linkedin
    

    Example: https://social.example.com/integrations/social/linkedin. No trailing slash. LinkedIn requires an absolute HTTPS URL; http://localhost is commonly accepted for development but is not what LinkedIn documents. Query parameters are ignored; a fragment (#) is invalid — register the bare path.

  6. Copy the credentials from the Auth tab and set them:

    LINKEDIN_CLIENT_ID="your-client-id"
    LINKEDIN_CLIENT_SECRET="your-client-secret"
    FRONTEND_URL="https://social.example.com"
    
  7. Restart Rustiz and run the status check above. Then connect a channel.

Exact OAuth scopes

Linkedin::scopes() returns, in this order:

#![allow(unused)]
fn main() {
vec![
    "openid",
    "profile",
    "w_member_social",
    "r_basicprofile",
    "rw_organization_admin",
    "w_organization_social",
    "r_organization_social",
]
}

The authorization URL joins them with %20 (URL-encoded spaces); the token response is checked by check_scopes(&self.info.scopes, token["scope"]). check_scopes accepts either a space- or comma-separated scope value and requires every requested scope. A missing scope produces ProviderError::NotEnoughScopes and the connection is refused; LinkedIn does not fail the login itself when a scope was not granted.

Which product grants what:

ScopeGranted by
openid, profileSign In with LinkedIn using OpenID Connect
w_member_socialShare on LinkedIn (also Community Management / Advertising API)
r_basicprofileCommunity Management API or Advertising API
rw_organization_adminCommunity Management API or Advertising API
w_organization_socialCommunity Management API or Advertising API
r_organization_socialCommunity Management API or Advertising API

The organization scopes are requested by the personal provider too, not just by linkedin-page. Both providers share the app and the scope set; this is why the same app can connect a member and then a page.

Redirect URI details

redirect_uri() is:

#![allow(unused)]
fn main() {
format!("{}/integrations/social/{}", self.ctx.config.frontend_url(), self.info.identifier)
}
  • frontend_url() trims all trailing /, so FRONTEND_URL=https://x/ still yields https://x/integrations/social/linkedin.
  • There is no redirectmeto.com relay (unlike TikTok): the value is used verbatim. LinkedIn wants HTTPS for anything non-local.
  • Personal provider, reconnect only: if a refresh id is supplied to GET /public/v1/social/linkedin?refresh=<internalId>, the token exchange appends ?refresh=<id> to the redirect URI (.../linkedin?refresh=r-1). The authorization URL itself never contains it. The CLI and connectChannelTool do not pass refresh, so this only matters for a client that calls the reconnect endpoint directly. If the exchange ever fails with invalid_redirect_uri, this is the first thing to inspect.
  • The page provider never appends ?refresh (its authenticate forces the plain URI), which is asserted by the port’s tests.

Both providers need their own URL registered if you use both:

https://social.example.com/integrations/social/linkedin
https://social.example.com/integrations/social/linkedin-page

Sandbox / testing mode

LinkedIn does not offer a separate sandbox host for these APIs. Instead:

  • Marketing API Development tier is the testing mode. Community Management Development is limited to 500 API calls per app per 24 h and 100 API calls per member per 24 h; BATCH_GET is disabled and Social Actions webhooks are not pushed. Plan a connect-and-post test well inside those numbers.
  • Use your own LinkedIn account plus a company page you administer. pages() / companies() only return organizations where the member has ADMINISTRATOR or CONTENT_ADMINISTRATOR.
  • There is no per-app “test user list” to add: any member who can log in and has the right page role can authorize the app.
  • Share on LinkedIn (member posting) is self-serve and works immediately after the product is added. Organization posting only works once the Community Management (or Advertising) product is approved.

Production app review

  • Community Management / Advertising APIs are gated products: apply through the Developer Portal, then request the Standard tier for production. Development is time-boxed (Community Management expects you to finish within 12 months) and call-limited; Standard removes the restrictions.
  • LinkedIn may require app verification (page association, business email, logo) before granting organization products.
  • If a product is not approved, the OAuth request fails with invalid scope (or returns a token without the scope and Rustiz reports not_enough_scopes).
  • Programmatic refresh tokens are only issued to approved Marketing API partners. Without one, the 60-day access token simply expires and the channel must be reconnected. Postiz’s guide calls this out as the reason to request the Advertising API.

Connecting a Channel (CLI & MCP)

CLI

# Open the LinkedIn consent page in the default browser.
rustiz channels connect linkedin --open

# Print the URL to stderr and wait for the callback in-process.
rustiz channels connect linkedin

# Remote server: open the printed URL yourself, then poll.
rustiz channels connect-status <connectionId>

Do not pass --fields or --external-url for this provider: any customFields object switches the shared connect path to the credential (customFields) branch, and externalUrl is unsupported for LinkedIn. Both produce a failed authenticate instead of an OAuth login.

In local/in-process mode the CLI serves the callback on the port of FRONTEND_URL when that is http://localhost / http://127.0.0.1, and waits up to --timeout seconds (default 300) for the new channel. It prints the channel JSON on success.

MCP

Start the flow:

{ "provider": "linkedin" }

The result is either a URL elicitation (for clients that support it) or:

{
  "connected": false,
  "pending": true,
  "connectionId": "abc123",
  "url": "https://www.linkedin.com/oauth/v2/authorization?...",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Poll with the same tool:

{ "connectionId": "abc123" }

linkedin is not is_between_steps, so there is no channelSetupTool follow-up. When polling reports connected, call integrationList to get the channel id.

Provider functions (triggerTool)

FunctionParametersReturnsNotes
companyurl — a company page URL such as https://www.linkedin.com/company/acme[name, "@[Name](urn:li:organization:<id>)"] or nullResolves the vanity name and returns the exact tag value to paste into the post text.
mentionquery — the exact vanity name (the part after linkedin.com/company/)array of {id, label, image, doNotCache}Searches /v2/organizations?q=vanityName. Only organization pages, never members.

The mention format is also applied automatically when a post carries a mention id/label (mention_format). escape_part preserves @[Name](urn:li:organization:<id>) and escapes every other @.

Post Settings & Media Validation

settings_schema()

FieldTypeRequiredBehavior
post_as_images_carouselbooleanNoIf true, the first post’s images are combined into one PDF document and uploaded as a LinkedIn document carousel. Requires at least 2 images and no video. Default: off.
carousel_namestringNoTitle shown on the PDF document. Used only when post_as_images_carousel is true; empty/missing becomes "slides".

additionalProperties: true. There is no visibility, link-preview, or reshare setting: every post is sent with visibility: "PUBLIC", feedDistribution: "MAIN_FEED" and lifecycleState: "PUBLISHED".

validate_settings() rules

  • If settings is not a JSON object, validation returns Ok(()): the DTO path only checks fields that exist. Send an object in practice.
  • post_as_images_carousel, if present and non-null, must be a boolean.
  • carousel_name, if present and non-null, must be a string.
  • Nothing else is validated here; media rules are in check_validity.

check_validity() rules

posts[0] is the main post; posts[1..] are thread replies/comments.

  • With post_as_images_carousel = true: the first post must have ≥ 2 media and no path containing mp4, otherwise: "Carousel can only be created with 2 or more images and no videos."
  • More than one media item with any mp4 in the first post: "Can have maximum 1 media when selecting a video."
  • Any media on a comment post: "Comments can only contain text."
  • Empty media is allowed (text-only posts).

Media and publish behavior

  • Images are read through the media store, then:
    • GIF passes through untouched.
    • PNG stays PNG.
    • JPEG stays JPEG.
    • Anything else is decoded and re-encoded as JPEG (quality 80).
    • Images are fitted inside 6000×6000 without enlargement (LinkedIn’s documented image cap is < 36,152,320 pixels; JPG/GIF/PNG).
  • Video is any path containing .mp4. It is uploaded in 2 MiB byte ranges to the uploadInstructions[0].uploadUrl, collecting the etag of every part, then POST /rest/videos?action=finalizeUpload with uploadedPartIds and an empty uploadToken. Initialization sends fileSizeBytes, uploadCaptions: false, uploadThumbnail: false.
  • PDF carousel: convert_images_to_pdf_carousel re-encodes every image to JPEG, uses the largest image’s dimensions as the PDF page size, centers every smaller image, and generates a minimal PDF (%PDF-1.4, one page per image, /DCTDecode JPEG streams). The resulting PDF is uploaded through the documents endpoint with Content-Type: application/pdf. The first post is rewritten to reference one synthetic carousel.pdf; the original comments are kept.
  • Only the first media of a post is used for video (validation enforces one); multi-image posts use content.multiImage.
  • Comment media is never published (validation rejects it).

Publish handshake

  1. post_pending uploads all media and returns PostResponse::pending with: authorId, postType (personal), message, isPdf, pdfTitle, mediaIds, poll (urns still processing), graceChecks.
  2. check_post_status polls each urn:
    • non-AVAILABLE → still processing;
    • PROCESSING_FAILED → BadBody with the failure reason;
    • a status-call error is tolerated up to 10 consecutive stalls;
    • once everything is available, if attempting && !confirmed it flips confirmed = true and returns Ready without creating the post.
  3. finalize_post:
    • if not attempting/confirmed, sets attempting = true, confirmed = false and returns Pending (the “arm” step);
    • on the next pass (attempting && confirmed) it creates the post.
  4. If attempting && confirmed is observed again in check_post_status, the provider refuses with: “LinkedIn may have already published this post, please check your account before posting again to avoid duplicates”. This is the duplicate guard for a crashed create.
  5. post() (the blocking path) loops for up to 8 minutes, sleeping 20 s between checks, then errors with “LinkedIn took too long to process the media, please try again”.

Plugs

The personal provider:

  • run_plug() → Unsupported for every method. autoRepostPost and autoPlugPost are not available on personal accounts.
  • run_post_plug() supports:
    • addComment — posts information["comment"] as a comment using the integration’s own token/actor (urn:li:person:<internalId>).
    • repostPostUsers — reshares post_id with reshareContext.parent, authored as the integration’s person urn and with empty commentary.

The page provider has the mirror image (see linkedin-page.md): it supports the two run_plug automations but rejects run_post_plug.

Implementation Notes & Gotchas

  • prompt=none is sent on the authorization URL. LinkedIn’s documented parameters are only response_type, client_id, redirect_uri, state and scope; prompt is a port artifact. It is harmless today, but if LinkedIn starts validating unknown parameters it can break the login.
  • No PKCE. generate_auth_url returns a code_verifier and the state is random, but the authorization URL has no code_challenge and the token request sends client_secret. Treat the flow as classic confidential-client OAuth.
  • The token response’s scope is authoritative. If the app is missing a product, the login can appear to succeed and then fail with not_enough_scopes after the code exchange. Check the Auth tab before debugging anything else.
  • 60-day access tokens; programmatic refresh is partner-only. refresh_token() posts grant_type=refresh_token to /oauth/v2/accessToken with the app credentials. If LinkedIn did not issue a refresh token, Rustiz stores None, the token eventually returns 401, the generic HTTP layer classifies 401 as RefreshToken, the engine tries to refresh with an empty token, fails, marks the channel refreshNeeded and notifies the org. This is exactly why the Postiz guide insists on the Advertising API form.
  • Token refresh is reactive, not scheduled. refresh_cron is false. The only proactive refresh in the engine applies to providers with refresh_cron = true; LinkedIn is refreshed when an API call produces a 401/RefreshToken (publish, analytics, trigger).
  • one_time_token = true and refresh_wait = true are declared but unused by the Rust engine. They are upstream metadata; do not expect special scheduling.
  • Hard-coded API versions. Posts, media, organizations, analytics and social actions send LinkedIn-Version: 202601. Comments send 202306, with the code comment “the comment endpoint still answers to the old version”. LinkedIn supports each version for roughly a year (202601 is scheduled to sunset around January 2027; 202306 is already far past its normal window). If comments start returning 426/400, this header is the cause.
  • Comment URLs are different. Main posts produce https://www.linkedin.com/feed/update/<id>; comments produce https://www.linkedin.com/embed/feed/update/<id>.
  • Text is escaped, not markdown. fix_text escapes \ < > # ~ _ | [ ] * ( ) { } @ outside company mentions. Company mentions written as @[Name](urn:li:organization:<id>) are preserved. This means #hashtag becomes \#hashtag; that is intentional little-text escaping, not a bug.
  • Mentions are organizations only. mention() calls /v2/organizations?q=vanityName&vanityName=..., so it can tag company pages, never individual members. supports_mentions = true means “company mentions”.
  • r_basicprofile does not grant member search. Even with the scope, the provider only reads sub, name, picture and vanityName.
  • Personal analytics really are unsupported. analytics() and post_analytics() return Unsupported when page == false. Use linkedin-page for organization metrics.
  • Signed media URLs can change the format. mime_lookup takes the extension after the last . of the final path segment including the query string. photo.png?token=... is therefore not recognized as PNG and is re-encoded as JPEG (losing transparency); a GIF behind a signed URL loses animation. Plain /uploads/photo.png paths are unaffected. has_extension (used for .mp4/.pdf) does a substring match, so videos and PDFs with query strings are still detected.
  • Media polling can be slow. Video/document processing is polled every 20 s for up to 8 minutes in the blocking path; the durable engine path can wait longer because it re-schedules. LinkedIn also occasionally returns 403 “resource is forbidden” or 503 “Service Unavailable” during processing; both are mapped to retry.
  • Video chunk size is 2 MiB. LinkedIn’s examples commonly use 4 MiB parts. Very large videos are worth testing end-to-end; the port is faithful to the upstream implementation rather than LinkedIn’s sample size.
  • Rate limits. Community Management Development is 500 calls/app/24 h plus 100 calls/member/24 h; Standard has no such restriction. Share on LinkedIn additionally throttles member posting (third-party write-ups commonly cite ~150 requests/member/day and 100,000/app/day, but LinkedIn does not publish that number in the OAuth guide). The generic HTTP layer retries 429, unclassified 500 and retry-classified responses twice (three attempts total, 5 s apart); there is no provider-specific rate-limit message.

References