Como enviar SMS em massa com Node.js: campanhas, cobranças e avisos

Capa do artigo Como enviar SMS em massa com Node.js: campanhas, cobranças e avisos

Enviar SMS em massa continua sendo a forma mais direta de alcançar quem não abre e-mail e não instalou o seu aplicativo: a mensagem chega em qualquer aparelho, sem depender de internet, e costuma ser lida em segundos. Cobrança de boleto perto do vencimento, aviso de entrega, lembrete de consulta e código de verificação têm algo em comum: precisam chegar na hora certa, para a pessoa certa, sem gastar crédito à toa.

Com Node.js e a API de SMS da APIBrasil, a chamada de envio cabe em poucas linhas. O trabalho de verdade está em volta dela: lista com consentimento registrado, controle de concorrência, personalização que não estoura 160 caracteres, acompanhamento de status por webhook e um freio automático quando o saldo acaba ou a operadora começa a recusar.

O roteiro abaixo segue a ordem em que esses problemas aparecem em produção. Todo o código roda em Node 18 ou superior, com o fetch nativo, e lê o token de uma variável de ambiente, nunca do código-fonte.

Casos de uso: cobrança, aviso, campanha e OTP

Nem todo disparo em lote é igual. Uma cobrança tolera algumas horas de atraso, mas precisa de um link que funcione; um código de verificação precisa chegar em segundos; uma campanha pode esperar o horário comercial e precisa de descadastro fácil. Separar esses perfis antes de escrever código evita a fila única em que o OTP fica atrás de milhares de mensagens promocionais.

TipoUrgênciaHorárioResposta esperadaBase legal típica
Cobrança (boleto, Pix, fatura)MédiaComercialClique no link de pagamentoExecução do contrato
Aviso transacional (pedido, entrega, consulta)AltaNo momento do eventoNenhuma ou confirmaçãoExecução do contrato
Campanha (promoção, evento, reativação)BaixaComercial, dias úteisClique ou respostaConsentimento
OTP (código de verificação)ImediataQualquerDigitação do códigoExecução do contrato

No catálogo da APIBrasil existem produtos separados para SMS avulso, SMS marketing e SMS OTP, todos no mesmo endpoint de envio. O que muda é o campo tipo no corpo (por exemplo, “sms-marketing” para campanhas) e a cobrança. O caso do código de verificação está detalhado em verificação por SMS com OTP na APIBrasil; aqui o foco são cobranças, avisos e campanhas.

Eventos são um caso que mistura os perfis: quem vai transmitir eventos ao vivo precisa lembrar os convidados do horário no dia anterior (campanha) e mandar o link minutos antes de começar (aviso). O SMS resolve as duas pontas porque chega mesmo para quem não está nas redes sociais naquele momento.

Lista de contatos, opt-in e LGPD

Quem quer enviar SMS em massa com segurança decide a qualidade da campanha antes do primeiro disparo, na lista. Para campanhas, trabalhe apenas com números que deram consentimento, e registre quando, onde e com qual texto a pessoa aceitou receber mensagens. Para cobranças e avisos ligados a um contrato, a base costuma ser a própria execução do contrato, mas a mensagem precisa se limitar a isso: um aviso de entrega não é lugar para oferta.

Uma tabela de contatos mínima para operar com segurança tem estas colunas:

  • telefone no formato internacional, só dígitos (5531999999999), que é o que a API espera no campo number;
  • nome para personalização e origem do cadastro (formulário, checkout, importação);
  • opt_in_em e opt_in_texto, para provar o consentimento;
  • descadastrado_em, preenchido assim que a pessoa responder PARAR ou pedir saída por qualquer canal.

Antes de cada lote, normalize os números, remova duplicados e filtre quem se descadastrou. Uma função simples evita boa parte dos erros de formato que viram crédito perdido:

// contatos.js
export function normalizarTelefone(bruto) {
  const digitos = String(bruto).replace(/\D/g, "");
  const semDdi = digitos.startsWith("55") ? digitos.slice(2) : digitos;
  // DDD (2 dígitos, sem zero) + número com 8 ou 9 dígitos
  if (!/^[1-9]{2}9?\d{8}$/.test(semDdi)) return null;
  return "55" + semDdi;
}

