Pular para o conteúdo
Node.js

Node.js: enviando SMS e lendo respostas na SMSMais

Um unicornio segurando um celular do qual saem baloes de mensagem voando como passaros, ao lado de uma coruja que traz um pergaminho de resposta

Olá meus Unicórnios! 🦄✨

Sabe aquele recurso que parece coisa de 2005 e continua sendo o mais confiável que existe? 😄 Pois é: o SMS. Ele não depende de o cliente ter instalado nada, não depende de internet no celular dele, e chega naquele aparelho simples que a avó usa. Quando o assunto é confirmar consulta, mandar código de acesso ou avisar que o pedido saiu, ele ainda ganha de muita coisa moderna.

Só que enviar é a parte fácil. A parte que dá trabalho é a outra metade: saber se chegou e ouvir o que a pessoa respondeu. Foi aí que eu me enrosquei quando fui integrar a SMSMais, e é exatamente sobre essas duas metades que este artigo fala.

🔑 A autenticação, que é mais simples do que parece

Vamos começar pelo começo, porque é aqui que a maioria dos tutoriais te abandona. A API da SMSMais tem uma URL base só:

https://smsmais.com

E o token da sua conta você pega no painel, no menu Configurações → API. Guarde-o bem: esse token dá acesso à sua conta e ao seu saldo. Se ele vazar, qualquer pessoa manda SMS por sua conta e o prejuízo é seu. Por isso ele nunca vai escrito dentro do código, e a gente já vai ver como fazer isso direito.

A documentação aceita três formas de mandar o token, e elas não são equivalentes:

FormaComo se escreveQuando usar
Bearer (recomendada) Cabeçalho Authorization: Bearer SEU_TOKEN Todos os endpoints. É a que a gente usa aqui.
Basic Usuário e senha da conta /send, /status e /buscar_respostas
Token na URL ?token=SEU_TOKEN Ferramentas de automação que só sabem colocar credencial na URL

Repare no detalhe que morde: o token na URL é a pior das três, e não porque a API seja pior com ela. É que a URL inteira costuma ir parar no log do servidor, no histórico do navegador e no log do proxy pelo caminho. Ela existe porque algumas ferramentas de CRM não sabem mandar cabeçalho, não porque seja uma boa ideia. Se você está escrevendo código, use o Bearer.

E como é um token errado, na prática? Vamos ver, porque essa é a primeira coisa que acontece com todo mundo. Mandando um envio sem cabeçalho nenhum:

curl -s -o resposta.txt -w "%{http_code}\n" \
  -X POST https://smsmais.com/send \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"id":"ext-001","destinations":[{"to":"5511999999999"}],"msg":"oi"}]}'

O que volta é curto e direto:

401
{"error":"unauthorized"}

E com um token inventado, o resultado é exatamente o mesmo: 401 e unauthorized. Isso é proposital, e é uma boa prática de segurança: a API não conta se o problema foi token ausente, token errado ou conta inexistente, porque contar ajudaria quem estivesse tentando adivinhar. Para você, que está integrando, significa uma coisa prática: diante de um 401, não adianta interpretar a mensagem, confira o token no painel.

O /saldo, esse sim, é um pouquinho mais falante:

{"error":"Unauthorized: missing or invalid token"}

📤 Enviando o primeiro SMS

O envio é um POST em /send. O corpo é um JSON com uma lista de mensagens, e cada mensagem tem três coisas: o seu identificador, para quem vai e o texto.

Aqui está o arquivo inteiro. É um arquivo só, de cima para baixo, sem nenhuma biblioteca instalada: o fetch já vem no Node moderno.

// Envia um SMS pela SMSMais e mostra o que a API respondeu.
// Rode assim:  SMSMAIS_TOKEN=SUA_CHAVE_AQUI node enviar-sms.js

const API = process.env.SMSMAIS_URL || "https://smsmais.com";
const TOKEN = process.env.SMSMAIS_TOKEN;

// Deixa o numero so com digitos: "(11) 99999-9999" vira "11999999999".
// Sem isso o parenteses e o traco viajam no JSON e o numero e recusado.
function limparNumero(numero) {
    return String(numero).replace(/\D/g, "");
}

