Node.js: áudio como ligação pela SMSMais
Olá meus Unicórnios! 🦄✨
No artigo passado a gente mandou SMS pela SMSMais e leu as respostas que voltavam. E aí veio o pedido que eu não esperava: "e se em vez de texto eu quisesse que o telefone tocasse e a pessoa ouvisse a minha voz?" 🤔
Confesso que na hora imaginei um mundo de trabalho: gravar, converter, subir o arquivo pela API, torcer. Sabe quando você acha que vai ser a tarde inteira? 😅 Pois é. É um campo a mais no JSON. O mesmo endereço, a mesma chave, o mesmo corpo do SMS, e a ligação acontece.
A parte difícil não é enviar. É descobrir que a ligação não aconteceu, porque a API responde um lindo 200 mesmo quando o telefone nunca tocou. É disso que este artigo trata. 📞
🎙️ O que é um torpedo de voz
É uma ligação automática: a plataforma disca para o número e, quando a pessoa atende, toca uma gravação que você preparou. Nada de robô lendo texto com voz sintética. É o seu MP3, com a sua voz, o seu jingle ou o aviso que você gravou no celular mesmo.
Serve bem para o que ninguém lê por escrito: aviso de vencimento, confirmação de consulta, lembrete de agendamento. Muita gente ignora SMS e nunca ignora o telefone tocando. 😄
📮 O envio é o mesmo do SMS, com um campo a mais
Aqui está a boa notícia inteira. O endereço é o mesmo POST /send, a autenticação é a mesma chave no cabeçalho Authorization, e a estrutura do corpo é idêntica. A diferença cabe numa linha:
{
"messages": [
{
"id": "voz-001",
"destinations": [ { "to": "5511999999999" } ],
"msg": "Aviso de vencimento",
"audio": "https://exemplo.com/gravacoes/aviso.mp3"
}
]
}
É o campo audio que muda tudo. Quando ele está presente, a plataforma entende que aquilo é uma ligação e não um SMS. Quando está ausente, o mesmo corpo manda texto.
Repare que o msg continua ali. Ele não é lido para a pessoa: é só uma etiqueta para você se achar depois, quando estiver olhando os relatórios e tentando lembrar do que era aquela ligação. Eu costumo escrever ali a descrição da campanha.
🌐 O campo audio recebe uma URL, não o arquivo
Essa é a primeira pedra no caminho, e ela derruba quase todo mundo na primeira tentativa. O campo audio não aceita o arquivo enviado por multipart, nem o conteúdo dele em base64. Ele aceita um endereço, e só.
O que isso significa na prática: quem baixa o seu MP3 não é você, é o servidor da SMSMais. Ele vai lá, faz o download do arquivo e toca na ligação. Por isso o endereço precisa estar publicamente acessível, aberto, sem senha, sem login, sem "só quem tem o link".
E é por isso que estes aqui não funcionam, por mais que abram lindamente no seu navegador:
- Link do Google Drive ou do Dropbox: eles não entregam o arquivo direto, entregam uma página HTML com um botão de baixar. O servidor recebe HTML onde esperava um MP3.
- Endereço dentro de uma área logada do seu sistema. Ele abre para você porque o seu navegador tem o cookie da sessão; o servidor da plataforma não tem.
- Endereço de rede local ou
localhost. Existe só dentro da sua máquina, e o mundo lá fora não enxerga.
O que funciona é o arquivo numa pasta pública do seu site, do tipo que você cola no navegador e o download começa na hora. Esse é o teste: abra a URL numa janela anônima, sem estar logado em nada. Se tocar, serve.
📞 Enviando a ligação em Node.js
Vamos ao código. Não precisa instalar nada: o fetch já vem pronto no Node 18 para cima, então é um arquivo e o comando node.
Primeiro a função que envia. Ela monta o corpo, manda e devolve a resposta já convertida:
const URL_API = process.env.SMSMAIS_URL || "https://smsmais.com";
// A chave NUNCA fica escrita aqui dentro: ela vem de variavel de ambiente.
const TOKEN = process.env.SMSMAIS_TOKEN;
async function enviarVoz(numero, urlDoAudio, textoDeReferencia) {
const corpo = {
messages: [
{
id: "voz-001",
destinations: [{ to: numero }],
msg: textoDeReferencia,
// A API nao recebe o arquivo: recebe a URL dele. Quem baixa
// o MP3 e o servidor da SMSMais, entao o endereco precisa
// ser publico. Link do Drive ou de pasta protegida vira
// "Invalid audio" la na frente, sem erro nenhum no envio.
audio: urlDoAudio
}
]
};
const resposta = await fetch(URL_API + "/send", {
method: "POST",
headers: {
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json"
},
body: JSON.stringify(corpo)
});
// Le o corpo como texto antes de tentar o JSON. Quando a API devolve
// um erro de servidor, o corpo vem em HTML e o .json() estoura com uma
// mensagem que nao ajuda em nada.
const texto = await resposta.text();
if (!resposta.ok) {
throw new Error("A API respondeu " + resposta.status + ": " + texto);
}
return JSON.parse(texto);
}
Duas linhas ali merecem atenção, e as duas são cicatriz. 😳
A primeira é o TOKEN saindo de process.env. Chave de API escrita dentro do arquivo vai junto para o repositório e fica no histórico do Git para sempre, mesmo depois de você apagar. Se ela vazar, quem achou manda ligação no seu saldo.
A segunda é o await resposta.text() antes do JSON.parse. Parece um passo a mais sem motivo, e é o contrário: quando a API devolve um erro de servidor, o corpo às vezes vem em HTML. Chamando resposta.json() direto, o erro que estoura é um SyntaxError reclamando de um < inesperado, e você perde meia hora procurando bug no seu código. Lendo como texto primeiro, a mensagem mostra o que a API realmente respondeu.
Rodando isso, a resposta do envio vem assim:
[
{
"id": 98765,
"schedule": "",
"nome": "",
"to": "5511999999999",
"msg": "Aviso de vencimento",
"externalId": "voz-001"
}
]
Guarde esse externalId: é o id que você mandou no envio, e é por ele que se consulta o status depois. E é agora que o artigo fica interessante. 👀
⚠️ O 200 mente: a ligação pode nunca ter acontecido
Se você só for ler um pedaço deste artigo, leia este. 🙏
Aquela resposta acima não quer dizer que o telefone tocou. Quer dizer que a plataforma aceitou o seu pedido e colocou na fila. A ligação em si acontece depois, e ela pode falhar de várias formas sem que nada disso volte para o seu fetch.
Aí mora a diferença cruel entre SMS e voz. No SMS existe um campo de status, o entrega. Na voz existem dois, e o que você quer não é o primeiro:
| Campo | O que ele responde |
|---|---|
entrega | Se a plataforma conseguiu passar o pedido adiante |
entrega_voz | Se a pessoa atendeu e ouviu a gravação |
Quem responde a pergunta que importa é o segundo. E os valores dele são estes:
| Valor | O que aconteceu |
|---|---|
Answered | Atendeu e ouviu a gravação 🎉 |
Not Answered | O telefone tocou e ninguém atendeu |
Called | A ligação está acontecendo agora |
Waiting | Ainda na fila, não discou |
Invalid audio | Não deu para baixar ou tocar o seu MP3 |
Fail | A chamada falhou |
O Invalid audio é o que você vai encontrar, e é exatamente o sintoma de URL que não é pública. Repare no detalhe cruel: o envio deu 200, o seu programa terminou feliz, o log ficou verde, e o cliente nunca recebeu ligação nenhuma. 😱
🔎 Consultando quem atendeu
A consulta é um GET simples, com o mesmo cabeçalho de autorização e o ext_id que você mandou no envio:
async function consultarStatus(idExterno) {
const resposta = await fetch(URL_API + "/status?ext_id=" + idExterno, {
headers: { "Authorization": "Bearer " + TOKEN }
});
const texto = await resposta.text();
if (!resposta.ok) {
throw new Error("A API respondeu " + resposta.status + ": " + texto);
}
return JSON.parse(texto);
}
E a leitura do resultado, que é onde a regra da voz aparece no código:
// Aqui mora a pegadinha da voz: quem diz se a ligacao foi atendida
// e o campo entrega_voz, nao o entrega. Um entrega "Sent" com
// entrega_voz "Invalid audio" e uma ligacao que NAO aconteceu.
for (const item of status) {
if (item.entrega_voz === "Answered") {
console.log("O numero " + item.to + " atendeu e ouviu a gravacao.");
} else {
console.log("O numero " + item.to + " nao ouviu. Motivo: " + item.entrega_voz);
}
}
Note que eu escrevi for e não forEach, e if/else e não um ternário espremido. Código de tutorial é para ser lido, não para ser bonito. 😊
Quando tudo dá certo, o status volta assim:
[
{
"entrega": "Sent",
"entrega_voz": "Answered",
"to": "5511999999999",
"data": "2026-08-19 21:57:04"
}
]
O numero 5511999999999 atendeu e ouviu a gravacao.
E agora o caso que interessa. Mesma requisição, mesmo 200, mesmo entrega: "Sent", e uma ligação que nunca existiu:
[
{
"entrega": "Sent",
"entrega_voz": "Invalid audio",
"to": "5511999999999",
"data": "2026-08-19 21:57:04"
}
]
O numero 5511999999999 nao ouviu. Motivo: Invalid audio
Olhe as duas saídas lado a lado. O campo entrega é idêntico nas duas. Se o seu programa olhasse só para ele, os dois casos seriam sucesso. É por isso que a regra da voz é: ignore o entrega e leia o entrega_voz. 🎯
🔔 Recebendo o resultado sem ficar perguntando
Ficar consultando o /status de minuto em minuto funciona para uma ligação, mas não para mil. Para isso existe o webhook: você cadastra uma URL no painel e a plataforma faz um POST nela quando o status muda.
O detalhe que importa aqui é que o formato do aviso de voz é diferente do de SMS. O de SMS traz um campo delivery. O de voz traz o par entrega e entrega_voz, o mesmo par da consulta:
[
{
"externalid": "voz-001",
"to": "5511999999999",
"entrega": "Sent",
"entrega_voz": "Answered",
"msg": "Aviso de vencimento",
"data_entrega": "2026-08-19 21:57:50",
"data": "2026-08-19 21:57:04"
}
]
Se você já tem um webhook montado para o SMS, ele vai receber esses avisos também, e o campo delivery que ele procura vai chegar vazio. Vale conferir a presença do entrega_voz antes de decidir como tratar o aviso.
⏰ Agendando a ligação
Ninguém quer receber aviso de vencimento às três da manhã. 😴 Para marcar a hora, é mais um campo na mesma mensagem:
{
"messages": [
{
"id": "voz-002",
"destinations": [ { "to": "5511999999999" } ],
"msg": "Lembrete de consulta",
"audio": "https://exemplo.com/gravacoes/lembrete.mp3",
"schedule": "2026-09-10 09:00:00"
}
]
}
O formato é AAAA-MM-DD HH:MM:SS e o horário é lido no fuso de São Paulo, não em UTC. Se o seu servidor está em outro fuso e você monta essa string a partir da hora dele, a ligação sai na hora errada. Escreva o horário de Brasília ali, direto.
💸 Um cuidado com lote grande
O /send aceita até 100.000 mensagens numa requisição só, então é tentador jogar a base inteira de uma vez. Antes disso, um aviso que dói: em conta pré-paga, a plataforma soma o custo do lote todo antes de aceitar. Se o saldo não cobrir, a resposta é 402 com o campo error valendo insufficient_balance, e nenhuma ligação é feita. Não é "manda o que der": é tudo ou nada.
A resposta ainda diz quanto faltou, o que ajuda a tratar isso direito:
{
"error": "insufficient_balance",
"saldo": 1.20,
"custo": 8.00,
"preco_voz": 0.08,
"qtd_voz": 100
}
Como o enviarVoz() lá de cima lança o erro com o corpo inteiro junto, essa informação chega no seu catch em vez de sumir. Era exatamente para isso que servia aquele await resposta.text() de que eu falei. 😉
📋 O arquivo inteiro
Juntando tudo, é isto aqui. Salve como enviar-voz.js e rode com SMSMAIS_TOKEN=SUA_CHAVE_AQUI node enviar-voz.js:
const URL_API = process.env.SMSMAIS_URL || "https://smsmais.com";
const TOKEN = process.env.SMSMAIS_TOKEN;
async function enviarVoz(numero, urlDoAudio, textoDeReferencia) {
const corpo = {
messages: [
{
id: "voz-001",
destinations: [{ to: numero }],
msg: textoDeReferencia,
audio: urlDoAudio
}
]
};
const resposta = await fetch(URL_API + "/send", {
method: "POST",
headers: {
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json"
},
body: JSON.stringify(corpo)
});
const texto = await resposta.text();
if (!resposta.ok) {
throw new Error("A API respondeu " + resposta.status + ": " + texto);
}
return JSON.parse(texto);
}
async function consultarStatus(idExterno) {
const resposta = await fetch(URL_API + "/status?ext_id=" + idExterno, {
headers: { "Authorization": "Bearer " + TOKEN }
});
const texto = await resposta.text();
if (!resposta.ok) {
throw new Error("A API respondeu " + resposta.status + ": " + texto);
}
return JSON.parse(texto);
}
async function main() {
if (!TOKEN) {
console.error("Falta a chave. Rode assim: SMSMAIS_TOKEN=SUA_CHAVE_AQUI node enviar-voz.js");
return;
}
try {
const enviadas = await enviarVoz(
"5511999999999",
"https://exemplo.com/gravacoes/aviso.mp3",
"Aviso de vencimento"
);
console.log("Ligacao na fila:");
console.log(JSON.stringify(enviadas, null, 4));
const status = await consultarStatus("voz-001");
console.log("");
console.log("Status da ligacao:");
console.log(JSON.stringify(status, null, 4));
for (const item of status) {
if (item.entrega_voz === "Answered") {
console.log("");
console.log("O numero " + item.to + " atendeu e ouviu a gravacao.");
} else {
console.log("");
console.log("O numero " + item.to + " nao ouviu. Motivo: " + item.entrega_voz);
}
}
} catch (erro) {
console.error("Nao deu certo: " + erro.message);
}
}
main();
Uma observação sobre o catch lá no fim: ele não está ali de enfeite para calar o erro. Sem ele, uma queda de rede no meio do envio derruba o programa com um stack trace de dez linhas. Com ele, você lê Nao deu certo: e a razão, em português. É a diferença entre um erro que ensina e um que assusta.
E o if (!TOKEN) logo no começo evita o erro mais bobo e mais frequente: rodar sem a variável de ambiente. Sem essa checagem, o programa monta o cabeçalho com Bearer undefined, a API devolve 401, e você fica olhando para uma mensagem de token inválido com a chave certa colada na área de transferência. 🙈
🎧 Sobre o arquivo de áudio
Fechando com o lado prático da gravação, que não é código mas é onde o projeto costuma emperrar:
- MP3. É o formato que o campo espera, e é o que a documentação usa no exemplo.
- Curto. Quem atende uma ligação automática decide em poucos segundos se continua ouvindo. Aviso de trinta segundos já é longo.
- Comece falando. Nada de dois segundos de silêncio no início: a pessoa atende, ouve o nada e desliga achando que foi trote.
- URL fixa. Se o seu sistema gera nomes de arquivo com data ou hash, cuidado ao apagar arquivos antigos: uma ligação agendada para amanhã vai buscar aquele endereço amanhã. Sumiu o arquivo, virou
Invalid audio.
Esse último é sorrateiro e vale repetir: o download acontece na hora da ligação, não na hora do envio. Entre agendar e discar, o arquivo precisa continuar lá. 📁
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Perguntas frequentes
Qual a diferença entre enviar um SMS e enviar uma ligação de voz?
POST /send. A única diferença no corpo é o campo audio, com a URL de um MP3. Quando ele está presente, a plataforma liga para o número em vez de mandar texto, e a pessoa ouve a gravação ao atender.Preciso enviar o arquivo MP3 para a API?
audio não recebe o arquivo nem o conteúdo dele em base64: recebe uma URL. Quem baixa o MP3 é o servidor da plataforma, então o endereço precisa estar acessível publicamente, sem senha e sem login.Por que meu envio de voz deu certo e o telefone não tocou?
entrega_voz, com o valor Invalid audio. Link de Google Drive, Dropbox ou pasta protegida por login causa exatamente isso.Como saber se a pessoa atendeu a ligação?
GET /status e olhando o campo entrega_voz. Ele vale Answered quando atenderam e ouviram, Not Answered quando ninguém atendeu, Called enquanto a ligação está em andamento e Fail ou Invalid audio quando deu problema. O campo entrega, sozinho, não responde isso.Dá para agendar a ligação para um horário específico?
schedule na mensagem, no formato AAAA-MM-DD HH:MM:SS, e o horário é interpretado no fuso de São Paulo. Sem esse campo, a ligação sai na hora.Quantas ligações dá para disparar de uma vez?
POST /send aceita até 100.000 mensagens numa requisição só, e cada uma pode ter vários destinos. Vale lembrar que contas pré-pagas passam por uma checagem de saldo: se o total do lote não couber no crédito, a requisição inteira é recusada com 402 e nenhuma ligação é feita.Leia também
API Mágica: notificações Web Push no navegador com PHP
Web Push com a API Mágica em PHP puro: o navegador se inscreve, o seu site envia a notificação e a API conta o clique. Sem Composer e sem biblioteca.
API Mágica: CEP e Pix de graça, sem cartão
Lancei a API Mágica: CEP, QR Code Pix, geradores e mais, de graça. Veja como consultar e gerar com Node.js em poucas linhas.
Resend: enviando e recebendo e-mails com Node.js
Tutorial da Resend com Node.js: criar a chave, enviar com fetch, verificar o domínio e receber e-mails por webhook conferindo a assinatura.