Ir para o conteúdo
Backend

LinkedIn API com JavaScript: Autenticação, Posts e Métricas sem SDK Oficial

Marcos Soares
Atualizado em 
13 minutos de leitura
Ilustracao 3D de portal translucido com condutos bioluminescentes verdes representando integracao com API do LinkedIn
Ouça este artigo
0:00LinkedIn API com JavaScript: Autenticação, Posts e Métricas sem SDK Oficial--:--

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.

Neste artigo

O problema: a API do LinkedIn é hostil a quem vem do ecossistema JavaScript

A maioria das APIs de redes sociais oferece SDK oficial em JavaScript. O LinkedIn não. A documentação oficial mistura endpoints da API v1 (descontinuada), v2 (atual) e da Community Management API (lançada em 2023), sem distinção clara entre eles. O resultado: devs perdem horas testando endpoints que retornam 403 sem explicação, tokens que expiram sem refresh, e escopos que mudaram de nome entre versões.

Este post resolve três operações concretas: autenticar via OAuth 2.0, publicar posts programaticamente e ler métricas de engajamento. Tudo com fetch nativo, TypeScript e sem dependência de SDK de terceiros.

Se você precisa de contexto sobre como estruturar a camada HTTP entre sua aplicação e APIs externas, o post sobre API Layers em aplicações fullstack cobre exatamente essa arquitetura.

Antes de escodar: o que você precisa no LinkedIn Developer Portal

A API do LinkedIn exige um "app" registrado no portal de desenvolvedores. Dois pontos que a documentação não deixa óbvio:

  1. Produtos determinam escopos. Você não escolhe escopos livremente. Precisa solicitar acesso ao produto "Share on LinkedIn" para publicar posts, e ao "Sign In with LinkedIn using OpenID Connect" para autenticação. Sem o produto aprovado, o escopo retorna erro silencioso no fluxo OAuth.

  2. Tokens de acesso duram 60 dias. Tokens de refresh duram 365 dias, mas só estão disponíveis para apps com o produto "Advertising API" aprovado. Para a maioria dos casos (publicação e leitura de perfil), você trabalha apenas com o access token de 60 dias e precisa implementar re-autenticação.

Produto LinkedInEscopos liberadosToken refresh disponível
Sign In with LinkedIn (OpenID Connect)openid, profile, emailNão
Share on LinkedInw_member_socialNão
Advertising APIr_ads, r_ads_reportingSim (365 dias)
Community Management APIw_member_social, r_organization_socialNão

Fluxo OAuth 2.0 passo a passo

O LinkedIn usa Authorization Code Flow. Nada de client credentials para ações em nome de usuários.

Passo 1: redirecionar o usuário para autorização

TypeScript
// linkedin-auth.ts
const LINKEDIN_AUTH_URL = "https://www.linkedin.com/oauth/v2/authorization";
 
interface LinkedInAuthParams {
  clientId: string;
  redirectUri: string;
  // state previne CSRF: gere um valor aleatório e valide no callback
  state: string;
}
 
function buildAuthorizationUrl(params: LinkedInAuthParams): string {
  const searchParams = new URLSearchParams({
    response_type: "code",
    client_id: params.clientId,
    redirect_uri: params.redirectUri,
    state: params.state,
    // escopos separados por espaço, não por vírgula
    scope: "openid profile email w_member_social",
  });
 
  return `${LINKEDIN_AUTH_URL}?${searchParams.toString()}`;
}

Passo 2: trocar o authorization code por access token

TypeScript
// linkedin-token.ts
interface TokenResponse {
  access_token: string;
  expires_in: number;
  scope: string;
}
 
