LinkedIn API Posting Guide 2026

The LinkedIn API allows you to programmatically create posts on personal profiles and organization pages, share images and articles, and manage your professional presence. This guide covers everything you need to know about integrating with LinkedIn's posting API, from OAuth authentication through publishing rich content.

LinkedIn's API has a reputation for being complex and tightly controlled. This is partly deserved. The approval process is strict, the documentation is sprawling, and the data structures are verbose. But once you understand the patterns, it is consistent and reliable. This tutorial walks through every step with working code examples.

Prerequisites and API Access

Before you can post via the LinkedIn API, you need:

LinkedIn's API access is approval-based. The Share on LinkedIn product is generally approved quickly (within a few days), but the Community Management API requires a more detailed application explaining your use case. Marketing Developer Platform access (for ads and analytics) requires a separate application.

OAuth 2.0 Authentication

LinkedIn uses a standard OAuth 2.0 authorization code flow. There is no PKCE requirement, but you must use a client secret for the token exchange.

Step 1: Build the Authorization URL

const authUrl = 'https://www.linkedin.com/oauth/v2/authorization'
  + '?response_type=code'
  + '&client_id=YOUR_CLIENT_ID'
  + '&redirect_uri=https://yourapp.com/callback'
  + '&state=random_csrf_token'
  + '&scope=openid%20profile%20email%20w_member_social';

// Redirect user to authUrl

Key scopes for posting:

Step 2: Exchange Code for Access Token

app.get('/callback', async (req, res) => {
  const { code, state } = req.query;

  if (state !== expectedState) {
    return res.status(403).send('Invalid state parameter');
  }

  const tokenRes = await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      redirect_uri: 'https://yourapp.com/callback',
      client_id: 'YOUR_CLIENT_ID',
      client_secret: 'YOUR_CLIENT_SECRET',
    }),
  });

  const { access_token, expires_in, refresh_token, refresh_token_expires_in }
    = await tokenRes.json();

  // access_token expires in 60 days (expires_in = 5184000)
  // refresh_token expires in 365 days
  // Store both securely
});

Step 3: Refresh Tokens

LinkedIn access tokens last 60 days, which is generous compared to other platforms. When they expire, use the refresh token:

async function refreshLinkedInToken(refreshToken) {
  const res = await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: 'YOUR_CLIENT_ID',
      client_secret: 'YOUR_CLIENT_SECRET',
    }),
  });

  const { access_token, expires_in, refresh_token: newRefreshToken }
    = await res.json();

  return { accessToken: access_token, refreshToken: newRefreshToken };
}

Getting the Author URN

Before creating posts, you need the authenticated user's URN (Uniform Resource Name), which serves as the author identifier. Use the userinfo endpoint:

async function getLinkedInUser(accessToken) {
  const res = await fetch('https://api.linkedin.com/v2/userinfo', {
    headers: { 'Authorization': `Bearer ${accessToken}` },
  });

  const profile = await res.json();
  // profile.sub is the member ID
  // Author URN format: "urn:li:person:{sub}"
  return {
    id: profile.sub,
    name: profile.name,
    email: profile.email,
    authorUrn: `urn:li:person:${profile.sub}`,
  };
}

Creating Text Posts

LinkedIn uses the Posts API (which supersedes the older UGC and Shares APIs). Here is how to create a basic text post:

async function createLinkedInPost(accessToken, authorUrn, text) {
  const res = await fetch('https://api.linkedin.com/rest/posts', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
      'LinkedIn-Version': '202401',
      'X-Restli-Protocol-Version': '2.0.0',
    },
    body: JSON.stringify({
      author: authorUrn,
      commentary: text,
      visibility: 'PUBLIC',
      distribution: {
        feedDistribution: 'MAIN_FEED',
        targetEntities: [],
        thirdPartyDistributionChannels: [],
      },
      lifecycleState: 'PUBLISHED',
      isReshareDisabledByAuthor: false,
    }),
  });

  if (res.status === 201) {
    // Success — post ID is in the x-restli-id header
    const postUrn = res.headers.get('x-restli-id');
    return postUrn;
  }

  const error = await res.json();
  throw new Error(`LinkedIn post failed: ${JSON.stringify(error)}`);
}

// Usage
const postUrn = await createLinkedInPost(
  token,
  'urn:li:person:abc123',
  'Just launched a new feature! Here is what it does and why it matters for your workflow...'
);

Important: LinkedIn requires the LinkedIn-Version header with every API request. This is a date-based version string that determines which API behavior you get. Use a recent version and test your integration when updating it.

Posts with Link Previews

To share a URL with a link preview card, include an article content block:

const res = await fetch('https://api.linkedin.com/rest/posts', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
    'LinkedIn-Version': '202401',
    'X-Restli-Protocol-Version': '2.0.0',
  },
  body: JSON.stringify({
    author: authorUrn,
    commentary: 'We just published a deep-dive on social media automation.',
    visibility: 'PUBLIC',
    distribution: {
      feedDistribution: 'MAIN_FEED',
      targetEntities: [],
      thirdPartyDistributionChannels: [],
    },
    content: {
      article: {
        source: 'https://example.com/blog/social-media-automation',
        title: 'The Complete Guide to Social Media Automation',
        description: 'Everything you need to know about automating your social presence.',
      },
    },
    lifecycleState: 'PUBLISHED',
    isReshareDisabledByAuthor: false,
  }),
});

Image Posts

Sharing images on LinkedIn is a multi-step process: register the image upload, upload the binary, then create the post referencing the uploaded image.

Step 1: Register the Upload

async function registerImageUpload(accessToken, authorUrn) {
  const res = await fetch('https://api.linkedin.com/rest/images?action=initializeUpload', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
      'LinkedIn-Version': '202401',
      'X-Restli-Protocol-Version': '2.0.0',
    },
    body: JSON.stringify({
      initializeUploadRequest: {
        owner: authorUrn,
      },
    }),
  });

  const { value } = await res.json();
  return {
    uploadUrl: value.uploadUrl,
    imageUrn: value.image,
  };
}

Step 2: Upload the Image Binary

const fs = require('fs');

async function uploadImageBinary(uploadUrl, accessToken, filePath) {
  const imageData = fs.readFileSync(filePath);

  const res = await fetch(uploadUrl, {
    method: 'PUT',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/octet-stream',
    },
    body: imageData,
  });

  if (!res.ok) {
    throw new Error(`Image upload failed: ${res.status}`);
  }

  return true;
}

Step 3: Create the Post with Image

async function createImagePost(accessToken, authorUrn, text, imageUrn) {
  const res = await fetch('https://api.linkedin.com/rest/posts', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
      'LinkedIn-Version': '202401',
      'X-Restli-Protocol-Version': '2.0.0',
    },
    body: JSON.stringify({
      author: authorUrn,
      commentary: text,
      visibility: 'PUBLIC',
      distribution: {
        feedDistribution: 'MAIN_FEED',
        targetEntities: [],
        thirdPartyDistributionChannels: [],
      },
      content: {
        media: {
          title: 'Uploaded image',
          id: imageUrn,
        },
      },
      lifecycleState: 'PUBLISHED',
      isReshareDisabledByAuthor: false,
    }),
  });

  const postUrn = res.headers.get('x-restli-id');
  return postUrn;
}

// Full flow
const { uploadUrl, imageUrn } = await registerImageUpload(token, authorUrn);
await uploadImageBinary(uploadUrl, token, './product-screenshot.png');
const postUrn = await createImagePost(token, authorUrn, 'New feature screenshot!', imageUrn);

Image requirements:

Multi-Image Posts

// After uploading multiple images and getting their URNs:
const imageUrns = ['urn:li:image:abc', 'urn:li:image:def', 'urn:li:image:ghi'];

const res = await fetch('https://api.linkedin.com/rest/posts', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
    'LinkedIn-Version': '202401',
    'X-Restli-Protocol-Version': '2.0.0',
  },
  body: JSON.stringify({
    author: authorUrn,
    commentary: 'Here are three product screenshots from our latest release.',
    visibility: 'PUBLIC',
    distribution: {
      feedDistribution: 'MAIN_FEED',
      targetEntities: [],
      thirdPartyDistributionChannels: [],
    },
    content: {
      multiImage: {
        images: imageUrns.map(urn => ({
          id: urn,
          altText: 'Product screenshot',
        })),
      },
    },
    lifecycleState: 'PUBLISHED',
    isReshareDisabledByAuthor: false,
  }),
});

Posting to Organization Pages

To post on behalf of a company page, you need the w_organization_social scope and the organization URN. The authenticated user must be an admin of the organization page.

// Get the organizations the user administers
async function getOrganizations(accessToken) {
  const res = await fetch(
    'https://api.linkedin.com/rest/organizationAcls'
    + '?q=roleAssignee&role=ADMINISTRATOR&state=APPROVED',
    {
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'LinkedIn-Version': '202401',
        'X-Restli-Protocol-Version': '2.0.0',
      },
    }
  );

  const { elements } = await res.json();
  return elements.map(el => el.organization);
  // Returns array like ['urn:li:organization:12345']
}

// Post as the organization
const orgUrn = 'urn:li:organization:12345';

const res = await fetch('https://api.linkedin.com/rest/posts', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
    'LinkedIn-Version': '202401',
    'X-Restli-Protocol-Version': '2.0.0',
  },
  body: JSON.stringify({
    author: orgUrn, // Use org URN instead of person URN
    commentary: 'Company update posted via the LinkedIn API.',
    visibility: 'PUBLIC',
    distribution: {
      feedDistribution: 'MAIN_FEED',
      targetEntities: [],
      thirdPartyDistributionChannels: [],
    },
    lifecycleState: 'PUBLISHED',
    isReshareDisabledByAuthor: false,
  }),
});

Document Posts (PDFs and Carousels)

LinkedIn's document posts (often used for carousel-style content) follow a similar upload pattern to images:

// Step 1: Register document upload
const regRes = await fetch(
  'https://api.linkedin.com/rest/documents?action=initializeUpload',
  {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
      'LinkedIn-Version': '202401',
      'X-Restli-Protocol-Version': '2.0.0',
    },
    body: JSON.stringify({
      initializeUploadRequest: { owner: authorUrn },
    }),
  }
);

const { value: { uploadUrl, document: documentUrn } } = await regRes.json();

// Step 2: Upload the PDF
const pdfData = fs.readFileSync('./carousel.pdf');
await fetch(uploadUrl, {
  method: 'PUT',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/octet-stream',
  },
  body: pdfData,
});

// Step 3: Create post with document
await fetch('https://api.linkedin.com/rest/posts', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
    'LinkedIn-Version': '202401',
    'X-Restli-Protocol-Version': '2.0.0',
  },
  body: JSON.stringify({
    author: authorUrn,
    commentary: 'Swipe through our latest product update.',
    visibility: 'PUBLIC',
    distribution: {
      feedDistribution: 'MAIN_FEED',
      targetEntities: [],
      thirdPartyDistributionChannels: [],
    },
    content: {
      media: { title: 'Product Update Q1 2026', id: documentUrn },
    },
    lifecycleState: 'PUBLISHED',
    isReshareDisabledByAuthor: false,
  }),
});

Rate Limits

LinkedIn's API rate limits are per-application and per-member:

Rate limits reset at midnight UTC. If you exceed them, you receive a 429 Too Many Requests response.

Common Errors and Troubleshooting

Error 401: "Invalid access token"

Your access token has expired (60-day lifespan) or was revoked. Use your refresh token to obtain a new access token. If the refresh token has also expired (365 days), the user must re-authorize.

Error 403: "Access denied to author"

You are trying to post with an author URN that the current access token does not have permission for. This commonly happens when trying to post as an organization without w_organization_social scope or when the user is not an admin of the organization.