export function prepararLista(contatos) {
  const vistos = new Set();
  return contatos
    .filter((c) => !c.descadastrado_em)
    .map((c) => ({ ...c, telefone: normalizarTelefone(c.telefone) }))
    .filter((c) => c.telefone && !vistos.has(c.telefone) && vistos.add(c.telefone));
}

Inclua um texto de saída no fim das campanhas (“Responda PARAR para não receber mais”) e ative o recebimento de respostas no envio, assunto da seção de webhooks. Descadastro que não funciona leva a denúncias na operadora e bloqueio do remetente.

Infográfico com checklist de SMS em massa: opt-in e descadastro, fila com limite de concorrência, mensagem até 160 caracteres, horário comercial e status de entrega por webhook

Como enviar SMS em massa com fila: p-limit e BullMQ

O endpoint de envio da APIBrasil é POST /sms/send/credits, autenticado apenas com o header Authorization: Bearer. O corpo leva number e message; a documentação mostra ainda operator, user_reply (para aceitar respostas) e webhook_url (para receber status). Confira na página da API quais desses campos o seu plano exige.

A função de envio

Um detalhe que derruba integrações: a API pode responder HTTP 200 com erro no corpo. Por isso a função abaixo só considera sucesso quando error for false, e anexa o status HTTP ao erro para que a fila saiba se vale a pena tentar de novo.

// sms.js
const BASE_URL = "https://gateway.apibrasil.io/api/v2";