async function exchangeCodeForToken(
  code: string,
  clientId: string,
  clientSecret: string,
  redirectUri: string
): Promise<TokenResponse> {
  const response = await fetch(
    "https://www.linkedin.com/oauth/v2/accessToken",
    {
      method: "POST",
      headers: {
        // LinkedIn exige form-urlencoded aqui, não JSON
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body: new URLSearchParams({
        grant_type: "authorization_code",
        code,
        client_id: clientId,
        client_secret: clientSecret,
        redirect_uri: redirectUri,
      }),
    }
  );
 
  if (!response.ok) {
    const error = await response.text();
    throw new Error(`LinkedIn token exchange failed: ${response.status} ${error}`);
  }
 
  return response.json() as Promise<TokenResponse>;
}

Um detalhe que causa confusão: o redirect_uri enviado na troca de token precisa ser idêntico ao usado na autorização. Caractere por caractere. Trailing slash diferente já causa invalid_redirect_uri.

Para lidar com retry e timeout nessa chamada HTTP, o padrão descrito em Fetch, Retry e Timeout se aplica diretamente.

Passo 3: obter o perfil do usuário autenticado

TypeScript
// linkedin-profile.ts
interface LinkedInProfile {
  sub: string; // identificador único do membro
  name: string;
  email: string;
  picture: string;
}
 
async function getProfile(accessToken: string): Promise<LinkedInProfile> {
  const response = await fetch("https://api.linkedin.com/v2/userinfo", {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  });
 
  if (!response.ok) {
    throw new Error(`Profile fetch failed: ${response.status}`);
  }
 
  return response.json() as Promise<LinkedInProfile>;
}

O endpoint /v2/userinfo é o correto para OpenID Connect. Muitos tutoriais ainda apontam para /v2/me, que funciona mas retorna um formato diferente e não inclui email sem escopo adicional.

Publicando posts via API

A publicação usa o endpoint /rest/posts da Community Management API. O header LinkedIn-Version é obrigatório e define qual versão da API você está chamando.

TypeScript
// linkedin-post.ts
interface CreatePostParams {
  accessToken: string;
  authorUrn: string; // formato: "urn:li:person:{sub}"
  text: string;
}
 
async function createTextPost(params: CreatePostParams): Promise<string> {
  const response = await fetch("https://api.linkedin.com/rest/posts", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${params.accessToken}`,
      "Content-Type": "application/json",
      // sem esse header, a API retorna 400 sem mensagem útil
      "LinkedIn-Version": "202401",
      "X-Restli-Protocol-Version": "2.0.0",
    },
    body: JSON.stringify({
      author: params.authorUrn,
      commentary: params.text,
      visibility: "PUBLIC",
      distribution: {
        feedDistribution: "MAIN_FEED",
        targetEntities: [],
        thirdPartyDistributionChannels: [],
      },
      lifecycleState: "PUBLISHED",
      isReshareDisabledByAuthor: false,
    }),
  });
 
  if (response.status === 201) {
    // o ID do post vem no header x-restli-id, não no body
    const postId = response.headers.get("x-restli-id");
    return postId ?? "created-without-id";
  }
 
  const error = await response.text();
  throw new Error(`Post creation failed: ${response.status} ${error}`);
}

Dois pontos que não estão claros na documentação:

  • O campo commentary é o texto do post. Não é text, não é message, não é content. É commentary.
  • O response de sucesso é 201 Created com body vazio. O identificador do post criado vem no header x-restli-id.

Publicando posts com imagem

Para posts com imagem, o fluxo tem três etapas: registrar o upload, enviar o binário, criar o post referenciando o asset.

TypeScript
// linkedin-image-upload.ts
interface InitializeUploadResponse {
  value: {
    uploadUrl: string;
    image: string; // URN da imagem para usar no post
  };
}
 
async function initializeImageUpload(
  accessToken: string,
  authorUrn: string
): Promise<InitializeUploadResponse["value"]> {
  const response = await fetch(
    "https://api.linkedin.com/rest/images?action=initializeUpload",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${accessToken}`,
        "Content-Type": "application/json",
        "LinkedIn-Version": "202401",
      },
      body: JSON.stringify({
        initializeUploadRequest: {
          owner: authorUrn,
        },
      }),
    }
  );
 
  if (!response.ok) {
    throw new Error(`Image upload init failed: ${response.status}`);
  }
 
  const data = (await response.json()) as InitializeUploadResponse;
  return data.value;
}
 
async function uploadImageBinary(
  uploadUrl: string,
  imageBuffer: ArrayBuffer
): Promise<void> {
  // o uploadUrl já contém autenticação embutida via query params
  // não envie o header Authorization aqui: causa 403
  const response = await fetch(uploadUrl, {
    method: "PUT",
    headers: {
      "Content-Type": "application/octet-stream",
    },
    body: imageBuffer,
  });
 
  if (!response.ok) {
    throw new Error(`Image binary upload failed: ${response.status}`);
  }
}

Depois do upload, crie o post referenciando o image URN retornado:

TypeScript
// linkedin-post-with-image.ts
async function createImagePost(
  accessToken: string,
  authorUrn: string,
  text: string,
  imageUrn: string
): Promise<string> {
  const response = 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,
      content: {
        media: {
          // altText é obrigatório pela spec mas ignorado por muitos devs
          altText: "Imagem do post",
          id: imageUrn,
        },
      },
    }),
  });
 
  if (response.status !== 201) {
    const error = await response.text();
    throw new Error(`Image post creation failed: ${response.status} ${error}`);
  }
 
  return response.headers.get("x-restli-id") ?? "created-without-id";
}

O que NÃO fazer

Anti-pattern 1: enviar Authorization no upload de imagem

