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 Page (linkedin-page)

linkedin-page publishes as a company page (urn:li:organization:<organizationId>). It is the same Rust implementation as linkedin with three differences: the identifier, the author type (company), and organization analytics. The provider is is_between_steps = true: the OAuth login creates a placeholder channel for the signed-in member, then channelSetupTool replaces it with the chosen organization.

  • Rust source: crates/rustiz-providers/src/linkedin_page.rs (wraps crates/rustiz-providers/src/linkedin.rs)
  • Upstream port: linkedin.page.provider.ts
  • Editor: Normal
  • Connection mode: oauth + a mandatory page-selection step

Both providers use the same app and the same seven scopes. You need the Community Management API (or Advertising API) product even to list the pages you administer. Register a second redirect URI for linkedin-page.

Overview

PropertyValue
Identifierlinkedin-page
Display nameLinkedIn Page
Connection modeoauth, then channelSetupTool (page selection)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; comments are authored as the organization
Pending / async publishingYes. supports_pending = true; same media → arm/confirm → create flow
AnalyticsYes. supports_analytics = true; organization page, follower and share statistics
Post analyticsYes. supports_post_analytics = true; per-share statistics with a social-actions fallback
MentionsYes, company pages only. supports_mentions = true
is_between_stepstrue — a channel is unusable until a page is selected
convert_to_jpegfalse
max_concurrent_job2 declared. The engine’s queue key is linkedin for both LinkedIn providers and takes the max of their limits, so the effective LinkedIn queue has 8 permits.
Character limit (max_length)3000 characters
APILinkedIn REST Posts API, LinkedIn-Version: 202601 (comments 202306)
OAuth scopesopenid, profile, w_member_social, r_basicprofile, rw_organization_admin, w_organization_social, r_organization_social
Token lifetime60-day access token; partner-only programmatic refresh

Server Environment Variables

required_env() is exactly the same as the personal provider:

#![allow(unused)]
fn main() {
&["LINKEDIN_CLIENT_ID", "LINKEDIN_CLIENT_SECRET"]
}
VariableRequiredDefaultPurpose
LINKEDIN_CLIENT_IDYesnoneSame app as linkedin.
LINKEDIN_CLIENT_SECRETYesnoneSame app as linkedin.
FRONTEND_URLYes, in practicehttp://localhost:5000Builds the redirect URI. Trailing slashes stripped.
LINKEDIN_BASE_URLNohttps://api.linkedin.comREST API origin.
LINKEDIN_OAUTH_BASE_URLNohttps://www.linkedin.comAuthorize/token origin.

There are no page-specific variables: the page provider reuses the linkedin app’s LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET.

Step-by-Step Developer App Setup

Use one LinkedIn app for both linkedin and linkedin-page.

  1. Create the app at https://www.linkedin.com/developers/apps/new and associate the company page you administer.

  2. Add products under Products:

    • Sign In with LinkedIn using OpenID Connect → openid, profile.
    • Share on LinkedIn → w_member_social.
    • Community Management API → r_basicprofile, r_organization_social, rw_organization_admin, w_organization_social. The Advertising API grants the same organization set and is the product the Postiz guide tells you to request.
    • Without a Marketing API product, companies() cannot work: the ACL endpoint requires rw_organization_admin, and posting requires w_organization_social.
  3. On the Auth tab, verify the OAuth 2.0 scopes list has all seven scopes from linkedin.md.

  4. Add both authorized redirect URLs:

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

    The page path is {FRONTEND_URL}/integrations/social/linkedin-page. No trailing slash. The code appends ?refresh= only for the personal provider, never for the page provider.

  5. Set LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET and FRONTEND_URL, restart, and confirm:

    rustiz providers --status | jq '.[] | select(.identifier=="linkedin-page")'
    
  6. Connect the channel, then select a page (next section). A freshly connected channel has inBetweenSteps=true and cannot publish.

Exact OAuth scopes

Identical Linkedin::scopes() set:

openid profile w_member_social r_basicprofile rw_organization_admin w_organization_social r_organization_social

check_scopes runs on the token response’s scope and requires every value. The page provider passes its own identifier into details_from_token, so the initial channel id is prefixed linkedin-page_<memberSub>; that placeholder is replaced during page selection.

Redirect URI details

{FRONTEND_URL}/integrations/social/linkedin-page
  • FRONTEND_URL has trailing / removed before concatenation.
  • No redirectmeto.com; the literal URI is sent to LinkedIn at authorize time and again at token exchange, byte-for-byte.
  • authenticate() explicitly skips the ?refresh= suffix for the page provider, even when a refresh id was stored. The port’s test authenticate_prefixes_id_and_never_adds_refresh_to_redirect asserts this.

