Node.js: ligação com áudio MP3 pela Zenvia
Olá meus Unicórnios! 🦄✨
Sabe quando o cliente pede uma coisa e você acha que entendeu? 😅 O pedido era "avisar as pessoas por telefone". Eu já estava montando o texto da mensagem quando veio a segunda parte: não é para mandar texto. É para o telefone tocar, a pessoa atender, e uma gravação falar com ela.
Aquele aviso de robô que liga, você atende e ouve "sua consulta está confirmada para amanhã". Aquilo ali. E o áudio já existia — era um MP3 gravado.
Eu achei que fosse difícil. Não é. É um POST. O que é difícil são as três ou quatro coisinhas ao redor que ninguém escreve em lugar nenhum, e que me fizeram perder um tempo bobo. É sobre elas que este artigo fala. 🙏
📞 O que a API de voz faz (e o que ela não faz)
A ideia é bem mais simples do que parece. Você não precisa de nada de telefonia rodando na sua máquina — nada de placa, nada de PABX, nada de SIP.
Você manda para a Zenvia duas coisas: o número que deve ser chamado e o endereço de um MP3 na internet. Ela liga para o número e, quando alguém atende, ela toca o arquivo.
seu programa -> POST /audio -> Zenvia -> liga para o telefone
|
+-> baixa o MP3 da URL que voce mandou
Repare na seta de baixo, porque ela é a origem de quase todo problema que eu tive: quem baixa o MP3 é o servidor da Zenvia, lá fora, num datacenter qualquer. Não é o seu programa que envia o arquivo. Seu programa só entrega o endereço dele e diz "vai buscar aí". 🎯
🔑 O token vem do ambiente, e o cabeçalho não é o que você espera
Duas coisas neste bloco, e as duas já me morderam.
A primeira é a regra que eu sigo em todo código que fala com API: credencial nunca é escrita dentro do arquivo. Nem "só para testar", nem "eu troco depois". Porque não troca. Vai para o Git, e aí já era. Ela vem do ambiente, sempre.
A segunda é o nome do cabeçalho — e essa é a pegadinha. Todo mundo (eu inclusive 🙋♀️) escreve Authorization: Bearer alguma-coisa no automático, porque é assim na maioria das APIs do mundo. Aqui não é. O cabeçalho se chama Access-Token e o valor vai nu, sem Bearer na frente.
// O token NUNCA fica escrito aqui dentro. Ele vem do ambiente:
// Windows: set ZENVIA_TOKEN=seu-token
// Linux: export ZENVIA_TOKEN=seu-token
const TOKEN = process.env.ZENVIA_TOKEN;
// A URL base tambem vem do ambiente, com o endereco real como padrao.
// E o que permite apontar para um servidor de teste sem mexer no codigo.
const BASE = process.env.ZENVIA_URL || 'https://voice-api.zenvia.com';
// Monta o cabecalho da requisicao. O nome do cabecalho e "Access-Token",
// NAO e "Authorization: Bearer" — esse e o engano que rende um 401 misterioso.
function montarAutenticacao() {
if (!TOKEN) {
throw new Error('Falta a variavel de ambiente ZENVIA_TOKEN');
}
return {
'accept': 'application/json',
'content-type': 'application/json',
'Access-Token': TOKEN
};
}
Aquele if (!TOKEN) parece bobagem, mas ele existe por um motivo bem específico. Sem ele, o token vira undefined, o cabeçalho sai preenchido com a palavra "undefined", e a API responde 401 — o mesmo erro de um token errado. Você vai passar meia hora conferindo se copiou o token direito, quando o problema é que o terminal nem enxergava a variável. 🙄
Com o if, o erro vira "Falta a variavel de ambiente ZENVIA_TOKEN", que se resolve em cinco segundos:
Deu errado: Falta a variavel de ambiente ZENVIA_TOKEN
☎️ O telefone precisa do código do país — e esse erro é silencioso
O número tem que ir no formato internacional: +55, DDD e o número. Parece detalhe chato de formatação, mas o estrago é grande.
Eu tinha um número vindo do banco de dados, do jeitinho que o cliente digitou no cadastro — com parêntese, com traço, às vezes com espaço a mais. Mandar isso cru para a API é pedir problema. Então antes de qualquer coisa, uma função que limpa e devolve o formato certo:
// Deixa o telefone no formato que a API espera: +55 e so digitos.
function normalizarTelefone(telefone) {
// Tira tudo que nao for numero: parenteses, traco, espaco, ponto.
let numero = String(telefone).replace(/[^0-9]/g, '');
// Se o numero ja veio com o 55 na frente, nao coloca de novo.
if (numero.length > 11 && numero.slice(0, 2) === '55') {
numero = numero.slice(2);
}
// Sem DDD nao da: 10 digitos e fixo, 11 e celular. Menos que isso, para tudo.
if (numero.length < 10) {
throw new Error('Telefone sem DDD: ' + telefone);
}
// O codigo do pais e obrigatorio. Mandar "11987654321" e o erro mais comum:
// a API aceita a chamada e nunca disca, porque nao sabe de que pais e o numero.
return '+55' + numero;
}
Três coisas aqui merecem um parágrafo cada, porque cada uma evita um bug diferente.
O if do 55 na frente. Metade dos cadastros vem com o código do país, metade não. Se você só colar '+55' + numero sem conferir, o número que já tinha o 55 vira +555511900000000 — e ninguém atende, porque esse telefone não existe. 😬
O if do tamanho. É melhor o seu programa parar aqui, com uma mensagem clara, do que mandar um número capenga e pagar por uma tentativa de ligação que nunca ia dar em nada.
E o comentário do código do país, que é o mais importante dos três. Mandar 11900000000 sem o +55 não estoura na sua cara — a resposta que volta é:
HTTP 400 - Numero de destino invalido: informe o codigo do pais
Que até que é uma mensagem honesta. O problema é quando você não lê a resposta e só olha se "deu erro ou não". Aí a ligação some no meio do caminho e você fica achando que o telefone do cliente é que está errado. Rodando a função, os dois formatos caem no mesmo lugar:
(11) 90000-0000 -> +5511900000000
5511900000000 -> +5511900000000
90000-0000 -> Deu errado: Telefone sem DDD: 90000-0000
🎵 A armadilha do localhost, que é a rainha de todas
Se você só for ler um pedaço deste artigo, leia este. 🙏
Meu MP3 estava na pasta do projeto. Subi o servidor local, abri http://localhost:3000/aviso.mp3 no navegador, o áudio tocou lindamente. Mandei essa URL para a API. E a ligação não saiu.
Eu fiquei olhando aquilo sem entender, porque o arquivo funcionava. Eu tinha acabado de ouvir. 🤦♀️
Aí caiu a ficha, e é aquela seta lá do começo do artigo: quem baixa o MP3 é o servidor da Zenvia. Quando ele lê localhost, ele procura o arquivo na máquina dele — que é um servidor num datacenter que nunca ouviu falar do meu computador. Não é que o endereço esteja errado. É que localhost significa "aqui mesmo", e o "aqui" dele não é o meu. 😳
E não adianta esperar que o erro seja claro. Ele vem como uma recusa comum, no meio de outras:
Deu errado: A ligacao foi recusada: Nao foi possivel baixar o audio: endereco inacessivel
O teste que resolve a dúvida em dez segundos, antes de gastar uma ligação: pegue a URL do áudio e abra no celular, com o wi-fi desligado. Se tocar, serve. Se não tocar, a Zenvia também não vai conseguir. 📱
Duas coisas mais sobre o arquivo, que eu aprendi na marra: ele precisa ser MP3 de verdade (o serviço não converte um .wav renomeado para você) e o link tem que apontar direto para o arquivo. Link de pasta compartilhada de nuvem, daqueles que abrem uma página com botão de download, não serve — o que chega lá do outro lado é uma página HTML, não um áudio.
🚀 O disparo, que é um POST e mais nada
Depois de toda essa preparação, a chamada em si é quase decepcionante de tão curta. E repare: nenhuma dependência. O fetch já vem dentro do Node.js desde a versão 18, então não tem npm install nenhum aqui.
// Dispara a ligacao. Quem toca o MP3 e o servidor da Zenvia, nao o seu programa.
async function ligarTocandoAudio(telefone, urlDoAudio) {
const destino = normalizarTelefone(telefone);
// ATENCAO: quem baixa este MP3 e o servidor da Zenvia, la fora.
// Por isso "http://localhost/aviso.mp3" NUNCA funciona: o localhost dele
// e a maquina dele, nao a sua. Precisa ser um endereco publico na internet.
const corpo = {
numero_destino: destino,
url_audio: urlDoAudio,
resposta_usuario: true,
gravar_audio: true,
detecta_caixa: true
};
const resposta = await fetch(BASE + '/audio', {
method: 'POST',
headers: montarAutenticacao(),
body: JSON.stringify(corpo)
});
const texto = await resposta.text();
// O corpo do erro nem sempre e JSON. Se tentar JSON.parse direto,
// o erro que voce ve e "Unexpected token" — e nao o motivo da recusa.
let dados = null;
try {
dados = JSON.parse(texto);
} catch (erro) {
throw new Error('A API respondeu algo que nao e JSON: ' + texto);
}
if (resposta.status !== 200 && resposta.status !== 201) {
let motivo = 'HTTP ' + resposta.status;
if (dados && dados.mensagem) {
motivo = dados.mensagem;
}
throw new Error('A ligacao foi recusada: ' + motivo);
}
return dados;
}
Os três campos de baixo são os que dão jeitinho à ligação, e valem a explicação:
numero_destino o telefone, com +55 na frente
url_audio endereco publico do MP3
resposta_usuario deixa a pessoa apertar uma tecla no teclado
gravar_audio guarda o audio da chamada para ouvir depois
detecta_caixa tenta perceber que caiu na caixa postal
Esse detecta_caixa é o meu preferido. Sem ele, a caixa postal atende, o robô fala com a secretária eletrônica e a chamada é contada como sucesso — todo mundo feliz, menos a pessoa que deveria ter ouvido o recado. 😅
Repare também no resposta.text() antes do JSON.parse. É feio, eu sei, e a vontade é chamar resposta.json() direto. Mas quando a coisa dá errado de verdade — o serviço fora do ar, um proxy no meio do caminho, uma página de erro do servidor — o que volta é HTML, não JSON. Com o resposta.json() você recebe um "Unexpected token", que não diz absolutamente nada. Do jeito acima, você recebe o texto que a API mandou, e ele quase sempre explica o problema.
Nos campos originais da documentação, esses cinco são numero_destino, url_audio, resposta_usuario, gravar_audio e detecta_caixa — os mesmos nomes, já em português, o que ajuda bastante na hora de conferir.
🎧 "Aceita para envio" não é "a pessoa ouviu"
Esta é a parte que eu mais quero que fique gravada, porque eu errei e o erro só apareceu dias depois. 😳
Quando o POST responde 200, vem um JSON curtinho com um identificador. Eu li aquilo, vi o 200, e escrevi no meu sistema: "aviso entregue". 🎉
Só que não. Aquele 200 quer dizer "recebi o seu pedido e coloquei na fila". Só isso. Nesse instante o telefone da pessoa ainda nem tocou. Entre esse 200 e alguém de fato ouvir a gravação existe um monte de coisa que pode dar errado: o telefone estar desligado, a pessoa não atender, cair na caixa postal, dar ocupado.
É por isso que a mensagem que o programa imprime é essa, e não outra:
// "aceita" quer dizer que a fila recebeu o pedido. Nao quer dizer
// que alguem atendeu — isso so se sabe depois, pelo webhook.
console.log('Ligacao aceita para envio.');
console.log('Identificador:', retorno.dados.id);
Rodando, sai assim:
Ligacao aceita para envio.
Identificador: ABC123XYZ
Guarde esse identificador. Ele é a única ponte entre o disparo de agora e a resposta que chega mais tarde — quando a ligação termina de verdade, a Zenvia avisa num webhook seu, e o que ela manda de volta é esse mesmo id, agora com o status real (atendida, não atendida, caixa postal). Se você não guardou o identificador junto do registro, quando o aviso chegar você não vai saber de quem ele é.
Configurar o webhook é assunto de outro dia. Mas gravar o identificador é assunto de hoje, na hora do disparo — porque depois não tem como recuperar. 🙃
🧯 Os erros, todos no mesmo lugar
O main() não faz nada de espetacular: chama a função, e envolve tudo num try/catch. É de propósito. Como toda validação lá de cima joga um Error com mensagem em português, o programa inteiro tem um lugar só onde o erro aparece para quem está olhando o terminal.
async function main() {
// Numero ficticio, so para o exemplo. Troque pelo seu antes de rodar.
const telefone = '(11) 90000-0000';
const urlDoAudio = process.env.URL_AUDIO;
try {
const retorno = await ligarTocandoAudio(telefone, urlDoAudio);
// "aceita" quer dizer que a fila recebeu o pedido. Nao quer dizer
// que alguem atendeu — isso so se sabe depois, pelo webhook.
console.log('Ligacao aceita para envio.');
console.log('Identificador:', retorno.dados.id);
} catch (erro) {
console.error('Deu errado:', erro.message);
process.exitCode = 1;
}
}
main();
O process.exitCode = 1 é pequeno e faz muita diferença. Sem ele o programa termina dizendo "deu tudo certo" para quem chamou, mesmo tendo falhado. Se esse script rodar dentro de uma tarefa agendada, ninguém nunca vai ficar sabendo que a ligação não saiu. Com o 1, quem chamou percebe.
E as mensagens que aparecem por ali, cada uma com o seu motivo:
Deu errado: Falta a variavel de ambiente ZENVIA_TOKEN
Deu errado: A ligacao foi recusada: Token de acesso invalido
Deu errado: A ligacao foi recusada: Nao foi possivel baixar o audio: endereco inacessivel
Deu errado: Telefone sem DDD: 90000-0000
Repare que nenhuma delas fala em código HTTP. Quem lê o terminal às sete da manhã querendo saber por que o aviso não saiu não precisa de um 400 na cara — precisa saber que o áudio não pôde ser baixado. 💜
💡 O resumo das armadilhas
Quatro coisas, e eu tropecei em todas: 😅
O cabeçalho é Access-Token, não Authorization: Bearer — e sem token a API devolve o mesmo 401 de token errado, então cheque a variável de ambiente antes de acusar a chave.
O telefone vai com +55, e o número que já veio com o 55 não pode ganhar outro.
O MP3 mora num endereço público, porque quem baixa é o servidor lá fora — localhost é a máquina dele, não a sua.
E o 200 do disparo é a fila dizendo "recebi", nunca a pessoa dizendo "ouvi". Guarde o identificador, que é ele quem vai casar com o aviso que chega depois.
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.