TypeScript
// ERRADO: o uploadUrl do LinkedIn já tem credenciais na query string
const response = await fetch(uploadUrl, {
  method: "PUT",
  headers: {
    Authorization: `Bearer ${accessToken}`, // causa 403
    "Content-Type": "application/octet-stream",
  },
  body: imageBuffer,
});

O uploadUrl retornado pelo LinkedIn contém tokens temporários nos query parameters. Adicionar o header Authorization conflita com essas credenciais e retorna 403 Forbidden. A correção é remover o header.

Anti-pattern 2: usar escopos da API v1

TypeScript
// ERRADO: escopos descontinuados
const scope = "r_liteprofile r_emailaddress w_member_social";
TypeScript
// CORRETO: escopos da OpenID Connect + Community Management API
const scope = "openid profile email w_member_social";

r_liteprofile e r_emailaddress são escopos da API v1 que o LinkedIn descontinuou. Eles ainda aparecem em muitos tutoriais e respostas do Stack Overflow. Se você usá-los, o fluxo OAuth retorna sucesso mas o token não tem permissão real para nada.

Anti-pattern 3: ignorar o header LinkedIn-Version

TypeScript
// ERRADO: sem LinkedIn-Version
const response = await fetch("https://api.linkedin.com/rest/posts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${accessToken}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(postData),
});
// retorna 400 com mensagem genérica

