Pular para o conteúdo
Node.js

Node.js: enviando SMS pela Zenvia

Paloma Macetko
Um unicornio de crina colorida segurando um celular com uma mensagem de texto saindo dele, ao lado de uma coruja magica levando um envelope luminoso

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