Working with the LinkedIn API with C#

17 minute read

The last day few days, I have been experimenting with the LinkedIn API to send my blog posts, YouTube videos, and community updates automatically to my LinkedIn profile from my open-source project JosephGuadagnoNet.Broadcasting. It’s been quite a challenge to get it to work. I ran into authentication issues, permission issues, and serialization issues. So what did I decide to do? You guessed, solve the issues and blog about it so you don’t have to.

Getting Started

First things first, you need to have a LinkedIn account and LinkedIn company page to post shares, as they are called on LinkedIn. If you don’t have an account or a company page, you can create one at LinkedIn. Once you have an account, you will need to create an application in the LinkedIn Developer Portal.

Developer Portal

On the Developer Portal, you will need to create an application. You can do this by clicking on the Create App button in the center on of the page. You will need to fill out the form with the following information:

Field Description
App name * The name of your application
LinkedIn Page * This is the owner (company page) of the application
Privacy policy URL The URL to your privacy policy
App logo * The logo for your application
Legal agreement Read and accept that API Terms of Use

(*) Required

Once done, you should see a screen similar to the following:

LinkedIn AI App Settings

Client ID and Client Secret

Once you have an application, you will need to get the clientid and clientsecret for the application. You will need these to authenticate with the LinkedIn API. If you are going to be posting on behalf of a user, you will need to get an OAuth token for the user. If you are going to be posting on behalf of a company, you will need to get an OAuth token for the company. I won’t be covering the organizational OAuth token in this post.

Navigate to the OAuth 2.0 Tools page to begin. You can also get there from you App Information page by clicking on Docs and tools, then OAuth Token Tools.

LinkedIn AI OAuth 2.0 Tools page

  • Click on Create token

On the next page, you will need to select your application and either chose Member authorization code (3-legged) or Client credential (2-legged). I didn’t come up with the names. Basically, Member authorization is if you are going to be posting on behalf of the user, as I will in this post, and Client credential is if you are going to be building an application or interface where the user will be signing in and doing something with their data. For this post, I will be using Member authorization code (3-legged).

For more on the different types of OAuth 2.0 flows, see LinkedIn Authentication Overview.

You will need to select the scopes that you will need for your application. For this post, I will be using the following scopes:

  • r_liteprofile: Read basic profile information
  • w_member_social: Post, comment and like posts on behalf of the user

Click Request access token to continue.

Once you do this, you will be redirected to sign-in to LinkedIn to grant the access. Enter your credentials and click Sign In.

Now, you will have a screen with the access token and details about the token. You will need this access token to make calls to the LinkedIn API. You can copy the token by clicking on the Copy access token button.

LinkedIn AI OAuth 2.0 Tools page

I strongly recommend that you save this access token somewhere secure like Azure Key Vault. If someone gets a hold of it, they can post on behalf of the user. If you do lose it, you can always revoke it and create a new one.

You’ll notice that the access token expires in approximately two months. You will need to refresh the token before it expires. You will need to do this before the token expires. Details on how to do this are in the LinkedIn Authentication Overview. I won’t be covering that in this post.

LinkedIn Manager

Now that we have the access token, we can start posting to LinkedIn. I created a class called LinkedInManager to handle all of the calls to the LinkedIn API. You check get the source in the LinkedIn API Manager GitHub Repository. I will be using the LinkedIn API to get the user’s LinkedIn ID and the Share on LinkedIn API to post the share.

Get User Info

All of the API calls to post a share on LinkedIn require the user’s LinkedIn ID. You can get this by making a call to the Profile API. You will need to make a GET call to the me endpoint, as shown below as an http request.

1
2
GET https://api.linkedin.com/v2/me
Authorization: Bearer {{my-access-token}}

In this http request, as well as the future requests in this posts, you will need to replace {{my-access-token}} with the access token you received from the OAuth 2.0 Tools page. There will likely be another variable in future https requests, {{my-person-id}}. This will be the LinkedIn ID you get from the me endpoint.

In the LinkedInManager class, I created a method called GetUser to make this call. The method is shown below.

1
2
3
4
5
6
7
8
9
 public async Task<LinkedInUser> GetMyLinkedInUserProfile(string accessToken)
{
    if (string.IsNullOrEmpty(accessToken))
    {
        throw new ArgumentNullException(nameof(accessToken));
    }
    
    return await ExecuteGetAsync<LinkedInUser>(LinkedInUserUrl, accessToken);
}

The ExecuteGetAsync method is a helper method that makes the call to the LinkedIn API. The method is shown below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private async Task<T> ExecuteGetAsync<T>(string url, string accessToken)
{
    _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
    _httpClient.DefaultRequestHeaders.Add ("Authorization", $"Bearer {accessToken}");
    var response = await _httpClient.GetAsync(url);
    if (response.StatusCode != HttpStatusCode.OK)
        throw new HttpRequestException(
            $"Invalid status code in the HttpResponseMessage: {response.StatusCode}.");
        
    // Parse the Results
    var content = await response.Content.ReadAsStringAsync();
            
    var options = new JsonSerializerOptions
    {
        PropertyNameCaseInsensitive = true,
    };

    var results = JsonSerializer.Deserialize<T>(content, options);

    if (results == null)
    {
        throw new HttpRequestException(
            $"Unable to deserialize the response from the HttpResponseMessage: {content}.");
    }

    return results;
}

You’ll notice that I am using the HttpClient class to make the call to the LinkedIn API. I am also using the System.Text.Json library to deserialize the response from the API call. You can use any library you want to make the call and deserialize the response.

On line 3 and 4, the required headers are set. The Accept header is set to application/json and the Authorization header is set to the access token.

Line 5 makes the call to the LinkedIn API. If the response is not OK, an exception is thrown.

Line 11 reads the response from the HTTP call.

Line 18 deserializes the response from the API call. If the response is null, an exception is thrown.

Upon success, the LinkedInUser object is returned. The LinkedInUser object is shown below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class LinkedInUser
{
    [JsonPropertyName("id")]
    public string Id { get; set; }

    [JsonPropertyName("profilePicture")]
    public ProfilePicture ProfilePicture { get; set; }
    
    [JsonPropertyName("vanityName")]
    public string VanityName { get; set; }
    
    [JsonPropertyName("localizedFirstName")]
    public string FirstName { get; set; }
    
    [JsonPropertyName("localizedLastName")]
    public string LastName { get; set; }
    
    [JsonPropertyName("localizedHeadline")]
    public string Headline { get; set; }
    
    [JsonPropertyName("firstName")]
    public LocalizedInformation LocalizedFirstName { get; set; }
    
    [JsonPropertyName("lastName")]
    public LocalizedInformation LocalizedLastName { get; set; }
    
    [JsonPropertyName("headline")]
    public LocalizedInformation LocalizedHeadline { get; set; }
}

Note: Not all of these fields will be filled. What is filled is based on the scope of for your OAuth token. The Id property is the only property that we use. Note, if you have the r_liteprofile scope, you will get all of the properties but VanityName.

The Id property of the LinkedInUser object, as well as other identifiers in the API, follows the Universal Resource Name URN internet standard. The format is urn:li:person:<person-id>, where <person-id> is the identifier for the person. For most calls, you will need the full URN. In the LinkedInManager class, you only need the <person-id> portion of the URN.

For more on the URNs and IDs in LinkedIn, see the LinkedIn URNs and IDs page.

Share on LinkedIn

The Share API on LinkedIn provides the different ways to create shares or posts on LinkedIn; plain text, text with a link, and text with an image. I will show you how to create each of these types of shares.

Post Plain Text

To post plain text, you will need to make a POST call to the ugcPosts endpoint. The call to the ugcPosts endpoint is shown below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
POST https://api.linkedin.com/v2/ugcPosts
Authorization: Bearer {{my-access-token}}
X-Restli-Protocol-Version: 2.0.0

{
  "author": "urn:li:person:{{my-person-id}}",
  "lifecycleState": "PUBLISHED",
  "specificContent": {
    "com.linkedin.ugc.ShareContent": {
      "shareCommentary": {
        "text": "Please ignore this post. This is a test post. It will be deleted shortly.}"
      },
      "shareMediaCategory": "NONE"
    }
  },
  "visibility": {
    "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
  }
}

A few things to note about this call. We introduced a new header X-Restli-Protocol-Version. This header is required for all POST calls. The value of the header is 2.0.0.