async function enviarSms(destino, texto, idExterno) {
    if (!TOKEN) {
        throw new Error("Falta a variavel de ambiente SMSMAIS_TOKEN.");
    }
    if (texto.length > 160) {
        throw new Error("O texto tem " + texto.length + " caracteres. O limite e 160.");
    }

    const corpo = {
        messages: [
            {
                id: idExterno,                              // seu identificador, e a idempotencia
                destinations: [{ to: limparNumero(destino) }],
                msg: texto
            }
        ]
    };

    const resposta = await fetch(API + "/send", {
        method: "POST",
        headers: {
            "Authorization": "Bearer " + TOKEN,
            "Content-Type": "application/json"
        },
        body: JSON.stringify(corpo)
    });

    const texto_resposta = await resposta.text();

    // Em 401 e 402 o corpo tambem e JSON, entao mostramos o motivo em vez de "erro".
    if (!resposta.ok) {
        throw new Error("A API respondeu HTTP " + resposta.status + ": " + texto_resposta);
    }

    return JSON.parse(texto_resposta);
}

async function main() {
    try {
        const enviadas = await enviarSms("+55 (11) 99999-9999", "Ola! Responda SIM para confirmar.", "pedido-1001");
        console.log("SMS aceito pela SMSMais:");
        console.log(JSON.stringify(enviadas, null, 4));
    } catch (erro) {
        console.error("Nao deu para enviar:", erro.message);
        process.exitCode = 1;
    }
}

main();

Para rodar, o token vai por variável de ambiente, nunca escrito no arquivo:

SMSMAIS_TOKEN=SUA_CHAVE_AQUI node enviar-sms.js

É a mesma ideia de sempre, e vale repetir porque é o erro mais caro deste artigo: token dentro do código vai junto para o repositório, e a partir daí ele está publicado para sempre, mesmo que você o apague no commit seguinte. 😳

Rodando sem definir a variável, o script para na primeira linha e diz o motivo:

Nao deu para enviar: Falta a variavel de ambiente SMSMAIS_TOKEN.

E com o token certo, o que volta é a lista das mensagens aceitas:

SMS aceito pela SMSMais:
[
    {
        "id": 98765,
        "schedule": "2026-08-27 22:41:19",
        "nome": "",
        "to": "5511999999999",
        "msg": "Ola! Responda SIM para confirmar.",
        "externalId": "pedido-1001"
    }
]

Guarde esses dois campos, porque eles são as duas pontas do fio:

  • id é o número interno da SMSMais. Serve para consultar o status depois em /status?uid=98765.
  • externalId é o seu identificador, o mesmo que você mandou em id. É ele que volta nos webhooks, e é por ele que você acha o registro no seu banco.

😅 O número que perdeu o 55 no caminho

Essa eu preciso contar porque foi um bug meu, e daqueles silenciosos.

A regra da API é que o número vai com DDI 55 + DDD + número, e que máscara é tolerada porque só os dígitos são aproveitados. Perfeito. Então eu escrevi aquela função limparNumero, testei com "(11) 99999-9999" e vi sair 11999999999. Limpinho. Só que faltando o 55.

Repare no detalhe cruel: a função fez exatamente o que eu pedi, tirar o que não é dígito. O 55 nunca esteve lá para ser tirado. Ela não tinha como avisar de nada, e o número saiu com cara de certo. A correção é boba, é passar o número já com o DDI:

enviarSms("+55 (11) 99999-9999", "Ola! Responda SIM para confirmar.", "pedido-1001");

E agora o campo to que a API devolve confirma que o número foi inteiro:

"to": "5511999999999",

A lição, que vale para qualquer integração: limpar não é validar. Uma função que remove caracteres nunca vai reclamar do que está faltando. Se o número vem de um cadastro antigo onde ninguém guardou o DDI, é no seu código que ele precisa ser acrescentado, e conferido.

✂️ O limite de 160 caracteres, e por que checar antes

O SMS tem 160 caracteres. Acima disso a SMSMais barra o envio com chars_exceeded e HTTP 422.

Aquele if lá em cima do arquivo existe por causa disso, e não é preciosismo:

if (texto.length > 160) {
    throw new Error("O texto tem " + texto.length + " caracteres. O limite e 160.");
}

Ele evita um erro chato de diagnosticar. Sem ele, você monta o texto, manda para a API, toma um 422 e fica olhando o corpo da resposta tentando adivinhar qual das mensagens do lote estourou. Com ele, o problema aparece na sua máquina, com o número exato de caracteres na mensagem:

Nao deu para enviar: O texto tem 161 caracteres. O limite e 160.

Um caractere. 😅 É sempre um caractere.

📡 Os status de entrega (DLR), que é onde mora a verdade

