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.
| Tipo | Urgência | Horário | Resposta esperada | Base legal típica |
|---|---|---|---|---|
| Cobrança (boleto, Pix, fatura) | Média | Comercial | Clique no link de pagamento | Execução do contrato |
| Aviso transacional (pedido, entrega, consulta) | Alta | No momento do evento | Nenhuma ou confirmação | Execução do contrato |
| Campanha (promoção, evento, reativação) | Baixa | Comercial, dias úteis | Clique ou resposta | Consentimento |
| OTP (código de verificação) | Imediata | Qualquer | Digitação do código | Execuçã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.

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.
![]()