export async function enviarSms({ number, message, webhookUrl }) {
  const resposta = await fetch(`${BASE_URL}/sms/send/credits`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.APIBRASIL_TOKEN}`,
    },
    body: JSON.stringify({
      number,                  // 5531999999999 (DDI + DDD + número)
      message,                 // texto já personalizado
      user_reply: true,        // aceita resposta do destinatário (PARAR, SIM...)
      webhook_url: webhookUrl, // opcional: URL que recebe os status
    }),
    signal: AbortSignal.timeout(15_000),
  });

  const corpo = await resposta.json().catch(() => ({}));

  // Erro pode vir com HTTP 200: sempre confira o campo "error"
  if (!resposta.ok || corpo.error) {
    const erro = new Error(corpo.message || `HTTP ${resposta.status}`);
    erro.status = resposta.status;
    throw erro;
  }

  // O id da mensagem vem dentro de response.data no exemplo da documentação;
  // confira o caminho exato na página da API antes de depender dele.
  return { id: corpo.response?.data?.id ?? corpo.data?.id, saldo: corpo.balance, corpo };
}

Lote com p-limit

Para lotes de centenas a poucos milhares de mensagens, uma fila em memória com limite de concorrência resolve. A biblioteca p-limit garante que só N envios aconteçam ao mesmo tempo, e Promise.allSettled deixa o lote terminar mesmo que alguns números falhem.

// lote.js
import pLimit from "p-limit";
import { enviarSms } from "./sms.js";
import { prepararLista } from "./contatos.js";

const limite = pLimit(5); // no máximo 5 envios simultâneos

export async function enviarLote(contatos, montarMensagem) {
  const lista = prepararLista(contatos);

  const resultados = await Promise.allSettled(
    lista.map((contato) =>
      limite(async () => {
        const r = await enviarSms({
          number: contato.telefone,
          message: montarMensagem(contato),
          webhookUrl: process.env.SMS_WEBHOOK_URL,
        });
        return { telefone: contato.telefone, id: r.id };
      })
    )
  );

  const ok = resultados.filter((r) => r.status === "fulfilled").map((r) => r.value);
  const falhas = resultados.filter((r) => r.status === "rejected").map((r) => r.reason);
  console.log(`enviados: ${ok.length} | falhas: ${falhas.length}`);
  return { ok, falhas }; // persista os ids: são a chave para cruzar com o webhook
}

Comece com concorrência baixa (3 a 5) e suba aos poucos observando erros e tempo de resposta. Se aparecer HTTP 402, pare o lote: é saldo insuficiente, e insistir só gera mais falhas. Para erros 4xx em geral não há retry; para timeout e 5xx, tente de novo com espera crescente e um limite pequeno de tentativas.

Quando migrar para BullMQ

Quando o lote precisa sobreviver a um restart, rodar em mais de um worker ou respeitar um limite de mensagens por minuto, troque a fila em memória por BullMQ com Redis. Cada contato vira um job com retentativas e backoff configurados na fila:

// fila-bullmq.js
import { Queue, Worker, UnrecoverableError } from "bullmq";
import { enviarSms } from "./sms.js";

const conexao = { connection: { host: "127.0.0.1", port: 6379 } };
const fila = new Queue("sms", conexao);

// Produtor: um job por contato
export async function enfileirar(lista, montarMensagem) {
  for (const contato of lista) {
    await fila.add(
      "enviar",
      { number: contato.telefone, message: montarMensagem(contato) },
      { attempts: 3, backoff: { type: "exponential", delay: 5_000 }, removeOnComplete: true }
    );
  }
}

// Consumidor: 5 em paralelo, no máximo 60 envios por minuto
new Worker(
  "sms",
  async (job) => {
    try {
      return await enviarSms({ ...job.data, webhookUrl: process.env.SMS_WEBHOOK_URL });
    } catch (erro) {
      // 4xx (número inválido, saldo, token) não melhora com retry
      if (erro.status && erro.status < 500) throw new UnrecoverableError(erro.message);
      throw erro; // timeout/5xx: BullMQ reagenda com backoff
    }
  },
  { ...conexao, concurrency: 5, limiter: { max: 60, duration: 60_000 } }
);

O limiter protege o saldo de um bug em loop, e um jobId derivado do contato e da campanha impede que a mesma mensagem entre duas vezes na fila.

Personalização e encurtador de link

Mensagem genérica cheira a spam. Nome, valor, data e um link curto resolvem a maior parte dos casos, desde que o texto final fique dentro de 160 caracteres. Acima disso a mensagem é fatiada e cobrada em partes; com acentos e emojis, o limite por parte cai ainda mais, então teste o texto real no seu próprio número antes de disparar.

// template.js
export function montarCobranca({ nome, valor, vencimento, linkCurto }) {
  const primeiroNome = nome.trim().split(" ")[0];
  const texto = `${primeiroNome}, seu boleto de R$ ${valor} vence em ${vencimento}. 2a via: ${linkCurto} Responda PARAR p/ sair`;
  if (texto.length > 160) {
    console.warn(`Mensagem com ${texto.length} caracteres para ${primeiroNome}`);
  }
  return texto;
}

O link merece um encurtador próprio: um endpoint /r/:codigo no seu domínio registra o clique (contato, campanha, horário) e redireciona para a página de pagamento. Isso devolve a métrica mais importante da campanha sem depender de terceiros, e um domínio conhecido passa mais confiança em mensagem de cobrança do que um encurtador genérico.

Status de entrega por webhook

A resposta imediata do envio só diz que a mensagem foi aceita. O que acontece depois chega pelo webhook: você informa a URL em webhook_url a cada requisição, e a APIBrasil faz um POST a cada mudança de status. Pela documentação de webhooks de SMS, o corpo é um array JSON com um objeto contendo version, id (o mesmo retornado no envio), status, number e os carimbos created_at, sent_at, delivered_at, error_at e blocked_at. Quando o destinatário responde, o status vem como reply e o texto chega no campo mensagem.

// webhook.js
import express from "express";

const app = express();
app.use(express.json());

// Caminho com segredo: o webhook de SMS não é assinado
app.post("/webhooks/sms/:segredo", (req, res) => {
  if (req.params.segredo !== process.env.SMS_WEBHOOK_SECRET) return res.sendStatus(404);

  const evento = Array.isArray(req.body) ? req.body[0] : req.body;
  if (!evento?.id) return res.sendStatus(400);

  res.sendStatus(200); // responda rápido; o processamento vem depois

  // status documentados: inserted_for_processing, valid, sent_to_carrier, reply
  salvarStatus(evento.id, evento.status, evento); // upsert pela chave (id, status)

  if (evento.status === "reply" && /PARAR|SAIR/i.test(evento.mensagem ?? "")) {
    descadastrar(evento.number);
  }
});

app.listen(3000);

Três cuidados: deduplique pelo par (id, status), porque cada mudança gera um POST novo e reenvios acontecem; confira se o id pertence a uma mensagem sua antes de agir, já que o webhook não traz assinatura (caminho com segredo e HTTPS são o mínimo); e trate sent_to_carrier como “a operadora aceitou”, não como “o aparelho recebeu”, porque a confirmação final pode não vir.

O status reply também abre a porta para pesquisas curtas depois de uma cobrança paga ou de uma entrega: “De 0 a 10, como foi?” com a resposta gravada pelo mesmo webhook. Se parte do seu atendimento roda em outro canal, vale comparar com a abordagem de NPS no WhatsApp e consolidar as duas fontes no mesmo relatório.

Custos e boas práticas

Enviar SMS em massa sem freio é a forma mais rápida de queimar saldo: cada chamada é cobrada, e a resposta devolve balance e tax para você acompanhar o consumo em tempo real. Esses valores chegam como string em formato brasileiro, então converta com cuidado antes de somar. Os preços por produto estão no catálogo da documentação e variam por plano; não vale a pena fixá-los em código.

  • Cheque o saldo antes do lote e pare automaticamente ao receber 402.
  • Nunca repita 4xx: número inválido ou token errado vão falhar de novo e podem gerar nova cobrança.
  • Respeite horário comercial em campanhas e cobranças; avisos transacionais podem sair a qualquer hora, mas só os que o cliente espera.
  • Teste com o seu próprio número antes de cada campanha nova: formato, tamanho e link.
  • Suba o volume aos poucos em um remetente novo e acompanhe falhas por lote.
  • Meça o que importa: enviados, aceitos pela operadora, respostas, cliques e descadastros por campanha.

O token e as URLs de webhook ficam fora do repositório; o guia de variáveis de ambiente com .env mostra como organizar isso por ambiente. Em produção, guarde a resposta de cada envio por alguns dias: é o que permite responder “esse SMS saiu?” sem adivinhar.

Perguntas frequentes

Posso enviar SMS em massa sem autorização do destinatário?

Para campanhas, não: trabalhe apenas com números que deram consentimento registrado e ofereça saída simples, como responder PARAR. Cobranças e avisos ligados a um contrato existente se apoiam na própria relação com o cliente, mas devem se limitar ao assunto do contrato. Mantenha a prova do opt-in e a data do descadastro na sua base.

Quantos SMS consigo enviar por minuto?

Não existe um número único: depende do seu plano, do produto de SMS contratado e do comportamento das operadoras. Comece com concorrência baixa, observe erros e latência e aumente aos poucos. O limiter do BullMQ ou o p-limit deixam esse ritmo configurável sem mudar o resto do código.

Como sei se o SMS foi entregue?

Pelo webhook informado em webhook_url. Os status documentados vão de inserted_for_processing a sent_to_carrier, que indica que a operadora aceitou a mensagem. A confirmação no aparelho depende da operadora e pode não chegar, então trate ausência de erro como bom sinal, não como garantia.

O que acontece se o saldo acabar no meio da campanha?

A API responde com HTTP 402, que significa saldo insuficiente. Repetir a chamada não resolve: pause a fila, recarregue no painel e retome do ponto em que parou usando os ids já persistidos.

SMS ou WhatsApp para avisos em massa?

SMS alcança qualquer celular sem internet e sem aplicativo, o que o torna ideal para cobranças, OTP e avisos críticos. O WhatsApp permite mídia, botões e conversas, mas exige número com o app e um dispositivo conectado. Muitas operações usam os dois: WhatsApp primeiro e SMS como fallback quando a primeira entrega falha.

Próximos passos

Com envio, fila, personalização e webhook no lugar, falta operar: acompanhar métricas por campanha, revisar textos que geram descadastro e manter a lista limpa. Se ainda não tem um token, crie sua conta em platform.apibrasil.com.br, gere o Bearer Token e faça o primeiro envio para o seu próprio número antes de apontar o script para a lista inteira.

Loading

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *