Node.js: enviando SMS pela ez4uteam
Olá meus Unicórnios! 🦄✨
Sabe quando você olha para a documentação de uma API, vê quatro campinhos
num formulário e pensa "isso é coisa de dez minutos"? 😅 Pois é. Enviar SMS
pela ez4uteam é mesmo simples — mas entre o "mandei" e o
"chegou no celular da pessoa" tem uma fileira de armadilhas que só aparecem
quando o SMS não chega. E o pior de todas: a API responde
HTTP 200 mesmo quando dá errado.
Neste artigo eu escrevo do zero, em Node.js puro, um arquivo que envia um
SMS e explica cada linha onde mora uma cilada. Sem axios, sem
classe, sem camada de abstração — o fetch que já vem no Node
resolve tudo. É para ler de cima a baixo. 🙏
📮 Como a API funciona
A ez4uteam recebe um formulário, não JSON. Isso já é a
primeira coisa que pega quem está acostumado com API moderna: se você mandar
Content-Type: application/json com um corpo bonitinho, a API não
reclama de forma clara — ela simplesmente não entende os campos.
São quatro campos, num POST:
account a sua conta na ez4uteam
licensekey a chave de licenca da conta
phoneNumber o telefone do destinatario, so digitos
messageText o texto da mensagem
Em inglês na tabela porque é assim que eles se chamam de verdade no corpo da requisição — se você trocar o nome, não funciona. 😉
📞 A armadilha do telefone
Essa foi a que mais me custou tempo, e é uma armadilha em três camadas. O
campo phoneNumber quer o número só com dígitos,
grudado, no formato código do país + DDD + número:
5547999998888
││ ││ └────────┴── o numero
││ └┴───────────── o DDD
└┴──────────────── o codigo do pais
O número acima é fictício — usei um 99999-8888
inventado em todos os exemplos deste artigo, e recomendo que você faça o mesmo
enquanto testa. Cada envio é cobrado, e um número real que você digitou errado
é um SMS chegando no celular de um desconhecido. 😬
As três camadas:
1. Nada de +, parêntese, traço ou espaço. As
pessoas digitam (47) 9 9999-8888, e é assim que o número chega do
formulário. Tudo isso precisa sair.
2. O zero do DDD tem de sumir. Muita gente escreve
047 por costume de telefone fixo antigo. Se aquele zero ficar, o
número vai com um dígito a mais e a operadora devolve "número inválido" — um
erro que não diz absolutamente nada sobre a causa real.
3. Falta o código do país. Quase todo número que vem de um
cadastro brasileiro tem 10 ou 11 dígitos, sem o 55 na frente. Você
tem de colocar.
A função que resolve as três de uma vez:
// Deixa so os digitos e devolve o telefone no formato que a ez4uteam espera:
// codigo do pais + DDD + numero, tudo grudado, sem "+", sem traco e sem espaco.
function normalizarTelefone(entrada) {
let digitos = "";
for (let i = 0; i < entrada.length; i++) {
const caractere = entrada[i];
if (caractere >= "0" && caractere <= "9") {
digitos = digitos + caractere;
}
}
// O zero do DDD ("047") tem de sair: com ele o numero fica com um digito
// a mais e a operadora devolve numero invalido.
if (digitos.length > 11 && digitos[0] === "0") {
digitos = digitos.substring(1);
}
// 10 ou 11 digitos e numero nacional sem o codigo do pais: falta o 55.
if (digitos.length === 10 || digitos.length === 11) {
digitos = "55" + digitos;
}
return digitos;
}
Repare no for que copia dígito por dígito. Dava para fazer com
uma expressão regular de uma linha, claro — mas assim qualquer pessoa lê e
entende o que está acontecendo, que é o objetivo aqui. 💜
Repare também na ordem dos dois if: o zero sai
antes de o 55 entrar. Se fosse ao contrário, o
047… com 12 dígitos não seria reconhecido como número nacional, o
55 nunca entraria, e você mandaria um número torto para a
operadora.
Passando os formatos que as pessoas realmente digitam, todos caem no mesmo lugar:
"(47) 9 9999-8888" -> 5547999998888
"47 9 9999-8888" -> 5547999998888
"047 99999-8888" -> 5547999998888
"+55 47 99999-8888" -> 5547999998888
"5547999998888" -> 5547999998888
"47 3333-8888" -> 554733338888
O +55 funciona de graça, porque o + é descartado
junto com os outros caracteres não numéricos. E o último é um fixo de 10
dígitos, que também recebe o código do país corretamente. 🎯
📏 O limite de 160 caracteres
SMS cabe em 160 caracteres. Passou disso, a operadora não recusa — ela quebra a mensagem em várias partes e cobra cada uma. Sua mensagem de 200 caracteres vira dois SMS, e você descobre no fim do mês.
Por isso eu prefiro estourar o erro aqui, na minha máquina, do que na fatura:
// O SMS cabe em 160 caracteres. Acima disso a operadora quebra em varias
// partes e cobra cada uma, entao e melhor descobrir aqui do que na fatura.
function contarCaracteres(mensagem) {
return mensagem.length;
}
🔤 O acento que vira interrogação
Essa é sorrateira. Você manda "Inscrição confirmada às 9h" e chega
"Inscri??o confirmada ?s 9h" no celular. Não é a operadora: é o
Content-Type.
Quando você declara application/x-www-form-urlencoded e para
por aí, o servidor do outro lado tem liberdade para interpretar os bytes na
codificação que quiser — e vários assumem latin1. O acerto é dizer, na cara
dura, qual é a codificação:
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
E, para montar o corpo, use o URLSearchParams, que já vem no
Node. Ele escapa acento, espaço e & sozinho:
const corpo = new URLSearchParams();
corpo.set("account", conta);
corpo.set("licensekey", licenca);
corpo.set("phoneNumber", numero);
corpo.set("messageText", mensagem);
Vale ver o que sai dali, porque é bonito: 😍
messageText=Inscri%C3%A7%C3%A3o+confirmada+%C3%A0s+9h
decodificado: Inscrição confirmada às 9h
O ç virou %C3%A7, que é UTF-8 corretamente
escapado. Se você montasse essa string na mão com concatenação, um
& no meio da mensagem cortaria o campo ao meio e o resto do
texto viraria um parâmetro novo — bug clássico, e silencioso. 🙈
🚨 O HTTP 200 que mente
Se você só for ler um pedaço deste artigo, leia este. 🙏
A ez4uteam responde HTTP 200 mesmo quando o envio
falha. Licença inválida? 200. Saldo acabou? 200. Número recusado?
200. Quem diz a verdade é o corpo da resposta, num campo chamado
Result:
{"Result":"OK","MessageId":"123456"} <- deu certo
{"Result":"NOK","ErrorDesc":"Invalid licensekey"} <- deu errado
Ou seja: aquele if (resposta.ok) que a gente escreve no
automático não serve para nada aqui. Ele vai ser verdadeiro
sempre, e o seu código vai anotar no banco que o SMS foi enviado enquanto o
cliente espera um código que nunca chegou. 😳
A verificação certa olha o Result, e usa o
ErrorDesc para dizer o motivo:
// A armadilha principal: mesmo dando errado a API responde HTTP 200.
// Quem diz se o SMS saiu e o campo Result, nao o codigo HTTP.
if (dados.Result !== "OK") {
const motivo = dados.ErrorDesc || texto;
throw new Error("A ez4uteam recusou o envio: " + motivo);
}
Aquele || texto no fim não é firula. Quando alguma coisa vai
muito errado do lado de lá — manutenção, endpoint mudado, proxy no meio —, a
resposta não é o JSON esperado e o ErrorDesc vem vazio. Sem esse
resgate, a sua mensagem de erro seria um undefined solto no log, e
você ficaria sem a menor pista do que aconteceu. Com ele, você vê a resposta
crua e entende na hora.
Pelo mesmo motivo o JSON.parse vai dentro de um
try: se a resposta for uma página de erro em HTML, eu quero uma
mensagem dizendo isso, não um SyntaxError genérico. 🛡️
💻 O arquivo completo
Junta tudo. Um arquivo, de cima a baixo, sem dependência nenhuma além do Node:
// Envio de SMS pela ez4uteam.
// Rode com: node enviar-sms.js "47 9 9999-8888" "Sua mensagem aqui"
const ENDPOINT = process.env.EZ4U_ENDPOINT
|| "https://dashboard.ez4uteam.com/api/wrappers/ez4u/sendSMS";
// Deixa so os digitos e devolve o telefone no formato que a ez4uteam espera:
// codigo do pais + DDD + numero, tudo grudado, sem "+", sem traco e sem espaco.
function normalizarTelefone(entrada) {
let digitos = "";
for (let i = 0; i < entrada.length; i++) {
const caractere = entrada[i];
if (caractere >= "0" && caractere <= "9") {
digitos = digitos + caractere;
}
}
// O zero do DDD ("047") tem de sair: com ele o numero fica com um digito
// a mais e a operadora devolve numero invalido.
if (digitos.length > 11 && digitos[0] === "0") {
digitos = digitos.substring(1);
}
// 10 ou 11 digitos e numero nacional sem o codigo do pais: falta o 55.
if (digitos.length === 10 || digitos.length === 11) {
digitos = "55" + digitos;
}
return digitos;
}
// O SMS cabe em 160 caracteres. Acima disso a operadora quebra em varias
// partes e cobra cada uma, entao e melhor descobrir aqui do que na fatura.
function contarCaracteres(mensagem) {
return mensagem.length;
}
async function enviarSms(telefone, mensagem) {
const conta = process.env.EZ4U_ACCOUNT;
const licenca = process.env.EZ4U_LICENSEKEY;
if (!conta || !licenca) {
throw new Error("Faltam as variaveis de ambiente EZ4U_ACCOUNT e EZ4U_LICENSEKEY.");
}
const numero = normalizarTelefone(telefone);
if (numero.length < 12) {
throw new Error("Telefone invalido depois de normalizar: " + numero);
}
if (contarCaracteres(mensagem) > 160) {
throw new Error("Mensagem com " + contarCaracteres(mensagem)
+ " caracteres. O limite de um SMS e 160.");
}
// A API recebe formulario, nao JSON. O URLSearchParams monta o corpo
// no formato certo e ja escapa acento, espaco e "&" da mensagem.
const corpo = new URLSearchParams();
corpo.set("account", conta);
corpo.set("licensekey", licenca);
corpo.set("phoneNumber", numero);
corpo.set("messageText", mensagem);
const resposta = await fetch(ENDPOINT, {
method: "POST",
headers: {
// Sem charset=UTF-8 aqui, o acento chega como "?" no celular.
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
},
body: corpo.toString(),
});
const texto = await resposta.text();
let dados;
try {
dados = JSON.parse(texto);
} catch (erro) {
throw new Error("A API respondeu algo que nao e JSON: " + texto);
}
// A armadilha principal: mesmo dando errado a API responde HTTP 200.
// Quem diz se o SMS saiu e o campo Result, nao o codigo HTTP.
if (dados.Result !== "OK") {
const motivo = dados.ErrorDesc || texto;
throw new Error("A ez4uteam recusou o envio: " + motivo);
}
return dados;
}
async function main() {
const telefone = process.argv[2];
const mensagem = process.argv[3];
if (!telefone || !mensagem) {
console.log('Uso: node enviar-sms.js "47 9 9999-8888" "Sua mensagem"');
return;
}
try {
const dados = await enviarSms(telefone, mensagem);
console.log("SMS enviado para " + normalizarTelefone(telefone));
console.log("Resposta da API:", JSON.stringify(dados));
} catch (erro) {
console.error("Falhou: " + erro.message);
process.exitCode = 1;
}
}
main();
Duas coisas para reparar no fim. A primeira é que main() só lê
os argumentos e chama enviarSms() — toda a lógica está na função,
então dá para importar este arquivo em outro lugar sem herdar a linha de
comando. A segunda é o process.exitCode = 1 em vez de um
process.exit(1) seco: assim o Node termina de escrever a saída
antes de encerrar, e você não perde a mensagem de erro. 🎈
🧪 Rodando sem gastar SMS
Aqui vai o truque que eu mais uso quando integro API que cobra por chamada: uma cópia falsa do serviço, respondendo no formato real, dentro da minha própria máquina. São vinte linhas.
const http = require("http");
const servidor = http.createServer(function (req, res) {
let corpo = "";
req.on("data", function (p) { corpo += p; });
req.on("end", function () {
const campos = new URLSearchParams(corpo);
// A graca esta aqui: responde 200 ate quando recusa o envio,
// exatamente como a API de verdade faz.
res.writeHead(200, { "Content-Type": "application/json" });
if (campos.get("licensekey") !== "licenca-de-teste") {
res.end(JSON.stringify({ Result: "NOK", ErrorDesc: "Invalid licensekey" }));
return;
}
res.end(JSON.stringify({ Result: "OK", MessageId: "123456" }));
});
});
servidor.listen(3399);
É por isso que o ENDPOINT, lá no topo do arquivo, lê uma
variável de ambiente antes de cair no endereço oficial. Apontando para a cópia
falsa, o mesmo código roda inteiro:
export EZ4U_ENDPOINT="http://127.0.0.1:3399/api/wrappers/ez4u/sendSMS"
export EZ4U_ACCOUNT="conta-de-teste"
export EZ4U_LICENSEKEY="licenca-de-teste"
node enviar-sms.js "(47) 9 9999-8888" "Seu codigo e 4821. Nao compartilhe."
E a saída, com o servidor falso imprimindo o que recebeu:
recebeu phoneNumber=5547999998888 messageText=Seu codigo e 4821. Nao compartilhe.
SMS enviado para 5547999998888
Resposta da API: {"Result":"OK","MessageId":"123456"}
Repare que o (47) 9 9999-8888 chegou do outro lado como
5547999998888. É a normalizarTelefone() fazendo o
trabalho dela, e agora dá para ver isso acontecendo. 🪄
O melhor da cópia falsa é que ela torna fácil justamente o que é difícil de provar com a API de verdade: os caminhos de erro. Trocando só a licença:
recebeu phoneNumber=5547999998888 messageText=Teste
Falhou: A ez4uteam recusou o envio: Invalid licensekey
Um HTTP 200, e ainda assim o programa avisa que o SMS não saiu
e sai com código 1. Era exatamente esse o comportamento que eu queria garantir.
✅
E os dois erros que nem chegam a bater na rede, porque o arquivo barra antes:
Falhou: Mensagem com 161 caracteres. O limite de um SMS e 160.
Falhou: Faltam as variaveis de ambiente EZ4U_ACCOUNT e EZ4U_LICENSEKEY.
🎁 Resumo das ciladas
As quatro que fazem o SMS não chegar, na ordem em que costumam aparecer:
O telefone vai só com dígitos, sem o zero do DDD e com o
55 na frente. A codificação precisa do
charset=UTF-8 no cabeçalho, senão o acento vira interrogação.
O tamanho passa de 160 e a operadora cobra duas vezes sem
avisar. E o HTTP 200 não quer dizer nada: quem
manda é o Result no corpo.
É essa última que separa a integração que parece funcionar da que funciona de verdade. 💫
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.