Node.js: enviando SMS pela Zenvia
Olá meus Unicórnios! 🦄✨
Enviar um SMS parece a tarefa mais simples do mundo, né? 😅 Você tem o número, tem a mensagem, tem a API. É só juntar os três e mandar. Foi exatamente o que eu pensei quando precisei disparar um código de confirmação por SMS num sistema meu — daqueles de seis dígitos, que chega no celular da pessoa para provar que o telefone é dela mesmo.
Pois é. O envio em si é umas dez linhas de código. O resto do tempo eu gastei descobrindo que a Zenvia é bem específica sobre o formato do número, que o SMS tem um limite de caracteres que ninguém avisa mas cobra, e que uma resposta HTTP 200 não quer dizer, de jeito nenhum, que a mensagem foi enviada. 🤯
Neste artigo eu monto do zero um script Node.js que envia SMS pela Zenvia. Sem SDK, sem axios, sem instalar nada — o fetch que já vem no Node dá conta. Um arquivo só, de cima para baixo.
📮 O endpoint e o que ele espera
A Zenvia trabalha com o conceito de canais: SMS, WhatsApp, RCS. Cada canal tem seu endereço, e o do SMS é este:
POST https://api.zenvia.com/v2/channels/sms/messages
A autenticação é a primeira coisa que me pegou. Eu já estava com o dedo pronto para escrever Authorization: Bearer, que é o que quase toda API usa hoje. A Zenvia não usa. Ela tem um cabeçalho próprio:
X-API-TOKEN: seu_token_aqui
E o corpo da requisição é um JSON com três informações: quem manda, para quem vai, e o conteúdo — que é uma lista, não um texto solto:
{
"from": "minhaloja",
"to": "5511988887777",
"contents": [
{ "type": "text", "text": "Seu codigo de confirmacao e 123456" }
]
}
Repare que contents é um array de objetos, cada um com um type. Para SMS o tipo é sempre text e o array tem um item só — mas a estrutura é essa porque os outros canais aceitam imagem, arquivo, botão. É a API sendo genérica, e a gente pagando com um par de colchetes a mais. 😄
🔑 O token nunca fica no código
Antes de qualquer linha de envio, o combinado: token de API não se escreve no arquivo. Nem "provisoriamente para testar", nem "o leitor troca depois". Ele sai de uma variável de ambiente, e o script recusa a rodar se ela não existir:
// A URL da API. Em teste, aponte ZENVIA_URL para um servidor local.
const URL_ZENVIA = process.env.ZENVIA_URL || 'https://api.zenvia.com/v2/channels/sms/messages';
// O token NUNCA fica escrito no codigo: sai da variavel de ambiente.
const TOKEN = process.env.ZENVIA_TOKEN;
// O remetente cadastrado no painel da Zenvia.
const REMETENTE = process.env.ZENVIA_REMETENTE || 'minhaloja';
Deixar a URL_ZENVIA vir do ambiente também tem um motivo prático que você vai entender lá na frente: é o que me deixou testar o script inteiro sem gastar um centavo de crédito. 😌
Para rodar, no Linux ou no Mac:
export ZENVIA_TOKEN="seu_token_aqui"
node enviar-sms.js
📞 O telefone: só dígitos, com o 55 na frente
Aqui mora a armadilha que mais me custou tempo. A Zenvia quer o número no formato internacional, só dígitos. Sem +, sem parênteses, sem traço, sem espaço.
Ou seja: aquele +55 (11) 98888-7777 que o cliente digitou bonitinho no formulário precisa virar 5511988887777 antes de sair da sua máquina.
E tem um detalhe cruel no meio disso: o zero do DDD. Muita gente digita 011 por hábito de telefone fixo antigo. Se você só tirar os caracteres especiais, esse zero sobrevive, o número fica com um dígito a mais e a Zenvia recusa como inválido — com uma mensagem de erro que não diz qual é o problema. 😳
// Deixa o telefone no formato que a Zenvia exige: so digitos, com o codigo
// do pais na frente. Nada de "+", parenteses, traco ou espaco.
function normalizarTelefone(telefone) {
// Tira tudo que nao for digito. O "+55 (11) 98888-7777" vira "5511988887777".
let numero = String(telefone).replace(/[^0-9]/g, '');
// O zero de discagem do DDD ("011") tem de sair, senao o numero fica com
// um digito a mais e a Zenvia recusa como invalido.
if (numero.length > 11 && numero.startsWith('0')) {
numero = numero.substring(1);
}
if (numero.length === 11 && numero.startsWith('0')) {
numero = numero.substring(1);
}
// Sem o codigo do pais, o numero tem 10 (fixo) ou 11 (celular) digitos.
// Nesse caso somamos o 55 do Brasil.
if (numero.length === 10 || numero.length === 11) {
numero = '55' + numero;
}
// Agora tem de ter 12 ou 13 digitos: 55 + DDD + numero.
if (numero.length !== 12 && numero.length !== 13) {
throw new Error('Telefone invalido: ' + telefone);
}
return numero;
}
Aquela última conferência é a que salva o seu crédito. Sem ela, um número truncado vai para a API, é recusado, e você fica olhando o log tentando entender. Melhor descobrir antes de sair da sua máquina.
Rodando com alguns formatos diferentes — todos números fictícios, é claro:
"+55 (11) 98888-7777" -> 5511988887777
"11988887777" -> 5511988887777
"011988887777" -> 5511988887777
"5511988887777" -> 5511988887777
"(11) 3333-4444" -> 551133334444
"9999" -> Telefone invalido: 9999
Repare no (11) 3333-4444: é um fixo, tem 10 dígitos em vez de 11, e mesmo assim passa. E o 9999 é barrado antes de virar requisição. 🙌
✂️ 160 caracteres — e o que acontece ao passar
Esta aqui eu não sabia, e ela custa dinheiro de verdade.
Um SMS cabe 160 caracteres. Passou disso, a mensagem não é recusada — ela é partida em pedaços pela operadora e você paga por cada pedaço. E tem uma pegadinha dentro da pegadinha: quando a mensagem é partida, cada parte precisa de um cabecalho para o celular remontar tudo na ordem certa, e esse cabeçalho come espaço. Aí cada pedaço passa a caber só 153 caracteres, não 160.
Ou seja: uma mensagem de 161 caracteres não vira "160 + 1". Vira dois SMS de 153, e a sua conta dobra por causa de um caractere. 😅
// Conta quantos SMS a mensagem vai gastar. Cada SMS cabe 160 caracteres;
// passou disso, a operadora parte em pedacos de 153 e cobra cada um.
function contarSms(texto) {
if (texto.length <= 160) {
return 1;
}
return Math.ceil(texto.length / 153);
}
Conferindo com a minha mensagem de confirmação e com uma de 161 caracteres:
curta 47 caracteres -> 1 SMS
longa 161 caracteres -> 2 SMS
🔤 O acento que vira interrogação
Irmã gêmea da anterior. O SMS tem uma tabela de caracteres própria, a GSM 03.38, e ela é de 1990 e poucos — nasceu sem pensar em português. Não tem ã, não tem ç, não tem õ.
Quando você manda uma letra que não está nessa tabela, acontece uma de duas coisas, e nenhuma é boa: ou a letra chega como ? no celular da pessoa, ou a operadora troca a mensagem inteira para uma codificação maior (UCS-2) — e nessa o limite despenca de 160 para 70 caracteres. Um acento, e a sua mensagem vira três SMS. 😱
A solução mais tranquila é tirar os acentos antes de enviar. "Confirmacao" sem cedilha é feio, eu sei, mas chega inteiro e custa um SMS só:
// Troca acento por letra sem acento. A tabela padrao do SMS (GSM 03.38) nao
// tem "ã" nem "ç": sem esta troca, quem recebe ve "?" no lugar da letra.
function tirarAcentos(texto) {
return texto.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
O truque do normalize('NFD') é elegante: ele separa a letra do acento em dois caracteres distintos — o á vira a mais um "acento agudo solto". Aí a expressão regular apaga só os acentos soltos, na faixa \u0300-\u036f, e sobra a letra limpa.
Rodando:
acentos: Confirmacao de inscricao: atencao, nao e cobranca
acentos: Agua, pao e acucar
📨 O envio
Com as três funções acima prontas, o envio fica curto. Monta o cabeçalho, monta o corpo, dispara:
// Monta o cabecalho de autenticacao. A Zenvia usa o header X-API-TOKEN,
// e nao o Authorization: Basic de outras APIs.
function montarCabecalhos() {
if (!TOKEN) {
throw new Error('Defina a variavel de ambiente ZENVIA_TOKEN');
}
return {
'accept': 'application/json',
'content-type': 'application/json',
'X-API-TOKEN': TOKEN
};
}
E a função que faz o trabalho:
// Envia o SMS de verdade.
async function enviarSms(telefone, mensagem) {
const destino = normalizarTelefone(telefone);
const texto = tirarAcentos(mensagem);
const corpo = {
from: REMETENTE,
to: destino,
contents: [
{ type: 'text', text: texto }
]
};
const resposta = await fetch(URL_ZENVIA, {
method: 'POST',
headers: montarCabecalhos(),
body: JSON.stringify(corpo)
});
// Le o corpo SEMPRE, mesmo quando deu erro: e la que a Zenvia
// explica o motivo da recusa.
const retorno = await resposta.json();
if (!resposta.ok) {
throw new Error('Zenvia recusou (HTTP ' + resposta.status + '): ' + JSON.stringify(retorno));
}
// Armadilha: HTTP 200 nao quer dizer entregue. Se vier um campo de erro
// no corpo, a mensagem NAO saiu, mesmo com o status verde.
if (retorno.code || retorno.error) {
throw new Error('Zenvia respondeu 200 com erro: ' + JSON.stringify(retorno));
}
return retorno;
}
🎭 O 200 que não é sucesso
Se você só for ler um pedaço deste artigo, leia este. 🙏
A primeira versão do meu código tinha uma verificação só: if (!resposta.ok). Se o status fosse 200, eu dava o envio por bem-sucedido e ia dormir feliz.
Erro meu. 😳 A Zenvia responde HTTP 200 em situações que não são sucesso nenhum — a requisição chegou bem formada, ela entendeu tudo, e mesmo assim não mandou a mensagem. Saldo insuficiente é o caso clássico. O status vem verde, e o motivo real vem dentro do corpo, num campo code.
Por isso as duas verificações. A primeira pega o erro de protocolo; a segunda pega o erro que se disfarça de sucesso. Sem a segunda, o seu sistema registra "SMS enviado" no banco enquanto o cliente espera um código que nunca vai chegar. É o pior tipo de bug: silencioso, e você só descobre pelo suporte reclamando.
Repare também no await resposta.json() vindo antes do if. É de propósito: se você só ler o corpo no caminho feliz, joga fora exatamente a explicação de que precisa quando dá errado.
🧪 Um servidor falso para não gastar crédito
Cada SMS enviado é cobrado, e chega no celular de alguém de verdade. Ficar disparando mensagem para conferir se o código está certo é caro e é chato para quem recebe. 😬
A saída é subir uma "Zenvia falsa": um servidor de vinte linhas que responde no mesmo formato documentado da API. Como a URL_ZENVIA vem do ambiente, é só apontar o script para ele:
// Servidor falso: responde no formato documentado da Zenvia.
const http = require('http');
const servidor = http.createServer((req, res) => {
let bruto = '';
req.on('data', (p) => { bruto += p; });
req.on('end', () => {
const token = req.headers['x-api-token'];
const corpo = JSON.parse(bruto || '{}');
res.setHeader('content-type', 'application/json');
if (token !== 'token-de-teste') {
res.statusCode = 401;
return res.end(JSON.stringify({ code: 'UNAUTHORIZED', message: 'Invalid token' }));
}
if (corpo.to === '5511900000001') {
res.statusCode = 400;
return res.end(JSON.stringify({ code: 'VALIDATION_ERROR', message: 'Invalid destination' }));
}
if (corpo.to === '5511900000002') {
// O caso traicoeiro: HTTP 200 com erro no corpo.
res.statusCode = 200;
return res.end(JSON.stringify({ code: 'INSUFFICIENT_BALANCE', message: 'No balance' }));
}
res.statusCode = 200;
res.end(JSON.stringify({ id: 'abc123', from: corpo.from, to: corpo.to, contents: corpo.contents }));
});
});
servidor.listen(3412, () => console.log('zenvia falsa na 3412'));
O melhor dessa brincadeira é que os caminhos de erro — justamente os difíceis de reproduzir com a API de verdade — ficam a um comando de distância. Ficar sem saldo de propósito para testar o tratamento de erro não é lá muito prático, né? 😄
Apontando o script para ele:
export ZENVIA_URL="http://127.0.0.1:3412/v2/channels/sms/messages"
export ZENVIA_TOKEN="token-de-teste"
node enviar-sms.js
E os quatro cenários, um atrás do outro:
Destino normalizado: 5511900000000
SMS gastos: 1
Enviado! id = abc123
Falhou: Zenvia recusou (HTTP 401): {"code":"UNAUTHORIZED","message":"Invalid token"}
Falhou: Zenvia recusou (HTTP 400): {"code":"VALIDATION_ERROR","message":"Invalid destination"}
Falhou: Zenvia respondeu 200 com erro: {"code":"INSUFFICIENT_BALANCE","message":"No balance"}
O último é o que me interessa mais: status 200, e ainda assim tratado como falha. É a segunda verificação fazendo o trabalho dela. 🎯
🧩 O arquivo inteiro
Fechando com o main(), que só junta as peças. O número é fictício — troque pelo seu antes de rodar de verdade:
async function main() {
// Numero ficticio, so para o exemplo.
const telefone = '+55 (11) 90000-0000';
const mensagem = 'Minha Loja - seu codigo de confirmacao e 123456';
console.log('Destino normalizado:', normalizarTelefone(telefone));
console.log('SMS gastos:', contarSms(mensagem));
try {
const retorno = await enviarSms(telefone, mensagem);
console.log('Enviado! id =', retorno.id);
} catch (erro) {
console.error('Falhou:', erro.message);
process.exitCode = 1;
}
}
main();
E é isso: um arquivo, zero dependências, quatro funções curtas. O process.exitCode = 1 no catch é o detalhe que faz diferença quando o script roda dentro de um agendamento — sem ele, o processo termina com sucesso mesmo tendo falhado, e quem chamou nunca fica sabendo. 🤫
🗺️ O resumo das armadilhas
Quatro coisas que eu não sabia quando comecei, e que valem mais que o código em si:
O telefone vai só com dígitos e com o 55 na frente — e o zero do DDD tem de sair, senão o número é recusado sem explicação. O limite é 160 caracteres, e passar dele não dá erro: parte a mensagem em pedaços de 153 e cobra cada um. O acento não existe na tabela do SMS: ou vira ?, ou derruba o limite para 70 caracteres. E o HTTP 200 não é garantia de nada — o motivo da recusa pode vir dentro do corpo, com o status todo verde.
É essa última que eu levo comigo para qualquer integração hoje: ler o corpo da resposta sempre, mesmo quando o status diz que deu tudo certo. 💜
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.