Se você só for ler um pedaço deste artigo, leia este. 🙏

Aquele 200 que você recebeu no envio não quer dizer que o SMS chegou. Ele quer dizer que a SMSMais aceitou a mensagem e a colocou na fila. É o status queued, e ele é uma promessa, não um recibo.

Quem entrega o recibo é o DLR (Delivery Report), que é a confirmação que a operadora devolve. E ela devolve quando quiser: segundos, ou minutos. Por isso essa informação chega depois, de forma assíncrona, e não na resposta do envio.

Estes são os valores que o campo delivery pode trazer:

deliveryO que significa na vida real
SentA operadora aceitou. Ainda não confirmou nada.
DeliveredChegou no aparelho. É o que você quer ver.
UndeliveredNão chegou: aparelho desligado, fora de área.
ExpiredA operadora tentou até o prazo acabar e desistiu.
RejectedA operadora recusou a mensagem.
InvalidO número não existe ou está fora do formato.
BlockedNúmero bloqueado.
ReleasingEm processamento na plataforma.
WaitingAguardando na fila.
DuplicateMensagem repetida, não reenviada.
Characters ExceededTexto acima do limite.

A distinção que mais confunde é entre Sent e Delivered. Um SMS pode ficar em Sent para sempre e nunca virar Delivered: aconteceu, a operadora aceitou, e a confirmação do aparelho nunca voltou. Se o seu sistema trata Sent como sucesso, ele vai contar como entregue mensagem que talvez ninguém tenha lido.

⚠️ Os erros do envio, e o que fazer com cada um

Antes de o DLR entrar em cena, o envio já tem o seu próprio conjunto de respostas. Elas são o que vem na hora, e nem todas são erro:

statusHTTPO que aconteceu
queued200Aceito, na fila. ✅
held_no_balance200Sem saldo. Fica guardado e envia sozinho na próxima recarga.
optout200A pessoa pediu descadastro. Não envia, e não cobra.
duplicate200ext_id repetido. Não reenviou.
chars_exceeded422Texto acima de 160 caracteres.
invalid_number422Número fora do formato.
nenhum401Token ausente ou inválido.
insufficient_balance402O saldo não cobre o lote inteiro.

Tem duas armadilhas escondidas nessa tabela, e elas são o motivo de eu ter feito questão de colocá-la aqui inteira.

A primeira: quatro desses status vêm com HTTP 200. Se o seu código faz só if (resposta.ok) e comemora, ele vai tratar optout e held_no_balance como envio feito. Não foram: um não vai sair nunca (a pessoa pediu para não receber) e o outro vai sair quando você recarregar, que pode ser semana que vem. Prepare-se para olhar o campo status, não só o código HTTP.

A segunda: o 402 é diferente de todos os outros. No /send, saldo insuficiente recusa a requisição inteira. Nenhuma mensagem do lote é inserida, nem as que caberiam no saldo. E o corpo vem com a conta feita, o que é bem útil para mostrar na tela do seu sistema:

{
    "error": "insufficient_balance",
    "saldo": 1.20,
    "custo": 8.00,
    "preco_sms": 0.08,
    "preco_voz": 0.08,
    "qtd_sms": 100,
    "qtd_voz": 0
}

É a diferença entre "faltou dinheiro" e "faltou R$ 6,80 para os 100 SMS que você tentou mandar". A segunda mensagem o seu usuário entende.

🪝 Recebendo o webhook: os dois avisos que a SMSMais manda

Agora a parte boa. A SMSMais avisa o seu servidor de duas coisas diferentes, e as duas chegam do mesmo jeito: um POST com um JSON, numa URL sua.

O primeiro aviso é o status de entrega:

[
    {
        "externalid": "pedido-1001",
        "to": "5511999999999",
        "delivery": "Delivered",
        "msg": "Ola! Responda SIM para confirmar.",
        "data_entrega": "2026-08-27 22:41:19",
        "data": "2026-08-27 22:41:16"
    }
]

O segundo é a resposta que o destinatário mandou de volta:

[
    {
        "event": "reply",
        "reply_id": 1308,
        "externalid": "pedido-1001",
        "message_id": 98765,
        "campaign_id": 3145950,
        "from": "5511999999999",
        "msg": "SIM",
        "shortcode": "40404",
        "received_at": "2026-08-27 22:41:52"
    }
]

Olhe os dois lado a lado, porque aqui estão as duas coisas que precisam entrar no seu código antes de qualquer lógica:

