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_basicprofileand 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 withnot_enough_scopesor LinkedIn rejects the authorization withinvalid scope. See Implementation Notes & Gotchas.
Overview
| Property | Value |
|---|---|
| Identifier | linkedin |
| Display name | |
| Connection mode | oauth (authorization code, no PKCE) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; replies are posted as comments on the parent post and may only contain text |
| Pending / async publishing | Yes. supports_pending = true; post_pending → check_post_status → finalize_post |
| Analytics | No for the member provider (supports_analytics = false; analytics() returns Unsupported). Page analytics live in linkedin-page |
| Post analytics | No (supports_post_analytics = false) |
| Mentions | Yes, company pages only. supports_mentions = true, mention_format emits @[Name](urn:li:organization:<id>) |
is_between_steps | false |
convert_to_jpeg | false (media is re-encoded inside prepare_media_buffer, not by the engine) |
max_concurrent_job | 8 |
Character limit (max_length) | 3000 characters |
| API | LinkedIn REST Posts API, LinkedIn-Version: 202601 (comments use 202306) |
| OAuth scopes | openid, profile, w_member_social, r_basicprofile, rw_organization_admin, w_organization_social, r_organization_social |
| Token lifetime | 60-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.
| Variable | Required | Default | Purpose |
|---|---|---|---|
LINKEDIN_CLIENT_ID | Yes | none | LinkedIn app Client ID (Auth tab). |
LINKEDIN_CLIENT_SECRET | Yes | none | LinkedIn app Client Secret (Auth tab). |
FRONTEND_URL | Yes, in practice | http://localhost:5000 | Builds the OAuth redirect URI. Trailing slashes are stripped by Config::frontend_url(). |
LINKEDIN_BASE_URL | No | https://api.linkedin.com | Overrides the REST API origin. Trailing slash stripped. |
LINKEDIN_OAUTH_BASE_URL | No | https://www.linkedin.com | Overrides 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.
-
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.
-
Verify the app if the Products tab asks for it (business email verification and page association).
-
Add the products under Products:
- Sign In with LinkedIn using OpenID Connect — grants
openid,profile(andemail, 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_adminandw_organization_social. Postiz’s docs explicitly recommend requesting it because it is also the path to programmatic refresh tokens. r_basicprofileis a legacy Marketing API permission. The self-serve “Sign In with LinkedIn using OpenID Connect” product grantsprofile, notr_basicprofile, so adding only the two self-serve products is not enough.
- Sign In with LinkedIn using OpenID Connect — grants
-
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.
-
Add the redirect URI under Auth → OAuth 2.0 settings → Authorized redirect URLs:
{FRONTEND_URL}/integrations/social/linkedinExample:
https://social.example.com/integrations/social/linkedin. No trailing slash. LinkedIn requires an absolute HTTPS URL;http://localhostis commonly accepted for development but is not what LinkedIn documents. Query parameters are ignored; a fragment (#) is invalid — register the bare path. -
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" -
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:
| Scope | Granted by |
|---|---|
openid, profile | Sign In with LinkedIn using OpenID Connect |
w_member_social | Share on LinkedIn (also Community Management / Advertising API) |
r_basicprofile | Community Management API or Advertising API |
rw_organization_admin | Community Management API or Advertising API |
w_organization_social | Community Management API or Advertising API |
r_organization_social | Community 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/, soFRONTEND_URL=https://x/still yieldshttps://x/integrations/social/linkedin.- There is no
redirectmeto.comrelay (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 andconnectChannelTooldo not passrefresh, so this only matters for a client that calls the reconnect endpoint directly. If the exchange ever fails withinvalid_redirect_uri, this is the first thing to inspect. - The page provider never appends
?refresh(itsauthenticateforces 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_GETis 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 hasADMINISTRATORorCONTENT_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 reportsnot_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)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
company | url — a company page URL such as https://www.linkedin.com/company/acme | [name, "@[Name](urn:li:organization:<id>)"] or null | Resolves the vanity name and returns the exact tag value to paste into the post text. |
mention | query — 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()
| Field | Type | Required | Behavior |
|---|---|---|---|
post_as_images_carousel | boolean | No | If 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_name | string | No | Title 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
settingsis not a JSON object, validation returnsOk(()): 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 containingmp4, otherwise:"Carousel can only be created with 2 or more images and no videos." - More than one media item with any
mp4in 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 theuploadInstructions[0].uploadUrl, collecting theetagof every part, thenPOST /rest/videos?action=finalizeUploadwithuploadedPartIdsand an emptyuploadToken. Initialization sendsfileSizeBytes,uploadCaptions: false,uploadThumbnail: false. - PDF carousel:
convert_images_to_pdf_carouselre-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,/DCTDecodeJPEG streams). The resulting PDF is uploaded through thedocumentsendpoint withContent-Type: application/pdf. The first post is rewritten to reference one syntheticcarousel.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
post_pendinguploads all media and returnsPostResponse::pendingwith:authorId,postType(personal),message,isPdf,pdfTitle,mediaIds,poll(urns still processing),graceChecks.check_post_statuspolls each urn:- non-
AVAILABLE→ still processing; PROCESSING_FAILED→BadBodywith the failure reason;- a status-call error is tolerated up to 10 consecutive stalls;
- once everything is available, if
attempting && !confirmedit flipsconfirmed = trueand returnsReadywithout creating the post.
- non-
finalize_post:- if not
attempting/confirmed, setsattempting = true,confirmed = falseand returnsPending(the “arm” step); - on the next pass (
attempting && confirmed) it creates the post.
- if not
- If
attempting && confirmedis observed again incheck_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. 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()→Unsupportedfor every method.autoRepostPostandautoPlugPostare not available on personal accounts.run_post_plug()supports:addComment— postsinformation["comment"]as a comment using the integration’s own token/actor (urn:li:person:<internalId>).repostPostUsers— resharespost_idwithreshareContext.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=noneis sent on the authorization URL. LinkedIn’s documented parameters are onlyresponse_type,client_id,redirect_uri,stateandscope;promptis a port artifact. It is harmless today, but if LinkedIn starts validating unknown parameters it can break the login.- No PKCE.
generate_auth_urlreturns acode_verifierand the state is random, but the authorization URL has nocode_challengeand the token request sendsclient_secret. Treat the flow as classic confidential-client OAuth. - The token response’s
scopeis authoritative. If the app is missing a product, the login can appear to succeed and then fail withnot_enough_scopesafter the code exchange. Check the Auth tab before debugging anything else. - 60-day access tokens; programmatic refresh is partner-only.
refresh_token()postsgrant_type=refresh_tokento/oauth/v2/accessTokenwith the app credentials. If LinkedIn did not issue a refresh token, Rustiz storesNone, the token eventually returns 401, the generic HTTP layer classifies 401 asRefreshToken, the engine tries to refresh with an empty token, fails, marks the channelrefreshNeededand notifies the org. This is exactly why the Postiz guide insists on the Advertising API form. - Token refresh is reactive, not scheduled.
refresh_cronis false. The only proactive refresh in the engine applies to providers withrefresh_cron = true; LinkedIn is refreshed when an API call produces a 401/RefreshToken(publish, analytics, trigger). one_time_token = trueandrefresh_wait = trueare 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 send202306, 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 producehttps://www.linkedin.com/embed/feed/update/<id>. - Text is escaped, not markdown.
fix_textescapes\ < > # ~ _ | [ ] * ( ) { } @outside company mentions. Company mentions written as@[Name](urn:li:organization:<id>)are preserved. This means#hashtagbecomes\#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 = truemeans “company mentions”. r_basicprofiledoes not grant member search. Even with the scope, the provider only readssub,name,pictureandvanityName.- Personal analytics really are unsupported.
analytics()andpost_analytics()returnUnsupportedwhenpage == false. Uselinkedin-pagefor organization metrics. - Signed media URLs can change the format.
mime_lookuptakes 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.pngpaths 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
- LinkedIn 3-legged OAuth (parameters, redirect rules, token response, refresh behavior): https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow
- LinkedIn API products and permissions (Community Management / Advertising scope tables): https://learn.microsoft.com/en-us/linkedin/marketing/increasing-access
- Programmatic refresh tokens: https://learn.microsoft.com/en-us/linkedin/shared/authentication/programmatic-refresh-tokens
- Sign In with LinkedIn using OpenID Connect: https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2
- Images API limits: https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/images-api
- Videos API: https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/videos-api
- LinkedIn app registration: https://www.linkedin.com/developers/apps
- Postiz LinkedIn guide: https://docs.postiz.com/self-host/providers/linkedin
- Postiz posting rules: https://docs.postiz.com/general/platforms/overview