Sandbox / testing mode

  • No separate sandbox. The Community Management Development tier is the testing mode: 500 API calls per app per 24 h, 100 per member per 24 h, no BATCH_GET, no Social Actions webhooks.
  • The signed-in member must be ADMINISTRATOR or CONTENT_ADMINISTRATOR on the target page. companies() filters out every other role.
  • App verification / page association may be required before the organization products are granted.
  • Test with a page you own; there is no “test page” construct.

Production app review

  • Apply for Community Management API (or Advertising API) and, for live traffic, the Standard tier. Development is restricted and time-boxed.
  • Standard removes the call limits and enables BATCH_GET / webhooks.
  • Approval is discretionary; LinkedIn can refuse or delay. Until then the page can still be connected on the Development tier, subject to the daily caps.
  • Refresh tokens are only issued to approved Marketing API partners. Without one, the 60-day access token expires and the page must be reconnected.

Connecting a Channel (CLI & MCP)

CLI

# 1. OAuth login. Creates an in-between-steps placeholder channel.
rustiz channels connect linkedin-page --open

# Remote server: the login URL is printed; poll with:
rustiz channels connect-status <connectionId>

# 2. The CLI has no page-selection command. Finish with MCP
#    channelSetupTool (or the equivalent API call), then list:
rustiz channels list

Do not pass --fields: it switches the shared connect path to the customFields branch, which this provider does not implement.

The CLI’s in-process callback server only activates when FRONTEND_URL is http://localhost or http://127.0.0.1; otherwise a remote rustiz serve handles the callback.

MCP

Step 1 — start OAuth:

{ "provider": "linkedin-page" }

Returns connectionId and url (or a URL elicitation). A human opens the URL.

Step 2 — poll until connected:

{ "connectionId": "abc123" }

The channel now exists with inBetweenSteps = true. It cannot publish yet.

Step 3 — list the pages the member administers:

{
  "integrationId": "int_...",
  "methodName": "companies",
  "dataSchema": []
}

companies calls:

GET /v2/organizationalEntityAcls?q=roleAssignee&state=APPROVED
    &projection=(elements*(role,organizationalTarget~(localizedName,vanityName,logoV2(original~:playableStreams))))

and returns only ADMINISTRATOR / CONTENT_ADMINISTRATOR entries:

[
  {
    "id": "111",
    "page": "111",
    "username": "acme",
    "name": "Acme",
    "picture": "https://media.example/logo.png"
  }
]

Step 4 — choose the page:

{
  "integrationId": "int_...",
  "data": { "page": "111" }
}

fetch_page_information reads data["page"] (not id), then calls:

GET /v2/organizations/111?projection=(id,localizedName,vanityName,logoV2(original~:playableStreams))

channelSetupTool writes the organization id/name/logo/vanity name into the integration, sets inBetweenSteps = false, and returns { "success": true, "name": "Acme" }. Selecting a page that is already connected as another channel fails with That page is already connected as another channel.

Never skip step 3/4. A placeholder channel has the member’s id (linkedin-page_<sub>) and would post as urn:li:organization:<memberSub> — the Posts API rejects it.

Provider functions (triggerTool)

FunctionParametersReturnsNotes
companiesnonearray of {id, page, username, name, picture}Only pages where the member is ADMINISTRATOR or CONTENT_ADMINISTRATOR; pages with a null organizationalTarget~ are skipped.

The personal provider’s company / mention helpers are declared on the shared Linkedin struct, but LinkedinPage::functions() exposes only companies. Use triggerTool with methodName: "companies".

Post Settings & Media Validation

LinkedinPage delegates settings_schema(), validate_settings() and check_validity() to Linkedin, so the rules are identical to the personal provider:

FieldTypeRequiredBehavior
post_as_images_carouselbooleanNoCombine the first post’s images into a PDF document carousel. Needs ≥ 2 images, no video.
carousel_namestringNoPDF document title; defaults to "slides".

validate_settings(): boolean/string type checks only.

check_validity():

  • carousel + fewer than 2 images or any video → "Carousel can only be created with 2 or more images and no videos."
  • more than one media with a video → "Can have maximum 1 media when selecting a video."
  • media on a comment post → "Comments can only contain text."

