Node.js: enviando SMS pela SendPulse
Olá meus Unicórnios! 🦄✨
Sabe quando você olha a documentação de uma API, vê "é só mandar um POST" e acha que vai ser meia hora de trabalho? 😅 Pois é. Enviar SMS pela SendPulse é simples — mas tem três detalhes que só aparecem depois que você já achou que tinha terminado.
O primeiro é que a autenticação é OAuth: você não manda a sua chave junto com o SMS, você troca a chave por um token que expira. O segundo é o telefone, que precisa chegar do jeitinho que a API quer. E o terceiro é o meu preferido, porque é o mais cruel: a SendPulse responde HTTP 200 mesmo quando não enviou nada. 🤯
Neste artigo eu monto um arquivo só, do zero, em Node.js puro — sem axios, sem o SDK oficial, sem nada além do que já vem instalado. É pra qualquer pessoa conseguir ler de cima a baixo e entender.
🔑 Primeiro: trocar a chave por um token
A SendPulse usa client_credentials. Você tem um client_id e um client_secret no painel, manda os dois num POST, e ela devolve um token que vale por um tempo determinado.
Antes de qualquer código, o combinado: credencial não mora no arquivo. Nunca. Nem "só pra testar", nem "depois eu troco". Vai tudo por variável de ambiente:
const API = process.env.SENDPULSE_API || "https://api.sendpulse.com";
let tokenGuardado = "";
let expiraEm = 0;
function agora() {
return Math.floor(Date.now() / 1000);
}
Repare que eu guardo duas coisas: o token e a hora em que ele morre. Essa segunda variável é a diferença entre um código que funciona e um código que funciona na maior parte das vezes — e já explico por quê.
A chamada em si é um POST bem direto:
async function obterToken() {
// Renova 10 minutos ANTES de expirar, nao no segundo exato.
// Um token que ainda vale 3 segundos quando voce confere ja morreu
// quando a requisicao do SMS chega do outro lado.
if (tokenGuardado !== "" && expiraEm - agora() > 600) {
return tokenGuardado;
}
const resposta = await fetch(API + "/oauth/access_token", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: process.env.SENDPULSE_CLIENT_ID,
client_secret: process.env.SENDPULSE_CLIENT_SECRET
})
});
const dados = await resposta.json();
if (!resposta.ok || !dados.access_token) {
throw new Error("Nao consegui autenticar na SendPulse: " + JSON.stringify(dados));
}
tokenGuardado = dados.access_token;
expiraEm = agora() + dados.expires_in;
return tokenGuardado;
}
⏰ A folga de 10 minutos — e o 401 que não faz sentido
Se você só for ler um pedaço deste artigo, leia este. 🙏
O erro óbvio é não guardar o token: pedir um novo a cada SMS funciona, mas é uma requisição a mais sempre, à toa. Então todo mundo guarda. E aí aparece o erro menos óbvio, que é guardar o token e conferir a validade assim:
// NAO faca isso
if (tokenGuardado !== "" && expiraEm > agora()) {
return tokenGuardado;
}
Está errado, e o jeito como ele falha é traiçoeiro. Imagine que o token vence às 14h30m00s. Às 14h29m58s, esse if olha e diz: "ainda vale, pode usar". Aí a sua requisição de SMS sai, atravessa a internet, chega no servidor da SendPulse às 14h30m01s — e leva um 401.
O que torna isso tão confuso é que o erro não é constante. Ele acontece em uma mensagem no meio de centenas, sem nenhum padrão visível, e some quando você tenta reproduzir. Você olha o log, vê um 401 solto, confere as credenciais, elas estão certas, e fica sem entender. 😳
Por isso a folga: eu não pergunto "o token ainda vale?", eu pergunto "o token ainda vale por mais de 10 minutos?". Se faltar menos que isso, pego um novo, mesmo que o antigo ainda estivesse tecnicamente vivo. Um token a mais custa uma requisição; um SMS de confirmação que não chega custa um cliente que não consegue entrar na sua aplicação.
📞 O telefone: tirar o enfeite e colocar o país
A SendPulse quer o número só com dígitos e com o código do país na frente. Só que o seu usuário não digita assim. Ele digita (47) 9 9999-0000, ou 47 99999.0000, ou com espaço no meio, ou com um + na frente — cada um de um jeito.
Então a primeira parte é limpar. Eu poderia usar uma expressão regular, mas como a ideia aqui é ser legível pra quem está começando, vai de for mesmo:
function normalizarTelefone(telefone, ddi) {
let limpo = "";
for (let i = 0; i < telefone.length; i++) {
const caractere = telefone[i];
if (caractere >= "0" && caractere <= "9") {
limpo = limpo + caractere;
}
}
// Se o numero ja comeca com o codigo do pais, nao coloque de novo.
// Prefixar sem conferir gera "555547..." — a API aceita, cobra o envio
// e o SMS nunca chega, porque esse numero nao existe.
if (limpo.indexOf(ddi) === 0) {
return limpo;
}
return ddi + limpo;
}
Aquele if no fim é a parte que interessa. É tentador escrever só return ddi + limpo e seguir a vida — afinal, é "só colocar o código do país". Mas metade dos números que chegam num formulário já vem com o 55, porque o usuário copiou de outro lugar ou o campo tinha máscara.
Sem essa conferência, 5547999990000 vira 555547999990000. E repare no detalhe cruel: a API não reclama. Ela aceita o número, responde que está tudo certo, cobra o envio — e o SMS simplesmente não chega, porque aquele número não existe no mundo. É o pior tipo de bug: sem erro nenhum, e com fatura no fim do mês.
Rodando os quatro casos:
(47) 9 9999-0000 -> 5547999990000
47 99999.0000 -> 5547999990000
5547999990000 -> 5547999990000
+1 (415) 555-0100 -> 14155550100
Os três primeiros chegam no mesmo lugar, escritos de três jeitos diferentes — que é exatamente o que a gente queria. E o quarto mostra que a função não tem nada de brasileira: passando "1" como código do país, ela monta um número americano do mesmo jeito. (Todos os números deste artigo são fictícios.)
✉️ O envio — e os 160 caracteres
Com o token na mão e o telefone limpo, o envio é um POST em /sms/send:
async function enviarSms(telefone, ddi, mensagem) {
if (mensagem.length > 160) {
throw new Error("Mensagem com " + mensagem.length + " caracteres: passa de 160 e vira mais de um SMS.");
}
const token = await obterToken();
const numero = normalizarTelefone(telefone, ddi);
const resposta = await fetch(API + "/sms/send", {
method: "POST",
headers: {
"content-type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify({
sender: process.env.SENDPULSE_REMETENTE,
phones: [numero], // sempre lista, mesmo para um destinatario so
body: mensagem
})
});
const dados = await resposta.json();
// O 200 aqui nao quer dizer "enviado". A SendPulse responde 200 com
// result: false no corpo quando falta saldo ou o remetente nao foi
// aprovado — quem olha so o status HTTP acha que deu certo.
if (dados.result !== true) {
throw new Error("A SendPulse recusou o envio: " + (dados.error_msg || JSON.stringify(dados)));
}
return dados;
}
Duas coisas pra reparar antes de eu falar do 200.
O phones é uma lista, sempre — mesmo quando você manda pra uma pessoa só. Esquecer os colchetes e passar a string direto é o erro de digitação mais comum aqui, e a mensagem de erro que volta não ajuda muito a descobrir isso.
E aquela checagem de 160 caracteres logo na primeira linha existe porque SMS é cobrado por mensagem, não por envio. Passou de 160, a operadora quebra em duas e você paga duas. Como quase todo SMS transacional é um código de confirmação curtinho, estourar esse limite quase sempre é bug — texto duplicado, variável que não foi substituída, nome de cliente gigante concatenado sem querer. Melhor a função gritar na sua cara do que a fatura gritar depois. 😅
🎭 O HTTP 200 que na verdade é um erro
Agora o meu favorito.
Quase toda API que a gente usa avisa que deu errado pelo status HTTP: 400, 401, 422, 500. A gente se acostuma. E é por isso que esse aqui pega tanta gente desprevenida — a SendPulse responde 200 OK e coloca a má notícia dentro do corpo:
{ "result": false, "error_msg": "Insufficient balance" }
Status 200. Requisição bem-sucedida. Nenhum erro em lugar nenhum. E nenhum SMS enviado. 🫠
Se o seu código faz o que todo código faz — if (resposta.ok) e segue em frente — ele vai gravar no banco que a mensagem foi enviada, vai mostrar "código enviado, confira seu celular" pro usuário, e vai ficar esperando um código que nunca vai chegar. O usuário não consegue se cadastrar, abre um chamado dizendo que o site está quebrado, e você vai olhar os logs e ver 200 em tudo.
Daí a linha que importa não ser resposta.ok, e sim:
if (dados.result !== true) {
Repare que é !== true, e não !dados.result. Os dois parecem iguais, mas o !== também derruba o caso em que o campo result veio ausente, ou veio como a string "true", ou como qualquer outra coisa que não seja exatamente o booleano verdadeiro. Quando o assunto é "isso foi enviado de verdade?", eu prefiro exigir a confirmação explícita a assumir que o silêncio é um sim.
▶️ Juntando tudo
Falta só o main() com o try/catch por cima:
async function main() {
try {
const retorno = await enviarSms("(47) 9 9999-0000", "55", "Seu codigo de confirmacao e: 123456");
console.log("SMS aceito pela SendPulse. Campanha:", retorno.data.campaign_id);
} catch (erro) {
console.error("Falhou:", erro.message);
process.exitCode = 1;
}
}
if (require.main === module) {
main();
}
module.exports = { normalizarTelefone, obterToken, enviarSms };
Aquele if (require.main === module) serve pra este arquivo poder ser as duas coisas: se você rodar node enviar-sms.js, ele envia; se você der require nele de outro arquivo, ele só oferece as funções e não sai disparando SMS sozinho na hora do import. 😬
E o process.exitCode = 1 no catch é o que faz o script avisar direito quando falha. Sem ele, o Node encerra com código 0 e qualquer agendador que esteja rodando isso vai achar que deu tudo certo — de novo o mesmo tema do artigo inteiro: falhar em silêncio é pior do que falhar alto.
As três variáveis de ambiente que ele espera:
SENDPULSE_CLIENT_ID=coloque_o_seu_id_aqui
SENDPULSE_CLIENT_SECRET=coloque_o_seu_segredo_aqui
SENDPULSE_REMETENTE=NomeDaSuaLoja
O SENDPULSE_REMETENTE é o nome que aparece como remetente no celular de quem recebe. Ele precisa ser aprovado antes no painel da SendPulse — e enquanto não for, os envios voltam justamente naquele 200 com result: false da seção anterior. Se você acabou de configurar tudo e o primeiro teste falhou sem motivo aparente, é o primeiro lugar pra olhar.
🎯 O resumo das três armadilhas
Se eu tivesse que reduzir este artigo a três linhas:
O token expira, e conferir a validade no limite gera um 401 aleatório que você não consegue reproduzir. Renove com folga.
O telefone precisa do código do país, mas conferir se ele já não está lá é o que evita um número inexistente que a API aceita e cobra.
O 200 não quer dizer enviado. Leia o result no corpo, sempre.
Nenhuma das três está em destaque na documentação. Todas as três aparecem no primeiro dia em que o código vai pra produção 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.