Pular para o conteúdo
Node.js

Node.js: enviando SMS pela ez4uteam

Paloma Macetko
Unicorno magico segurando um celular do qual saem mensagens de texto voando, ao lado de uma coruja mensageira com um envelope e um castelo ao fundo

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