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(wrapscrates/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
| Property | Value |
|---|---|
| Identifier | linkedin-page |
| Display name | LinkedIn Page |
| Connection mode | oauth, then channelSetupTool (page selection) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; comments are authored as the organization |
| Pending / async publishing | Yes. supports_pending = true; same media → arm/confirm → create flow |
| Analytics | Yes. supports_analytics = true; organization page, follower and share statistics |
| Post analytics | Yes. supports_post_analytics = true; per-share statistics with a social-actions fallback |
| Mentions | Yes, company pages only. supports_mentions = true |
is_between_steps | true — a channel is unusable until a page is selected |
convert_to_jpeg | false |
max_concurrent_job | 2 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 |
| API | LinkedIn REST Posts API, LinkedIn-Version: 202601 (comments 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; 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"]
}
| Variable | Required | Default | Purpose |
|---|---|---|---|
LINKEDIN_CLIENT_ID | Yes | none | Same app as linkedin. |
LINKEDIN_CLIENT_SECRET | Yes | none | Same app as linkedin. |
FRONTEND_URL | Yes, in practice | http://localhost:5000 | Builds the redirect URI. Trailing slashes stripped. |
LINKEDIN_BASE_URL | No | https://api.linkedin.com | REST API origin. |
LINKEDIN_OAUTH_BASE_URL | No | https://www.linkedin.com | Authorize/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.
-
Create the app at https://www.linkedin.com/developers/apps/new and associate the company page you administer.
-
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 requiresrw_organization_admin, and posting requiresw_organization_social.
- Sign In with LinkedIn using OpenID Connect →
-
On the Auth tab, verify the OAuth 2.0 scopes list has all seven scopes from linkedin.md.
-
Add both authorized redirect URLs:
https://social.example.com/integrations/social/linkedin https://social.example.com/integrations/social/linkedin-pageThe 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. -
Set
LINKEDIN_CLIENT_ID,LINKEDIN_CLIENT_SECRETandFRONTEND_URL, restart, and confirm:rustiz providers --status | jq '.[] | select(.identifier=="linkedin-page")' -
Connect the channel, then select a page (next section). A freshly connected channel has
inBetweenSteps=trueand 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_URLhas 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 testauthenticate_prefixes_id_and_never_adds_refresh_to_redirectasserts 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
ADMINISTRATORorCONTENT_ADMINISTRATORon 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)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
companies | none | array 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:
| Field | Type | Required | Behavior |
|---|---|---|---|
post_as_images_carousel | boolean | No | Combine the first post’s images into a PDF document carousel. Needs ≥ 2 images, no video. |
carousel_name | string | No | PDF 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_payloadusesauthor = urn:li:organization:<internalId>.upload_pictureuses the organization owner for initialize-upload requests.- Company images and documents are polled through
/rest/images/<urn>and/rest/documents/<urn>untilAVAILABLE; personal images/documents only get a one-cycle grace check. - Comments use
actor = urn:li:organization:<internalId>and theLinkedIn-Version: 202306header. - 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):
| Method | Fields | Behavior |
|---|---|---|
autoRepostPost | likesAmount | Reads 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. |
autoPlugPost | likesAmount, post | Same 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>;channelSetupToolreplaces 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()needsrw_organization_admin;page_information()additionally reads the organization’slocalizedName,vanityNameandlogoV2. A member with onlyw_organization_socialcan post but cannot list/select pages. - Role filter is strict. Only
ADMINISTRATORandCONTENT_ADMINISTRATORare returned.ANALYST,RECRUITER,LEAD_GENERATIONand similar roles are invisible even though they may see the page in LinkedIn. - Analytics has a fixed
percentage_change = 5.0.page_analyticsalways 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,organizationalEntityFollowerStatisticsandorganizationalEntityShareStatisticswith 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 onlyLikesandCommentsfor today. Empty labels are filtered out. post_analytics()returns labels lazily. Unlikepage_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_plugworks,run_post_plugdoes not. Personal:run_post_plugworks,run_plugdoes not. This is faithful to upstream, but surprising. autoRepostPostandautoPlugPosttake 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.
ProviderInfosaysmax_concurrent_job = 2, but the engine keys queues on the identifier prefix before-, solinkedinandlinkedin-pageshare thelinkedinqueue and the code takes the maximum (8). Do not size rate-limit budgets from the declared 2. companies()does not paginate. The ACL request setsq=roleAssigneeandstate=APPROVEDbut nocount/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 intriggerTool; pass a page id directly tochannelSetupToolif you already know it.refresh_token()returns the member, not the page. It callsdetails_from_token(..., prefix_id = false), so the returned id is the bare membersub(nolinkedin-page_prefix and no organization id). The engine’s refresh path only stores the access/refresh token and expiry, so the channel’s organizationinternal_idis 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()callspages()andfetch_page_information()with the required organization id, returning the page’s name/logo/vanity name. It is only reached by the engine whenroot_internal_iddiffers frominternal_id; normal OAuth channels leaveroot_internal_idnull, so the shared member token is used directly. - Rate limits and versions are shared with the personal provider. The same
202601/202306headers, the samefix_textescaping and the same company-only mention format apply.
References
- LinkedIn 3-legged OAuth: https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow
- API products and scopes: https://learn.microsoft.com/en-us/linkedin/marketing/increasing-access
- Community Management app review / tiers: https://learn.microsoft.com/en-us/linkedin/marketing/community-management-app-review
- Organizations and ACL lookup: https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/organization-access-control
- Share on LinkedIn: https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/share-on-linkedin
- Postiz LinkedIn Page guide: https://docs.postiz.com/self-host/providers/linkedin-page
- Postiz posting rules: https://docs.postiz.com/general/platforms/overview