Skip to content

feat: add LinkedIn organization page support to InSender #71

Description

@artcava

Context

Currently InSender always posts as a personal profile, building the author URN as urn:li:person:{IN_OWNER}. LinkedIn also supports posting to organization (company) pages via the same UGC Posts API, using urn:li:organization:{org_id} as the author.

This issue tracks the full implementation of the company-page sender path.


LinkedIn Platform Prerequisites

Before writing any code, the following must be configured in the LinkedIn Developer Portal:

1. LinkedIn Developer App setup

  1. Go to https://www.linkedin.com/developers/apps and create or select your app.
  2. In the Products tab, request access to:
    • Share on LinkedIn (required for UGC post creation)
    • Marketing Developer Platform (required to post as an organization)
  3. Under AuthOAuth 2.0 scopes, ensure these scopes are authorised:
    • w_member_social – post as a person
    • w_organization_socialrequired to post as an organization
    • r_organization_social – read org posts (useful for debugging)
  4. Verify that your LinkedIn account has the Admin role on the target organization page. Without this, the API will return a 403 Forbidden.

2. Obtain a token with the correct scopes

Follow the LinkedIn OAuth 2.0 Authorization Code flow and include w_organization_social in the scope parameter:

https://www.linkedin.com/oauth/v2/authorization
  ?response_type=code
  &client_id={YOUR_CLIENT_ID}
  &redirect_uri={YOUR_REDIRECT_URI}
  &scope=w_member_social%20w_organization_social%20r_organization_social

Exchange the authorization code for an access token via:

POST https://www.linkedin.com/oauth/v2/accessToken

Store the resulting token in IN_ACCESS_TOKEN.

3. Find your organization ID

Call:

GET https://api.linkedin.com/v2/organizationalEntityAcls?q=roleAssignee&role=ADMINISTRATOR&projection=(elements*(organizationalTarget~(id,localizedName)))
Authorization: Bearer {IN_ACCESS_TOKEN}
X-Restli-Protocol-Version: 2.0.0

The id field of the organization element is the numeric ID to use in the URN (e.g. 98765432).

Alternatively, find it in the LinkedIn company page URL:
https://www.linkedin.com/company/YOUR_COMPANY/admin/ → the URL will contain the numeric ID.


Environment Variables Required

Add one new variable to src/local.settings.json.example and docs/configuration.md:

Variable Type Required Description
IN_ORG_ID string Optional Numeric LinkedIn organization ID. When set, posts are authored as urn:li:organization:{IN_ORG_ID}. When absent, posts fall back to the personal profile via urn:li:person:{IN_OWNER}.

Implementation Plan

src/SenderPlugins/InSender.cs

Replace the hardcoded urn:li:person:{inOwner} with a helper that resolves the correct URN:

private static string ResolveAuthorUrn()
{
    var orgId = Environment.GetEnvironmentVariable("IN_ORG_ID");
    if (!string.IsNullOrWhiteSpace(orgId))
        return $"urn:li:organization:{orgId}";

    var personId = Environment.GetEnvironmentVariable("IN_OWNER")
        ?? throw new InvalidOperationException("Either IN_OWNER or IN_ORG_ID must be set.");
    return $"urn:li:person:{personId}";
}

Replace the line:

var inOwner = Environment.GetEnvironmentVariable("IN_OWNER")
    ?? throw new InvalidOperationException("IN_OWNER environment variable is not set.");

with:

var author = ResolveAuthorUrn();

Update the payload builder to use author instead of $"urn:li:person:{inOwner}":

return new
{
    author = author,   // ← was: $"urn:li:person:{owner}"
    lifecycleState = "PUBLISHED",
    specificContent,
    visibility
};

generatePayLoad signature update

Change:

private dynamic generatePayLoad(string? asset, string owner, string summary)

to:

private dynamic generatePayLoad(string? asset, string authorUrn, string summary)

and update all call sites accordingly.


Image upload for organization posts

Note: the current registerUpload recipe (urn:li:digitalmediaRecipe:feedshare-image) and owner URN in the init payload must also reflect the organization URN when posting as an organization:

owner = authorUrn,   // urn:li:organization:... or urn:li:person:...

Tests to Add

In tests/SenderPlugins/InSenderTests.cs:

  • ResolveAuthorUrn_WhenOrgIdIsSet_ReturnsOrganizationUrn
  • ResolveAuthorUrn_WhenOrgIdIsAbsent_ReturnsPersonUrn
  • ResolveAuthorUrn_WhenBothAreAbsent_ThrowsInvalidOperationException
  • SendAsync_WhenPostingAsOrganization_PayloadContainsOrgUrn

Documentation to Update

  • docs/configuration.md – add IN_ORG_ID row
  • src/local.settings.json.example – add "IN_ORG_ID": "" with comment
  • README.md – update LinkedIn description to mention org page support
  • docs/getting-started.md – add a note explaining the IN_OWNER vs IN_ORG_ID selection

Acceptance Criteria

  • When IN_ORG_ID is set and IN_OWNER is also set, posts are authored as urn:li:organization:{IN_ORG_ID}
  • When only IN_OWNER is set, behaviour is unchanged (personal profile)
  • When neither is set, InSender throws InvalidOperationException at runtime (fail-fast)
  • All new tests pass
  • CI remains green

Metadata

Metadata

Assignees

No one assigned

    Labels

    approvedtriage completed successfullyenhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions