Node.js: enviando SMS e lendo respostas na SMSMais
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:
| Forma | Como se escreve | Quando 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 emid. É 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:
delivery | O que significa na vida real |
|---|---|
Sent | A operadora aceitou. Ainda não confirmou nada. |
Delivered | Chegou no aparelho. É o que você quer ver. |
Undelivered | Não chegou: aparelho desligado, fora de área. |
Expired | A operadora tentou até o prazo acabar e desistiu. |
Rejected | A operadora recusou a mensagem. |
Invalid | O número não existe ou está fora do formato. |
Blocked | Número bloqueado. |
Releasing | Em processamento na plataforma. |
Waiting | Aguardando na fila. |
Duplicate | Mensagem repetida, não reenviada. |
Characters Exceeded | Texto 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:
status | HTTP | O que aconteceu |
|---|---|---|
queued | 200 | Aceito, na fila. ✅ |
held_no_balance | 200 | Sem saldo. Fica guardado e envia sozinho na próxima recarga. |
optout | 200 | A pessoa pediu descadastro. Não envia, e não cobra. |
duplicate | 200 | ext_id repetido. Não reenviou. |
chars_exceeded | 422 | Texto acima de 160 caracteres. |
invalid_number | 422 | Número fora do formato. |
| nenhum | 401 | Token ausente ou inválido. |
insufficient_balance | 402 | O 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?
delivery: "Delivered".Como diferencio o webhook de entrega do webhook de resposta?
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 que e o ext_id e por que ele importa?
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?
Preciso mandar o numero com o 55 na frente?
Leia também
API Mágica: notificações Web Push no navegador com PHP
Web Push com a API Mágica em PHP puro: o navegador se inscreve, o seu site envia a notificação e a API conta o clique. Sem Composer e sem biblioteca.
API Mágica: CEP e Pix de graça, sem cartão
Lancei a API Mágica: CEP, QR Code Pix, geradores e mais, de graça. Veja como consultar e gerar com Node.js em poucas linhas.
Resend: enviando e recebendo e-mails com Node.js
Tutorial da Resend com Node.js: criar a chave, enviar com fetch, verificar o domínio e receber e-mails por webhook conferindo a assinatura.