Pular para o conteúdo
Node.js

Node.js: integrando ligações com a Letscall

Paloma Macetko
Ilustração de um unicórnio com fone de atendimento ligado por um fio de luz a um telefone mágico, ao lado de uma coruja entregando pergaminhos que representam eventos de webhook

Olá meus Unicórnios! 🦄✨

Sabe quando você olha a documentação de uma API de telefonia e pensa "ah, é só um POST para criar a ligação"? 😅 Pois é. Foi exatamente isso que eu pensei quando peguei uma integração com a Letscall para fazer — aquele tipo de central telefônica em que um operador tem um ramal e você quer que o sistema ligue esse ramal num telefone comum.

O POST realmente é um só. O que ninguém conta é que a resposta desse POST não te dá o identificador que você vai precisar para encerrar a ligação. Ele chega depois, por webhook, junto com uma fila de eventos que teimam em chegar repetidos e fora de ordem. 🤯

Neste artigo eu percorro o ciclo inteiro de uma ligação: criar, receber os eventos, encerrar e consultar os detalhes. Dois arquivos de Node.js puro — sem axios, sem Express, sem npm install. Só o fetch e o http que já vêm na caixa.

🗺️ O ciclo de uma ligação, do começo ao fim

Antes do código, o mapa. É ele que explica por que a integração precisa das duas metades — o cliente que chama a API e o servidor que escuta:

voce                     Letscall                  seu servidor
 |                          |                            |
 |-- cria a ligacao ------->|                            |
 |<-- request_id -----------|                            |
 |                          |                            |
 |                          |-- call_start ------------->|
 |                          |-- target_ringing --------->|  (tocando)
 |                          |-- call_established ------->|  (atendeu!)
 |                          |-- call_ended ------------->|  (acabou)
 |                          |                            |
 |-- encerra (session_id) ->|                            |
 |-- consulta detalhes ---->|                            |

Repare no detalhe cruel: você cria a ligação e recebe um request_id. Mas quem encerra a chamada é o session_id — e ele só aparece nos eventos, não na resposta da criação. Se o seu código só guardar o que o POST devolveu, você fica sem como desligar. 😳

🔑 O token que vence sem avisar

A autenticação é um POST com utilizador e senha, e devolve um access_token que vai num cabeçalho chamado, adivinhe, access_token (não é Authorization, não é Bearer — é o nome cru mesmo).

// A URL e as credenciais vem do ambiente. Nunca escreva a senha aqui:
// o arquivo vai para o git e a senha vai junto.
const BASE = process.env.LETSCALL_URL;
const USUARIO = process.env.LETSCALL_USUARIO;
const SENHA = process.env.LETSCALL_SENHA;

// O token vale poucos minutos. Guardo em memoria e renovo quando o
// servidor reclamar — nao adianta pedir um token novo a cada chamada.
let tokenAtual = "";

async function autenticar() {
  const resposta = await fetch(BASE + "/api/account/credentials/verify", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ name: USUARIO, password: SENHA }),
  });

  const dados = await resposta.json();

  if (!dados.access_token) {
    throw new Error("Falha ao autenticar: usuario ou senha recusados.");
  }

  tokenAtual = dados.access_token;
  return tokenAtual;
}

Agora a parte que me custou tempo. Esse token vence em poucos minutos, e quando vence a API não responde 401. Ela responde HTTP 200 com um corpo dizendo err_code: 1043002. 🙃

Isso significa que um if (resposta.ok) ingênuo passa batido: você acha que deu tudo certo, mas a ligação não foi criada. O sintoma é maldoso — a integração funciona lindamente por uns minutos depois de subir e começa a falhar sozinha, sem que ninguém tenha mexido em nada.

A correção é tratar esse código de erro antes de qualquer outra coisa: renovar o token e repetir a chamada uma vez.

// Toda chamada passa por aqui. O detalhe que importa esta no fim:
// err_code 1043002 quer dizer "token vencido", e ai vale a pena
// autenticar de novo e repetir UMA vez. Sem esse retry, a integracao
// funciona por alguns minutos e depois comeca a falhar sozinha.
async function chamarApi(metodo, caminho, corpo, jaTentou) {
  if (tokenAtual === "") {
    await autenticar();
  }

  const opcoes = {
    method: metodo,
    headers: {
      "content-type": "application/json",
      access_token: tokenAtual,
    },
  };

  if (corpo) {
    opcoes.body = JSON.stringify(corpo);
  }

  const resposta = await fetch(BASE + caminho, opcoes);
  const texto = await resposta.text();

  let dados = {};
  if (texto !== "") {
    dados = JSON.parse(texto);
  }

  if (dados.err_code === 1043002 && !jaTentou) {
    // Token vencido: renova e repete uma unica vez.
    // O "jaTentou" evita o loop infinito quando a senha esta errada.
    await autenticar();
    return chamarApi(metodo, caminho, corpo, true);
  }

  if (!resposta.ok) {
    const motivo = dados.msg || "erro " + resposta.status;
    throw new Error("A Letscall recusou " + caminho + ": " + motivo);
  }

  return dados;
}

O jaTentou não é preciosismo. Sem ele, uma senha errada faz o código entrar num laço infinito: falha com 1043002, renova, falha de novo, renova de novo… para sempre. Esse parâmetro é o que transforma "tenta de novo" em "tenta de novo uma vez".

📞 Criando a ligação

Aqui está o coração da coisa, e é surpreendentemente pequeno:

// Cria a ligacao entre um ramal e um telefone.
// ATENCAO: quem toca primeiro e o ramal (call_from). So quando o
// operador atende e que o telefone do outro lado comeca a tocar.
async function criarLigacao(ramal, telefone, bina) {
  const dados = await chamarApi("POST", "/api/call_sessions/create", {
    call_from: ramal,
    call_to: [telefone], // sempre lista, mesmo com um numero so
    sendsdp: true,
    outbound_caller_id: bina,
  });

  if (!dados.request_id) {
    throw new Error("A Letscall nao devolveu request_id.");
  }

  // Repare: aqui so existe request_id. O session_id, que e o que
  // encerra a ligacao, so chega depois, pelo webhook. Guardar so o
  // request_id agora e o certo.
  return dados.request_id;
}

Três coisas para reparar:

O call_to é uma lista, mesmo quando você liga para um número só. Mandar a string solta faz a API recusar.

Quem toca primeiro é o ramal. Isso me pegou de surpresa: eu esperava que o telefone do cliente tocasse e, ao atender, chamasse o operador. É o contrário. A central liga primeiro para o call_from (o ramal) e, só depois que o operador atende, o telefone do outro lado começa a tocar. Faz sentido quando você pensa — não adianta acordar o cliente se não tem ninguém para falar com ele.

O outbound_caller_id é o bina, o número que aparece no visor de quem recebe. Sem ele, sai o número padrão da conta, que costuma não ser o que você quer.

🦉 O webhook, onde a coisa fica interessante

Se você só for ler um pedaço deste artigo, leia este. 🙏 É aqui que moram as armadilhas que realmente quebram integração em produção.

Antes do código, o erro nº 1 de quem está começando — e eu já cometi: o webhook precisa de uma URL pública. Você configura http://localhost:3000/webhook no painel, testa, e não chega absolutamente nada. Óbvio em retrospecto: quem faz o POST é o servidor da Letscall, lá do datacenter dele. O localhost dele é ele mesmo, não a sua máquina. Em desenvolvimento, use um túnel; em produção, um endereço de verdade como https://meusite.com.br/webhook/ligacoes.

Agora o receptor inteiro. São 115 linhas, sem dependência nenhuma:

// webhook.js — recebe os eventos de ligacao da Letscall.
// So o http do Node: nada de Express, nada de npm install.

const http = require("http");

const PORTA = process.env.PORTA || 3009;

// O que eu sei de cada ligacao. Num sistema de verdade isto e o banco.
const ligacoes = {};

// Os IDs de evento que ja processei. Sem isto, o mesmo evento chega
// duas vezes e voce cobra o cliente duas vezes.
const eventosVistos = new Set();

// A ordem dos eventos NAO e garantida: "encerrada" pode chegar antes de
// "atendida". Por isso cada evento so avanca o estado, nunca volta.
const ORDEM = {
  call_start: 1,
  target_add: 2,
  target_ringing: 3,
  call_established: 4,
  call_ended: 5,
};