In the LinkedIn API Manager, as call is made to PostShareText. The method is shown below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public async Task<string> PostShareText(string accessToken, string authorId, string postText)
{
    // Validation removed for brevity
    var shareRequest = new ShareRequest
    {
        Author = string.Format(LinkedInAuthorUrn, authorId),
        Visibility = new Visibility { VisibilityEnum = VisibilityEnum.Anyone },
        SpecificContent = new SpecificContent
        {
            ShareContent = new ShareContent
            {
                ShareCommentary = new TextProperties()
                {
                    Text = postText
                },
                ShareMediaCategoryEnum = ShareMediaCategoryEnum.None
            }
        }
    };
    
    var linkedInResponse = await CallPostShareUrl(accessToken, shareRequest);
    if (linkedInResponse is { IsSuccess: true, Id: not null })
    {
        return linkedInResponse.Id;
    }
    throw new HttpRequestException($"Failed to post status update to LinkedIn: LinkedIn Status Code: '{linkedInResponse.ServiceErrorCode}', LinkedIn Message: '{linkedInResponse.Message}'");
}

We start off by creating a ShareRequest object. Now, in my implementation I have made a view assumption like setting the Visibility to Anyone. You might want to change that in your implementation. Similar to the GetMyLinkedInProfile call, I use a helper method for all of the POST calls. This was helpful for a few reasons, the first, I was repeated code, but more importantly, the serialization and deserialization of the request and response for the LinkedIn API was very particular. The helper method is shown below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private async Task<ShareResponse> CallPostShareUrl(string accessToken, ShareRequest shareRequest)
{
    // Validation removed for brevity
    
    HttpRequestMessage requestMessage = new HttpRequestMessage(HttpMethod.Post, LinkedInPostUrl);
    requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
    requestMessage.Headers.Add ("X-Restli-Protocol-Version", "2.0.0");
    
    JsonSerializerOptions jsonSerializationOptions = new(JsonSerializerDefaults.Web)
    {
        WriteIndented = false,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    };
    var jsonRequest = JsonSerializer.Serialize(shareRequest, jsonSerializationOptions);  
    var jsonContent = new StringContent(jsonRequest, null, "application/json");
    requestMessage.Content = jsonContent;
    
    var response = await _httpClient.SendAsync(requestMessage);
    
    var content = await response.Content.ReadAsStringAsync();
    var options = new JsonSerializerOptions
    {
        PropertyNameCaseInsensitive = true
    };
    var linkedInResponse = JsonSerializer.Deserialize<ShareResponse>(content, options);
    
    if (linkedInResponse == null)
    {
        // TODO: Custom Exception
        throw new HttpRequestException(
            $"Unable to deserialize the response from the HttpResponseMessage: {content}.");
    }

    return linkedInResponse;
}

Lines 5-7, I prepare the http call and headers.

Line 9 - 16, I send the JsonSerializationOptions. Take note of the DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull setting, without this calls to the LinkedIn API will fail with either a 400 or 422 error.

Line 18, I send the request.

Line 20 -25, I deserialize the response, and if successful, return the ShareResponse object.

The ShareResponse object, shown below, is used to deserialize the response from the LinkedIn API. Only the Id property is provided upon a successful call to the LinkedIn API. If the call fails, the Message, ServiceErrorCode, and Status properties will be populated.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ShareResponse
{
    [JsonPropertyName("message")]
    public string? Message { get; set; }
    
    [JsonPropertyName("serviceErrorCode")]
    public int? ServiceErrorCode { get; set; }
    
    [JsonPropertyName("status")]
    public int? Status { get; set; }
    
    [JsonPropertyName("id")]
    public string? Id { get; set; }
    
    public bool IsSuccess => !string.IsNullOrEmpty(Id);
}

You can do with the Id property as you like, this is LinkedIn’s unique identifier for the post.

Post Text with Link

The post text with link API is very similar to the post text API. The only difference is the addition of media object in the com.linkedin.ugc.ShareContent section of request. Here is the sample request.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
POST https://api.linkedin.com/v2/ugcPosts
Authorization: Bearer {{my-access-token}}
X-Restli-Protocol-Version: 2.0.0

{
    "author": "urn:li:person:{{my-person-id}}",
    "lifecycleState": "PUBLISHED",
    "specificContent": {
        "com.linkedin.ugc.ShareContent": {
            "shareCommentary": {
                "text": "LinkedIn has an AI Assisted Editor for posting articles."
            },
            "shareMediaCategory": "ARTICLE",
            "media": [
                {
                    "status": "READY",
                    "description": {
                        "text": "This is the description of the media."
                    },
                    "originalUrl": "https://www.josephguadagno.net/",
                    "title": {
                        "text": "Joseph Guadagno Website"
                    }
                }
            ]
        }
    },
    "visibility": {
        "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
    }
}

