Node.js: integrando ligações com a Letscall
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
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.