function tratarEvento(evento) {
  const requestId = evento.request_id;

  if (!requestId) {
    return;
  }

  // O ID do evento: a Letscall nao manda um, entao monto um com o que
  // identifica o evento sem ambiguidade — a ligacao, o tipo e o instante.
  const idEvento = requestId + "|" + evento.event_type + "|" + evento.time;

  if (eventosVistos.has(idEvento)) {
    console.log("  (repetido, ignorei):", evento.event_type);
    return;
  }

  eventosVistos.add(idEvento);

  if (!ligacoes[requestId]) {
    ligacoes[requestId] = { estado: "", sessionId: "", posicao: 0 };
  }

  const ligacao = ligacoes[requestId];

  // O session_id so aparece a partir do primeiro evento. E ele que
  // encerra a ligacao depois — guarde assim que aparecer.
  if (evento.session_id) {
    ligacao.sessionId = evento.session_id;
  }

  const posicao = ORDEM[evento.event_type] || 0;

  if (posicao < ligacao.posicao) {
    // Evento atrasado. Registro, mas nao deixo o estado voltar.
    console.log("  (fora de ordem, nao retrocedi):", evento.event_type);
    return;
  }

  ligacao.posicao = posicao;
  ligacao.estado = evento.event_type;

  if (evento.event_type === "target_ringing") {
    console.log("  o telefone esta tocando");
  }

  if (evento.event_type === "call_established") {
    // Este e o evento que importa para cobrar: aqui alguem atendeu.
    // "call_start" so quer dizer que a ligacao foi pedida.
    console.log("  atendeu! comeca a contar aqui");
  }

  if (evento.event_type === "call_ended") {
    console.log("  acabou. session_id:", ligacao.sessionId);
  }
}

const servidor = http.createServer(function (req, res) {
  if (req.method !== "POST") {
    res.writeHead(405);
    res.end();
    return;
  }

  // O corpo chega em pedacos. Juntar tudo antes do JSON.parse e
  // obrigatorio: com um pedaco so, o parse quebra em payload grande.
  let corpo = "";

  req.on("data", function (pedaco) {
    corpo += pedaco;
  });

  req.on("end", function () {
    // Responder 200 PRIMEIRO, processar depois. Se voce demorar para
    // responder, a Letscall acha que caiu e manda o evento de novo.
    res.writeHead(200, { "content-type": "text/plain" });
    res.end("OK");

    try {
      const evento = JSON.parse(corpo);
      console.log("Evento:", evento.event_type);
      tratarEvento(evento);
    } catch (erro) {
      console.error("Corpo invalido:", erro.message);
    }
  });
});

servidor.listen(PORTA, function () {
  console.log("Ouvindo webhook na porta " + PORTA);
});

🧩 As quatro armadilhas do webhook, uma a uma

Vale destrinchar cada uma, porque cada linha ali evita um bug específico.