Error 422: "Invalid field value"

The request body contains an invalid value. Common causes: malformed URNs, missing required fields, or incorrect visibility settings. Double-check the author URN format (urn:li:person:ID or urn:li:organization:ID).

Error: "Content too long"

LinkedIn post text has a 3,000-character limit. The API returns a 422 error if your commentary field exceeds this. Truncate or split long content before posting.

Error: "Image upload URL expired"

The upload URL returned by the initialize upload endpoint is time-limited. You typically have a few minutes to complete the upload. If it expires, register a new upload and get a fresh URL.

Error: Missing LinkedIn-Version header

The REST API requires the LinkedIn-Version header on every request. Omitting it results in a 400 error. Use a format like 202401 (YYYYMM). Check LinkedIn's changelog for the latest supported version.

Best Practices for Production

Complete Working Example

class LinkedInClient {
  constructor(clientId, clientSecret) {
    this.clientId = clientId;
    this.clientSecret = clientSecret;
    this.baseUrl = 'https://api.linkedin.com/rest';
    this.version = '202401';
  }

  headers(accessToken) {
    return {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
      'LinkedIn-Version': this.version,
      'X-Restli-Protocol-Version': '2.0.0',
    };
  }

  async createPost(accessToken, { authorUrn, text, imageUrn, articleUrl }) {
    const body = {
      author: authorUrn,
      commentary: text,
      visibility: 'PUBLIC',
      distribution: {
        feedDistribution: 'MAIN_FEED',
        targetEntities: [],
        thirdPartyDistributionChannels: [],
      },
      lifecycleState: 'PUBLISHED',
      isReshareDisabledByAuthor: false,
    };

    if (imageUrn) {
      body.content = { media: { title: 'Image', id: imageUrn } };
    } else if (articleUrl) {
      body.content = { article: { source: articleUrl } };
    }

    const res = await fetch(`${this.baseUrl}/posts`, {
      method: 'POST',
      headers: this.headers(accessToken),
      body: JSON.stringify(body),
    });

    if (res.status === 201) {
      return res.headers.get('x-restli-id');
    }

    const error = await res.json();
    throw new Error(`LinkedIn post failed (${res.status}): ${JSON.stringify(error)}`);
  }

  async uploadImage(accessToken, authorUrn, imageBuffer) {
    // Register
    const regRes = await fetch(
      `${this.baseUrl}/images?action=initializeUpload`,
      {
        method: 'POST',
        headers: this.headers(accessToken),
        body: JSON.stringify({ initializeUploadRequest: { owner: authorUrn } }),
      }
    );
    const { value } = await regRes.json();

    // Upload binary
    await fetch(value.uploadUrl, {
      method: 'PUT',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Content-Type': 'application/octet-stream',
      },
      body: imageBuffer,
    });

    return value.image; // image URN
  }
}

// Usage
const linkedin = new LinkedInClient('client_id', 'client_secret');

// Text post
const postUrn = await linkedin.createPost(token, {
  authorUrn: 'urn:li:person:abc123',
  text: 'Excited to share our latest product update!',
});

// Image post
const imageUrn = await linkedin.uploadImage(
  token,
  'urn:li:person:abc123',
  fs.readFileSync('./screenshot.png')
);
const imagePostUrn = await linkedin.createPost(token, {
  authorUrn: 'urn:li:person:abc123',
  text: 'Here is a preview of our new dashboard.',
  imageUrn,
});

Skip the API Complexity with Kleo

LinkedIn's API requires managing versioned headers, multi-step image uploads, organization permissions, and 60-day token lifecycles. For a founder or small team, building and maintaining this integration is a significant ongoing commitment.

Kleo handles all of this for you. Connect your LinkedIn profile or company page in one click, generate posts with AI that learns your professional voice, and schedule them alongside your X, Threads, and Bluesky content. No API keys, no URN management, no version headers.

Post to LinkedIn without the API overhead

Kleo handles OAuth, image uploads, organization pages, and scheduling. Just write and publish.

Get Started with Kleo