Media handling, PDF generation, the upload endpoints and the arm/confirm create handshake are all in linkedin.md. The only page-specific differences are:

  • post_payload uses author = urn:li:organization:<internalId>.
  • upload_picture uses the organization owner for initialize-upload requests.
  • Company images and documents are polled through /rest/images/<urn> and /rest/documents/<urn> until AVAILABLE; personal images/documents only get a one-cycle grace check.
  • Comments use actor = urn:li:organization:<internalId> and the LinkedIn-Version: 202306 header.
  • The release URL is still https://www.linkedin.com/feed/update/<postId>.

Plugs

The page provider supports the run_plug automations (the opposite of the personal provider):

MethodFieldsBehavior
autoRepostPostlikesAmountReads likesSummary.totalLikes from /v2/socialActions/<postId>; if >= likesAmount, waits 2 s and creates a reshare with reshareContext.parent = postId, authored by the page. Returns true when it reposted.
autoPlugPostlikesAmount, postSame threshold; posts post as a comment on postId with actor = urn:li:organization:<internalId>. Returns true when it commented.

run_post_plug() returns Unsupported for every method, so addComment / repostPostUsers plugs cannot target a page through this provider.

The plug fields are read leniently (plug_text): strings, numbers and booleans are all stringified; a missing likesAmount parses to 0, so the threshold is always met.

Implementation Notes & Gotchas

  • The page id is not the member id. The initial OAuth channel id is linkedin-page_<memberSub>; channelSetupTool replaces it with the numeric organization id. Forgetting the setup step produces a channel that fails at publish time with an invalid organization urn.
  • Page selection is a separate privilege. companies() needs rw_organization_admin; page_information() additionally reads the organization’s localizedName, vanityName and logoV2. A member with only w_organization_social can post but cannot list/select pages.
  • Role filter is strict. Only ADMINISTRATOR and CONTENT_ADMINISTRATOR are returned. ANALYST, RECRUITER, LEAD_GENERATION and similar roles are invisible even though they may see the page in LinkedIn.
  • Analytics has a fixed percentage_change = 5.0. page_analytics always reports a 5.0% change regardless of the data — a port artifact, not a computed delta. Labels are always returned in this order: Page Views, Clicks, Shares, Engagement, Comments, Organic Followers, Paid Followers.
  • Page analytics needs three endpoints. It queries organizationPageStatistics, organizationalEntityFollowerStatistics and organizationalEntityShareStatistics with a daily time range. If one product scope is missing, the whole call fails rather than returning partial data.
  • Post analytics falls back to social actions. It queries organizationalEntityShareStatistics?...&shares=List(<postId>); if every label is empty it queries /v2/socialActions/<postId> and emits only Likes and Comments for today. Empty labels are filtered out.
  • post_analytics() returns labels lazily. Unlike page_analytics, it drops empty series, so a post with no data returns [] rather than zeroed series.
  • Plugs are inverted between the two providers. Page: run_plug works, run_post_plug does not. Personal: run_post_plug works, run_plug does not. This is faithful to upstream, but surprising.
  • autoRepostPost and autoPlugPost take a raw post id. They do not normalize a LinkedIn URN or URL; pass the id the provider returned (releaseId, e.g. urn:li:share:...).
  • The 2-permit concurrency limit is not what runs. ProviderInfo says max_concurrent_job = 2, but the engine keys queues on the identifier prefix before -, so linkedin and linkedin-page share the linkedin queue and the code takes the maximum (8). Do not size rate-limit budgets from the declared 2.
  • companies() does not paginate. The ACL request sets q=roleAssignee and state=APPROVED but no count/start, so only LinkedIn’s default first page of organization ACLs is returned. A member who administers many pages may not see all of them in triggerTool; pass a page id directly to channelSetupTool if you already know it.
  • refresh_token() returns the member, not the page. It calls details_from_token(..., prefix_id = false), so the returned id is the bare member sub (no linkedin-page_ prefix and no organization id). The engine’s refresh path only stores the access/refresh token and expiry, so the channel’s organization internal_id is preserved; do not treat the refresh result’s id as the page id.
  • Same 60-day token and partner-only refresh as the personal provider. See linkedin.md.
  • Reconnect re-derives the page. reconnect() calls pages() and fetch_page_information() with the required organization id, returning the page’s name/logo/vanity name. It is only reached by the engine when root_internal_id differs from internal_id; normal OAuth channels leave root_internal_id null, so the shared member token is used directly.
  • Rate limits and versions are shared with the personal provider. The same 202601 / 202306 headers, the same fix_text escaping and the same company-only mention format apply.

References