1. O corpo chega em pedaços. Esta é clássica de Node e derruba muita gente que veio do PHP, onde file_get_contents("php://input") resolve tudo numa linha. No Node, o corpo da requisição é um stream: ele chega picado, e o JSON.parse num pedaço só quebra.

  // O corpo chega em pedacos. Juntar tudo antes do JSON.parse e
  // obrigatorio: com um pedaco so, o parse quebra em payload grande.
  let corpo = "";

  req.on("data", function (pedaco) {
    corpo += pedaco;
  });

  req.on("end", function () {

O traiçoeiro é que funciona nos testes. Payload pequeno chega inteiro num pedaço só, e você jura que está certo. O bug só aparece quando o evento vem maior — e aí é em produção. 😬

2. O 200 tem de sair rápido. Repare na ordem dentro do req.on("end"): eu respondo primeiro e processo depois.

    // Responder 200 PRIMEIRO, processar depois. Se voce demorar para
    // responder, a Letscall acha que caiu e manda o evento de novo.
    res.writeHead(200, { "content-type": "text/plain" });
    res.end("OK");

    try {
      const evento = JSON.parse(corpo);
      console.log("Evento:", evento.event_type);
      tratarEvento(evento);
    } catch (erro) {
      console.error("Corpo invalido:", erro.message);
    }

Parece errado, né? A intuição manda processar e depois confirmar. Mas se o seu código demora — uma consulta pesada, uma escrita lenta — a Letscall conclui que o seu servidor caiu e manda o mesmo evento de novo. Você processa duas vezes. Se esse processamento cobra o cliente, você cobrou duas vezes. 💸

3. O evento duplicado chega mesmo assim. Responder rápido reduz a duplicação, mas não elimina: qualquer soluço de rede entre o servidor deles e o seu gera reenvio. Então guarde o que já viu.

  // O ID do evento: a Letscall nao manda um, entao monto um com o que
  // identifica o evento sem ambiguidade — a ligacao, o tipo e o instante.
  const idEvento = requestId + "|" + evento.event_type + "|" + evento.time;

  if (eventosVistos.has(idEvento)) {
    console.log("  (repetido, ignorei):", evento.event_type);
    return;
  }

  eventosVistos.add(idEvento);

A Letscall não manda um "ID do evento", então eu monto um: a ligação, o tipo do evento e o instante. Essa combinação identifica o evento sem ambiguidade — dois eventos diferentes da mesma ligação nunca têm o mesmo tipo e o mesmo segundo.

4. A ordem não é garantida. Essa é a que mais dói, porque contradiz tudo o que a intuição diz. Os eventos viajam por caminhos diferentes e chegam fora de ordem: dá para receber call_ended antes de call_established. Isso mesmo — a notificação de que a ligação acabou chegando antes da notificação de que ela foi atendida. 🤯

Se o seu código faz só estado = evento.event_type, uma ligação já encerrada volta para "em andamento" e nunca mais fecha. A defesa é dar um número a cada etapa e nunca deixar o estado retroceder:

// A ordem dos eventos NAO e garantida: "encerrada" pode chegar antes de
// "atendida". Por isso cada evento so avanca o estado, nunca volta.
const ORDEM = {
  call_start: 1,
  target_add: 2,
  target_ringing: 3,
  call_established: 4,
  call_ended: 5,
};
  const posicao = ORDEM[evento.event_type] || 0;

  if (posicao < ligacao.posicao) {
    // Evento atrasado. Registro, mas nao deixo o estado voltar.
    console.log("  (fora de ordem, nao retrocedi):", evento.event_type);
    return;
  }

  ligacao.posicao = posicao;
  ligacao.estado = evento.event_type;

🎭 "Criada" e "atendida" são coisas diferentes

Esta merece seção própria porque é a diferença que separa uma integração que cobra certo de uma que cobra errado.

O evento call_start quer dizer que a ligação foi pedida. Só isso. Ninguém atendeu nada — o ramal nem começou a tocar direito. Já o call_established é o momento em que os dois lados estão realmente falando.

Se você começar a contar tempo (ou dinheiro) no call_start, vai cobrar pelos segundos de chamada tocando no vazio — inclusive das ligações que ninguém atendeu, que geram call_start e nunca geram call_established.

  if (evento.event_type === "target_ringing") {
    console.log("  o telefone esta tocando");
  }

  if (evento.event_type === "call_established") {
    // Este e o evento que importa para cobrar: aqui alguem atendeu.
    // "call_start" so quer dizer que a ligacao foi pedida.
    console.log("  atendeu! comeca a contar aqui");
  }

  if (evento.event_type === "call_ended") {
    console.log("  acabou. session_id:", ligacao.sessionId);
  }

Foi olhando os eventos de uma chamada não atendida que a ficha caiu para mim: tinha call_start, tinha target_ringing umas cinco vezes seguidas (cada toque gera um), e depois call_ended direto. Nenhum call_established no meio. A ligação existiu, tocou, e morreu sem ninguém atender.

🔴 Encerrando a ligação

Depois de todo esse contexto, encerrar é decepcionantemente simples — desde que você tenha guardado a coisa certa:

// Encerra a ligacao. Usa o session_id, NAO o request_id.
async function encerrarLigacao(sessionId) {
  await chamarApi("POST", "/api/call_sessions/destroy", {
    session_id: sessionId,
  });
}

É session_id, não request_id. Eu confundi os dois na primeira tentativa e passei um tempo achando que a API estava com problema, quando na verdade eu estava mandando o identificador errado — e a resposta era um educado "sessão não encontrada". Era o meu código. 😳

Por isso aquele trecho lá no receptor, que guarda o session_id assim que ele aparece em qualquer evento:

  // O session_id so aparece a partir do primeiro evento. E ele que
  // encerra a ligacao depois — guarde assim que aparecer.
  if (evento.session_id) {
    ligacao.sessionId = evento.session_id;
  }

🔍 Consultando os detalhes

Terminada a ligação, você quer saber o que aconteceu: atendeu? quanto tempo tocou? quanto tempo falou? E aqui vem outra surpresa de modelagem: não existe "buscar ligação pelo ID". A Letscall lista as ligações de um ramal num intervalo de datas, e você procura a sua na lista.

// Consulta os detalhes: quem falou com quem, quanto tocou, quanto durou.
// Nao existe "buscar pelo id": a Letscall lista as ligacoes do dia e
// voce procura a sua na lista.
async function consultarLigacao(ramal, data, sessionId) {
  const caminho =
    "/api/call_logs/list?pagination=1&pagesize=100&sort_by=DEFAULT" +
    "&extension=" + ramal +
    "&start_time=" + data + "T00:00:00" +
    "&end_time=" + data + "T23:59:59";

  const dados = await chamarApi("GET", caminho);

  for (const item of dados.items) {
    if (item.session_id === sessionId) {
      return item;
    }
  }

  return null;
}

Os campos que interessam no item devolvido, em português:

session_id       o identificador da ligacao (o seu)
status           ANSWERED se atendeu, NONE se nao atendeu
ring_duration    segundos que passou tocando
talk_duration    segundos que passou em conversa
duration         duracao total
direction        OUTBOUND_CALL para fora, EXTENSION_CALL entre ramais
end_reason       CALLER_DISCONNECT ou CALLED_DISCONNECT (quem desligou)

O end_reason é um mimo: ele diz quem desligou primeiro. CALLER_DISCONNECT é o lado que ligou, CALLED_DISCONNECT é quem recebeu. Dá para descobrir se foi o operador que encerrou o atendimento ou se o cliente desistiu no meio.

Repare também no ring_duration separado do talk_duration. São números diferentes e ambos úteis: o primeiro mede a paciência de quem espera, o segundo é o que normalmente se cobra.

▶️ O cliente rodando

Juntando tudo, o main() percorre o ciclo:

async function main() {
  const ramal = "200";
  const telefone = "5511900000000";
  const bina = "5511300000000";

  try {
    const requestId = await criarLigacao(ramal, telefone, bina);
    console.log("Ligacao pedida. request_id:", requestId);

    // Numa integracao de verdade o session_id chega pelo webhook.
    // Aqui uso o que o receptor guardou para poder encerrar e consultar.
    const sessionId = process.env.SESSION_ID;

    if (sessionId) {
      const detalhe = await consultarLigacao(ramal, "2026-06-21", sessionId);

      if (detalhe) {
        console.log("Status:", detalhe.status);
        console.log("Tocou:", detalhe.ring_duration, "segundos");
        console.log("Falou:", detalhe.talk_duration, "segundos");
      } else {
        console.log("Ligacao ainda nao aparece na lista do dia.");
      }

      await encerrarLigacao(sessionId);
      console.log("Ligacao encerrada.");
    }
  } catch (erro) {
    console.error("Deu errado:", erro.message);
    process.exitCode = 1;
  }
}

if (require.main === module) {
  main();
}

module.exports = { criarLigacao, encerrarLigacao, consultarLigacao };

E a saída, com os dados fictícios do exemplo:

Ligacao pedida. request_id: 9000000000000000001
Status: ANSWERED
Tocou: 6 segundos
Falou: 843 segundos
Ligacao encerrada.

O caminho de erro também precisa se comportar. Com a senha errada, o script para no primeiro passo, com mensagem em português e código de saída 1 — nada de seguir adiante fingindo que está tudo bem:

Deu errado: Falha ao autenticar: usuario ou senha recusados.

🧪 As duas armadilhas, acontecendo

Falar que evento chega repetido e fora de ordem é fácil. Ver é melhor. Mandei no receptor a sequência de uma ligação, com um evento duplicado e um fora de ordem de propósito — o call_established chegando depois do call_ended:

Ouvindo webhook na porta 3010
Evento: call_start
Evento: target_add
Evento: target_ringing
  o telefone esta tocando
Evento: target_ringing
  (repetido, ignorei): target_ringing
Evento: call_ended
  acabou. session_id: 9000000000000000002
Evento: call_established
  (fora de ordem, nao retrocedi): call_established

É essa última linha que me deixa dormir à noite. 😌 O call_established chegou atrasado, o receptor registrou que ele chegou, e não deixou a ligação encerrada voltar para "atendida". Sem aquelas seis linhas do ORDEM, essa chamada ficaria eternamente em aberto no sistema.

📋 O que eu guardaria num papel colado no monitor

Se eu tivesse que resumir a integração inteira em poucas linhas, seria isto:

O request_id vem da criação, o session_id vem dos eventos, e é o segundo que encerra a ligação. O token vence respondendo 200 com err_code 1043002, não 401. O webhook responde antes de processar, junta o corpo antes do parse, ignora o que já viu e nunca deixa o estado andar para trás. E cobrar começa no call_established, nunca no call_start.

Nenhuma dessas linhas está na documentação. Todas custaram uma tarde. 🙂

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Leia também