LinkedIn 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:
-
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.
-
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 LinkedIn | Escopos liberados | Token refresh disponível |
|---|---|---|
| Sign In with LinkedIn (OpenID Connect) | openid, profile, email | Não |
| Share on LinkedIn | w_member_social | Não |
| Advertising API | r_ads, r_ads_reporting | Sim (365 dias) |
| Community Management API | w_member_social, r_organization_social | Nã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
// 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
// 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
// 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.
// 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 Createdcom body vazio. O identificador do post criado vem no headerx-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.
// 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:
// 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
// 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
// ERRADO: escopos descontinuados
const scope = "r_liteprofile r_emailaddress w_member_social";// 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
// 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éricaA 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:
// 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ário | Abordagem | Justificativa |
|---|---|---|
| App web com login do usuário | OAuth Authorization Code (como descrito acima) | Único fluxo que permite ações em nome do usuário |
| Bot que posta em página da empresa | OAuth + armazenar token com re-auth a cada 60 dias | Sem refresh token disponível para a maioria dos produtos |
| Apenas leitura de perfil público | OpenID 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 token | Funciona, mas exige re-autenticação a cada 60 dias |
| Volume alto de posts (mais de 100/dia) | Solicitar acesso ao Marketing Developer Platform | Rate 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:
// 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.

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.