In the LinkedIn API Manager, a call is made to the PostTextWithLink method. The code is shown below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public async Task<string> PostShareTextAndLink(string accessToken, string authorId, string postText, string link, string? linkTitle = null, string? linkDescription = null)
{
    // Validation removed for brevity
    
    var shareRequest = new ShareRequest
    {
        Author = string.Format(LinkedInAuthorUrn, authorId),
        Visibility = new Visibility { VisibilityEnum = VisibilityEnum.Anyone },
        SpecificContent = new SpecificContent
        {
            ShareContent = new ShareContent
            {
                ShareCommentary = new TextProperties()
                {
                    Text = postText
                },
                ShareMediaCategoryEnum = ShareMediaCategoryEnum.Article
            }
        }
    };
    var media = new Media{OriginalUrl = link};
    if (!string.IsNullOrEmpty(linkDescription))
    {
        media.Description = new TextProperties {Text = linkDescription};
    }
    if (!string.IsNullOrEmpty(linkTitle))
    {
        media.Title = new TextProperties {Text = linkTitle};
    }
    shareRequest.SpecificContent.ShareContent.Media = new[] { media };
    
    var linkedInResponse = await CallPostShareUrl(accessToken, shareRequest);
    if (linkedInResponse is { IsSuccess: true, Id: not null })
    {
        return linkedInResponse.Id;
    }
    throw new HttpRequestException(BuildLinkedInResponseErrorMessage(linkedInResponse));

}

You’ll notice that most of the start of the code looks the same as the PostShareText method. However, on line 17 we see the ShareMediaCategoryEnum field to ShareMediaCategoryEnum.Article which creates a “Article”, a link with a posts.

On lines 21-30 we construct the Media object and add it to the ShareContent object.

Line 32, we call the CallPostShareUrl method and return the Id property if successful.

Post Text with Image

The post text with image API is very similar to the post text with link API. The only different is that we have to upload the image to LinkedIn. This is a three step process. Step one, is for us to notify LinkedIn that we want to add file. I know, it’s weird but I didn’t write the API, luckily, it’s all wrapped in the LinkedIn API Manager. Step two, is to upload the file to LinkedIn. Followed by step three, which is to post the share with the image.

Step One - Notify LinkedIn of the File

In order to upload an image to LinkedIn, we need to notify LinkedIn that we want to upload a file. This is done by calling the asset API and using the registerUpload method. Here is the sample request.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
POST https://api.linkedin.com/v2/assets?action=registerUpload
Authorization: Bearer {{my-access-token}}
X-Restli-Protocol-Version: 2.0.0

{
    "registerUploadRequest": {
        "recipes": [
            "urn:li:digitalmediaRecipe:feedshare-image"
        ],
        "owner": "urn:li:person:{{my-person-id}}",
        "serviceRelationships": [
            {
                "relationshipType": "OWNER",
                "identifier": "urn:li:userGeneratedContent"
            }
        ]
    }
}

The only dynamic part of this request is the owner field. This is the LinkedIn URN of the person who is uploading the image. Upon success, you will receive a response back similar to this

1
2
3
4
5
6
7
8
9
10
11
12
{
    "value": {
        "uploadMechanism": {
            "com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest": {
                "headers": {},
                "uploadUrl": "https://api.linkedin.com/mediaUpload/<LinkedInAssetId>/feedshare-uploadedImage/0?ca=vector_feedshare&cn=uploads&m=AQJbrN86Zm265gAAAWemyz2pxPSgONtBiZdchrgG872QltnfYjnMdb2j3A&app=1953784&sync=0&v=beta&ut=2H-IhpbfXrRow1"
            }
        },
        "mediaArtifact": "urn:li:digitalmediaMediaArtifact:(urn:li:digitalmediaAsset:<LinkedInAssetId>,urn:li:digitalmediaMediaArtifactClass:feedshare-uploadedImage)",
        "asset": "urn:li:digitalmediaAsset:<LinkedInAssetId>"
    }
}

<LinkedInAssetId> is a unique Asset Id that LinkedIn assigned. I replaced a real asset id in this response with <LinkedInAssetId> so no one deletes the object. :smile:

For the next step, we are going to need the uploadUrl from the response. We will use this to upload the image to LinkedIn. Will we also need the asset field for when we create the post on LinkedIn. Again, this is all wrapped up in the LinkedIn API Manager.

Step Two - Upload the Image