A Community Management API (/rest/*) exige o header LinkedIn-Version. Sem ele, o erro retornado é um 400 genérico que não menciona o header ausente. Adicione "LinkedIn-Version": "202401" (ou a versão mais recente disponível na documentação).

Lendo métricas de engajamento

Para ler métricas de posts publicados, use o endpoint de social actions:

TypeScript
// linkedin-metrics.ts
interface PostMetrics {
  likes: number;
  comments: number;
  shares: number;
}
 
async function getPostMetrics(
  accessToken: string,
  postUrn: string
): Promise<PostMetrics> {
  // encode do URN porque contém ":" que precisa ser escapado na URL
  const encodedUrn = encodeURIComponent(postUrn);
 
  const [likesRes, commentsRes] = await Promise.all([
    fetch(
      `https://api.linkedin.com/v2/socialActions/${encodedUrn}/likes?count=0`,
      {
        headers: { Authorization: `Bearer ${accessToken}` },
      }
    ),
    fetch(
      `https://api.linkedin.com/v2/socialActions/${encodedUrn}/comments?count=0`,
      {
        headers: { Authorization: `Bearer ${accessToken}` },
      }
    ),
  ]);
 
  if (!likesRes.ok || !commentsRes.ok) {
    throw new Error("Failed to fetch post metrics");
  }
 
  const likesData = await likesRes.json();
  const commentsData = await commentsRes.json();
 
  return {
    // paging.total retorna o count sem precisar paginar todos os resultados
    likes: likesData.paging?.total ?? 0,
    comments: commentsData.paging?.total ?? 0,
    shares: 0, // shares não tem endpoint público separado na v2
  };
}

O truque do count=0 é intencional: você pede zero elementos mas o objeto paging.total retorna o total real. Isso evita transferir dados desnecessários quando você só quer a contagem.

Para automatizar a coleta periódica dessas métricas, um cron job simples resolve. O post sobre ferramentas open source para automação mostra padrões reutilizáveis para esse tipo de tarefa.

Matriz de decisão: qual abordagem de integração usar

CenárioAbordagemJustificativa
App web com login do usuárioOAuth Authorization Code (como descrito acima)Único fluxo que permite ações em nome do usuário
Bot que posta em página da empresaOAuth + armazenar token com re-auth a cada 60 diasSem refresh token disponível para a maioria dos produtos
Apenas leitura de perfil públicoOpenID Connect (openid profile email)Escopo mínimo, não precisa do produto "Share"
Automação pessoal (seu próprio perfil)OAuth manual uma vez + salvar tokenFunciona, mas exige re-autenticação a cada 60 dias
Volume alto de posts (mais de 100/dia)Solicitar acesso ao Marketing Developer PlatformRate limits padrão são 100 requests por dia por membro

A questão do rate limit merece atenção: o LinkedIn aplica limites por membro autenticado, não por app. Se sua aplicação gerencia múltiplas contas, cada conta tem seu próprio limite.

Armazenamento seguro de tokens

Tokens do LinkedIn devem ser tratados como credenciais sensíveis. Em uma aplicação Node.js, armazene-os criptografados no banco de dados, não em variáveis de ambiente (que são estáticas e não suportam múltiplos usuários).

O padrão descrito em Node.js Backend 2026 para gerenciamento de secrets se aplica aqui. Para a camada de dados, se você usa PostgreSQL, o post sobre connection pooling com PgBouncer cobre a infraestrutura necessária para manter essas queries performáticas.

Sobre o ciclo de vida do token: implemente um middleware que verifica expires_at antes de cada chamada à API. Se faltam menos de 24 horas para expirar, dispare o fluxo de re-autenticação proativamente em vez de esperar o 401.

Tratamento de erros específicos do LinkedIn

A API do LinkedIn retorna erros em formatos inconsistentes dependendo do endpoint. Alguns retornam JSON estruturado, outros retornam texto puro. Um wrapper defensivo:

TypeScript
// linkedin-error.ts
interface LinkedInApiError {
  status: number;
  message: string;
  serviceErrorCode?: number;
}
 
async function parseLinkedInError(
  response: Response
): Promise<LinkedInApiError> {
  const contentType = response.headers.get("content-type") ?? "";
 
  if (contentType.includes("application/json")) {
    const body = await response.json();
    return {
      status: response.status,
      message: body.message ?? body.error_description ?? JSON.stringify(body),
      serviceErrorCode: body.serviceErrorCode,
    };
  }
 
  // alguns endpoints retornam texto puro em vez de JSON no erro
  const text = await response.text();
  return {
    status: response.status,
    message: text || `HTTP ${response.status}`,
  };
}

Os serviceErrorCode mais comuns: 65600 significa escopo insuficiente, 100 significa recurso não encontrado (geralmente URN malformado), e 65604 indica que o membro não autorizou a ação específica.

Para entender como closures e protótipos afetam a forma como você estrutura esses wrappers de erro, o post sobre mecânicas do JavaScript dá o contexto necessário.

Posição editorial

A API do LinkedIn é uma das piores APIs de rede social para se integrar com JavaScript. Documentação fragmentada, ausência de SDK oficial, rate limits agressivos, tokens sem refresh para a maioria dos casos de uso. A decisão de usar essa API diretamente (em vez de ferramentas como Zapier ou Buffer) só faz sentido se você precisa de controle programático fino sobre o conteúdo publicado, ou se está construindo uma ferramenta SaaS que gerencia múltiplas contas.

Se seu caso é "quero agendar posts do meu perfil pessoal", use uma ferramenta pronta. Se seu caso é "preciso integrar publicação no LinkedIn dentro de um CMS ou plataforma de marketing", o investimento de implementar o fluxo OAuth e manter tokens vale a pena. A linha entre os dois cenários é: se você tem mais de uma conta para gerenciar, ou precisa de lógica condicional sobre quando e o que publicar, a integração direta compensa.

FAQ

O LinkedIn tem SDK oficial para Node.js ou JavaScript?

Não. Existem pacotes npm de terceiros como linkedin-api-client, mas nenhum é mantido pelo LinkedIn. A abordagem mais segura é usar fetch diretamente com os endpoints REST, como mostrado neste post. Pacotes de terceiros tendem a ficar desatualizados quando o LinkedIn muda versões da API.

Meu token expirou e o usuário precisa re-autenticar. Como faço isso sem fricção?

Armazene o expires_at (timestamp atual + expires_in retornado na troca de token) no banco de dados. Quando faltar menos de 7 dias para expirar, exiba um banner na interface pedindo re-autorização. O fluxo OAuth completo redireciona o usuário e retorna um novo token. Não há forma de renovar silenciosamente sem o produto Advertising API.

Posso publicar em uma Company Page via API?

Sim, mas precisa do produto "Community Management API" aprovado e do escopo w_organization_social. O author no payload do post muda de urn:li:person:{id} para urn:li:organization:{id}. O administrador da página precisa autorizar o app.

O rate limit de 100 requests por dia é por app ou por usuário?

Por membro autenticado, por app. Se sua app tem 50 usuários, cada um tem seu limite de 100 requests/dia. Se um único usuário autentica em dois apps diferentes, cada app tem 100 requests para aquele usuário. O LinkedIn documenta isso na seção "Rate Limiting" do Developer Portal, mas o número exato pode variar por produto.

A API v2 /v2/ugcPosts ainda funciona?

Funciona, mas está em processo de descontinuação. O LinkedIn recomenda migrar para /rest/posts com o header LinkedIn-Version. Se seu código atual usa ugcPosts, ele vai continuar funcionando por enquanto, mas novos campos e funcionalidades só são adicionados ao endpoint /rest/posts.

Marcos Soares

Escrito por

Marcos Soares

Fullstack Developer · CEO da Agência Poti

Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.

Comentários

Participe da discussão

Seja o primeiro a comentar!

Continue Aprofundando

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.