Um: os dois chegam dentro de um array, mesmo quando há um aviso só. Repare nos colchetes. Se você escrever const aviso = JSON.parse(corpo) e for direto em aviso.delivery, vai receber undefined no primeiro webhook da sua vida, sem erro nenhum, e vai passar a tarde procurando o problema na configuração. 😳

Dois: o que separa um do outro é o campo event. A resposta traz "event": "reply" e o texto em msg; o aviso de entrega não tem event nenhum, e traz delivery. Como os dois batem na mesma URL, é esse campo que decide o caminho.

E repare no que os liga: o externalid é o mesmo nos dois, pedido-1001, que é aquele id que você escolheu lá no envio. É o fio que amarra o envio, a entrega e a resposta ao mesmo registro do seu banco.

O servidor que recebe os dois cabe num arquivo, e também não precisa de nenhuma biblioteca, o http do Node dá conta:

// Recebe os avisos da SMSMais: o status de entrega e a resposta do destinatario.
// Rode assim:  node receber-webhook.js

const http = require("http");

const PORTA = 3099;

// O que cada status de entrega quer dizer, em portugues.
const STATUS = {
    "Delivered":   "entregue no aparelho",
    "Sent":        "aceito pela operadora, ainda sem confirmacao",
    "Undelivered": "nao entregue (aparelho desligado ou fora de area)",
    "Expired":     "o prazo de entrega acabou",
    "Rejected":    "recusado pela operadora",
    "Invalid":     "numero invalido",
    "Blocked":     "numero bloqueado"
};

function tratarEntrega(aviso) {
    const explicacao = STATUS[aviso.delivery] || "status desconhecido: " + aviso.delivery;
    console.log("[entrega] " + aviso.externalid + " para " + aviso.to + " -> " + explicacao);
    console.log("          entregue em " + aviso.data_entrega);
}

function tratarResposta(aviso) {
    console.log("[resposta] " + aviso.from + " respondeu: " + aviso.msg);
    console.log("           era resposta da mensagem " + aviso.externalid + ", recebida em " + aviso.received_at);
}

function tratarAviso(aviso) {
    // O que separa os dois e o campo "event". Sem ele, e o aviso de entrega.
    if (aviso.event === "reply") {
        tratarResposta(aviso);
    } else {
        tratarEntrega(aviso);
    }
}

const servidor = http.createServer(function (req, res) {
    if (req.method !== "POST") {
        res.writeHead(405);
        return res.end("Use POST.");
    }

    let corpo = "";
    req.on("data", function (pedaco) { corpo += pedaco; });

    req.on("end", function () {
        try {
            const recebido = JSON.parse(corpo);

            // A SMSMais manda um array, mesmo quando ha um aviso so.
            // Tratar como objeto unico faz o codigo quebrar no primeiro webhook.
            const avisos = Array.isArray(recebido) ? recebido : [recebido];

            for (let i = 0; i < avisos.length; i++) {
                tratarAviso(avisos[i]);
            }

            // Responda 200 rapido. Enquanto nao houver 200, a plataforma
            // continua marcando o registro para reenvio.
            res.writeHead(200, { "Content-Type": "application/json" });
            res.end(JSON.stringify({ ok: true }));
        } catch (erro) {
            console.error("Payload que nao deu para ler:", erro.message);
            res.writeHead(400);
            res.end("payload invalido");
        }
    });
});

servidor.listen(PORTA, function () {
    console.log("Esperando webhooks da SMSMais em http://0.0.0.0:" + PORTA + "/");
});

Subindo o servidor e mandando nele os três avisos (uma entrega bem sucedida, uma resposta e uma entrega que falhou), é isso que aparece no terminal:

Esperando webhooks da SMSMais em http://0.0.0.0:3099/
[entrega] pedido-1001 para 5511999999999 -> entregue no aparelho
          entregue em 2026-08-27 22:41:19
   -> respondi HTTP 200 {"ok":true}
[resposta] 5511999999999 respondeu: SIM
           era resposta da mensagem pedido-1001, recebida em 2026-08-27 22:41:52
   -> respondi HTTP 200 {"ok":true}
[entrega] pedido-1002 para 5511988888888 -> nao entregue (aparelho desligado ou fora de area)
          entregue em 2026-08-27 22:45:00
   -> respondi HTTP 200 {"ok":true}

Duas linhas desse arquivo merecem atenção, porque cada uma evita um bug específico.