For this, I don’t have an http request to show you, if you want to play around with an http client you will need to use something like cUrl to upload the image. Sample cUrl command is shown below.

1
curl -i --upload-file {{path-to-image}} --header "Authorization: Bearer {{my-access-token}}" '{{uploadUrl}}'

Replace the following tokens

Token Value
{{path-to-image}} The path to the image you want to upload
{{my-access-token}} Your LinkedIn access token
{{uploadUrl}} The uploadUrl from the response of the registerUpload API call
Step Three - Post the Share

For the http request, it is almost the same as the post text and link. We only change the ShareMediaCategoryEnum to ShareMediaCategoryEnum.Image. Here is the sample request.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
POST https://api.linkedin.com/v2/ugcPosts
Authorization: Bearer {{my-access-token}}
X-Restli-Protocol-Version: 2.0.0

{
    "author": "urn:li:person:{{my-person-id}}",
    "lifecycleState": "PUBLISHED",
    "specificContent": {
        "com.linkedin.ugc.ShareContent": {
            "shareCommentary": {
                "text": "LinkedIn has an AI Assisted Editor for posting articles."
            },
            "shareMediaCategory": "IMAGE",
            "media": [
                {
                    "status": "READY",
                    "description": {
                        "text": "LinkedIn has an AI Assisted Editor for posting articles."
                    },
                    "media": "urn:li:digitalmediaAsset:D5622AQHqpGB5YNqcvg",
                    "originalUrl": "https://www.josephguadagno.net/2023/08/08/linkedin-now-has-an-ai-assisted-editor-for-post",
                    "title": {
                        "text": "LinkedIn has an AI Assisted Editor for Post"
                    }
                }
            ]
        }
    },
    "visibility": {
        "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
    }
}

Mostly everything is the same as the previous call, however, here we set the media to the asset field we received when we made the upload request.

In the LinkedIn API Manager, the call looks like this.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public async Task<string> PostShareTextAndImage(string accessToken, string authorId, string postText, byte[] image, string? imageTitle = null, string? imageDescription = null)
{
    // Validation removed for brevity
    
    // Call the Register Image endpoint to get the Asset URN
    var uploadResponse = await GetUploadResponse(accessToken, authorId);
    
    // Upload the image
    var uploadUrl = uploadResponse.Value.UploadMechanism.MediaUploadHttpRequest.UploadUrl;
    var wasFileUploadSuccessful = await UploadImage(accessToken, uploadUrl, image);

    if (!wasFileUploadSuccessful)
    {
        throw new ApplicationException("Failed to upload image to LinkedIn");
    }
    
    // Send the image via PostShare
    var shareRequest = new ShareRequest
    {
        Author = string.Format(LinkedInAuthorUrn, authorId),
        Visibility = new Visibility { VisibilityEnum = VisibilityEnum.Anyone },
        SpecificContent = new SpecificContent
        {
            ShareContent = new ShareContent
            {
                ShareCommentary = new TextProperties()
                {
                    Text = postText
                },
                ShareMediaCategoryEnum = ShareMediaCategoryEnum.Image
            }
        }
    };
    
    var media = new Media{MediaUrn = uploadResponse.Value.Asset};
    
    if (!string.IsNullOrEmpty(imageDescription))
    {
        media.Description = new TextProperties {Text = imageDescription};
    }
    if (!string.IsNullOrEmpty(imageTitle))
    {
        media.Title = new TextProperties {Text = imageTitle};
    }
    shareRequest.SpecificContent.ShareContent.Media = new[] { media };
    
    var linkedInResponse = await CallPostShareUrl(accessToken, shareRequest);
    if (linkedInResponse is { IsSuccess: true, Id: not null })
    {
        return linkedInResponse.Id;
    }
    throw new HttpRequestException(BuildLinkedInResponseErrorMessage(linkedInResponse));
}

On line 6, the request is made to upload the file via the GetUploadResponse method, which returns a UploadRegistrationResponse upon success, otherwise it throws an exception.

On lines 9 and 10 we upload the file via the UploadImage method, which returns a true upon success, otherwise a failure.

The rest of code is the same as the previous example.

You can view this methods in the LinkedInApiManager.cs file.

Wrap Up

That was a lot to take in. We covered how to post text, links, and images to LinkedIn. We also covered how to get the access token. We also covered how to get the user’s profile information. I hope you found this useful as well as the break down of calls required to call the API.

If you have any questions, please feel free to reach out to me on Twitter, LinkedIn, or via the contact.

References