Pular para o conteúdo
Node.js

Node.js: ligação com áudio MP3 pela Zenvia

Paloma Macetko
Ilustração colorida de um unicórnio de crina arco-íris falando num telefone antigo de disco, com uma coruja mágica segurando um pergaminho em forma de onda sonora, notas musicais e ondas de áudio brilhantes viajando até uma antena de castelo com velas flutuantes

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