A primeira é o Array.isArray. Ela é a proteção contra aquela armadilha dos colchetes, e faz o código funcionar tanto se vier um array quanto se vier um objeto solto. É uma linha, e ela é a diferença entre o seu webhook funcionar de primeira ou você reescrever a função de tratamento depois.

A segunda é o STATUS[aviso.delivery] || "status desconhecido". Aquela tabela tem onze valores, e eu mapeei sete. Se chegar um dos outros quatro, o código não quebra nem finge que entendeu: ele imprime o nome do status que não conhece. É bem melhor descobrir assim do que por uma linha em branco no log.

🔁 Quem não pode receber webhook: o polling

Webhook exige um servidor com endereço público, e nem todo mundo tem isso na hora que precisa. Para esse caso existe o /buscar_respostas, que faz o caminho contrário: em vez de a SMSMais te avisar, você pergunta.

curl "https://smsmais.com/buscar_respostas?desde_id=0&limit=50" \
  -H "Authorization: Bearer SUA_CHAVE_AQUI"

O detalhe que faz esse endpoint valer a pena é o desde_id. A resposta traz um campo ultimo_id, e você guarda esse número para mandar como desde_id na consulta seguinte. Assim cada chamada traz só o que é novo, e você não fica reprocessando as mesmas respostas de sempre. É o mesmo raciocínio de um cursor: você não pergunta "o que existe?", pergunta "o que apareceu depois deste ponto?".

O retorno vem com a resposta já correlacionada com a mensagem original, que é o que evita você mesmo ter de cruzar as duas pontas:

{
    "sucesso": true,
    "total": 2,
    "page": 1,
    "pages": 1,
    "nao_lidas": 2,
    "ultimo_id": 3,
    "respostas": [
        {
            "id": 3,
            "destinatario": "5511999999999",
            "resposta": "Respondo sim",
            "shortcode": "9108043",
            "recebida_em": "2026-06-21 05:25:46",
            "lida": false,
            "mensagem_respondida": {
                "id": 139615,
                "ext_id": "ext-001",
                "conteudo": "SMSMais: confirme sua consulta. Responda SIM.",
                "enviada_para": "5511999999999",
                "enviada_em": "2026-06-21 05:25:37"
            },
            "campanha": {
                "id": 9500049,
                "nome": "Confirmacao de consulta"
            }
        }
    ]
}

Tem ainda dois parâmetros que combinam bem: nao_lidas=1 traz só o que você ainda não processou, e marcar_lida=1 marca como lidas as que vieram naquela chamada. Juntos eles dão uma fila simples, sem você precisar guardar estado nenhum do seu lado.

E a maior parte da confusão com SMS vem de uma coisa só: tratar o queued do envio como se fosse a entrega. São três momentos diferentes, ligados por um identificador só, e é isso que ninguém conta. 🙂

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Perguntas frequentes

A resposta 200 do envio significa que o SMS chegou?
Nao. O 200 quer dizer que a SMSMais aceitou a mensagem e a colocou na fila. A confirmacao de que o aparelho recebeu chega depois, de forma assincrona, pelo webhook de status com delivery: "Delivered".
Como diferencio o webhook de entrega do webhook de resposta?
Pelo campo event. O aviso de resposta traz "event": "reply" e o texto em msg, junto com from e received_at. O de entrega nao tem event: tem delivery e data_entrega.
Por que meu SMS foi recusado com chars_exceeded?
O limite e de 160 caracteres por mensagem. Acima disso o envio e barrado com HTTP 422. Vale conferir o tamanho no seu codigo antes de chamar a API, porque assim voce ve o erro na hora em vez de descobrir pelo retorno.
O que e o ext_id e por que ele importa?
E o seu proprio identificador da mensagem. Ele garante idempotencia: se o seu sistema reenviar o mesmo ext_id, a SMSMais responde duplicate em vez de mandar o SMS de novo. Ele tambem volta como externalid nos dois webhooks, e e por ele que voce liga o aviso ao registro do seu banco.
O que acontece se o meu webhook estiver fora do ar?
A plataforma marca o registro para reenvio enquanto nao receber um HTTP 200. Por isso o seu endpoint deve responder 200 rapido, antes de qualquer processamento demorado.
Preciso mandar o numero com o 55 na frente?
Sim: DDI 55 + DDD + numero. Mascara e o sinal de mais sao tolerados porque a API usa apenas os digitos, mas o 55 tem de estar la. Se voce limpar a mascara no seu codigo e passar so o DDD, o 55 some sem aviso nenhum.

Leia também