Pular para o conteúdo
Node.js

Node.js: áudio como ligação pela SMSMais

Ilustração colorida de um unicórnio de crina luminosa falando dentro de um telefone antigo, com ondas sonoras douradas viajando por um fio de luz até um celular, uma coruja mágica carregando um disco brilhante e um castelo ao fundo

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:

CampoO que ele responde
entregaSe a plataforma conseguiu passar o pedido adiante
entrega_vozSe a pessoa atendeu e ouviu a gravação

Quem responde a pergunta que importa é o segundo. E os valores dele são estes:

ValorO que aconteceu
AnsweredAtendeu e ouviu a gravação 🎉
Not AnsweredO telefone tocou e ninguém atendeu
CalledA ligação está acontecendo agora
WaitingAinda na fila, não discou
Invalid audioNão deu para baixar ou tocar o seu MP3
FailA 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?
É o mesmo endereço e a mesma chave: 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?
Não, e é aqui que muita gente trava. O campo 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?
Provavelmente o áudio não pôde ser baixado. O envio responde 200 normalmente porque a plataforma só aceitou o pedido; o problema aparece depois, no campo 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?
Consultando 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?
Sim. Basta acrescentar o campo 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?
O 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