Cenário comum: sua aplicação faz checkout, salva o pedido no banco, e depois chama a API para mandar WhatsApp ao cliente. Tudo funciona em desenvolvimento. Em produção, um dia o gateway WhatsApp está fora 30 segundos no momento exato em que sua aplicação faz o disparo. Resultado: pedido salvo, mensagem perdida, cliente sem aviso, ticket aberto no SAC.
O Outbox Pattern resolve isso de forma definitiva. Persiste a intenção de envio em uma tabela ANTES de chamar o gateway, e um worker assíncrono consome a tabela com retry automático. Se o gateway estiver fora, o worker tenta de novo até conseguir. Zero mensagem perdida, zero acoplamento entre fluxo de negócio e disponibilidade do gateway.
O problema clássico
// Padrão fire-and-forget (PROBLEMÁTICO em produção)
async function processarPedido(pedido) {
await db.salvarPedido(pedido);
await whatsappAPI.enviar({ // ← se falhar aqui...
to: pedido.telefone,
text: `Pedido #${pedido.id} confirmado`,
});
}
Quatro modos de falha possíveis:
- Network timeout temporário do gateway
- Gateway WhatsApp passando por incidente
- Sua aplicação cair entre
salvarPedidoeenviar - Rate limit do WhatsApp (você passou de algum threshold)
Em todos os 4 casos, a mensagem se perde silenciosamente.
O Outbox Pattern
Ideia central: salvar a intenção de envio no banco de dados ANTES de chamar o gateway. Worker separado consome o banco e faz o envio com retry. A operação de "salvar pedido" e "agendar mensagem" vira uma transação atômica.
Fluxo
Request HTTP Worker (loop)
│ │
▼ ▼
┌─────────┐ transação ┌──────────────┐
│ Pedido │───────────▶│ outbox_jobs │
│ DB │ │ status=PENDING│
└─────────┘ └──────────────┘
│
▼
┌──────────────────┐
│ ZAP API HTTP │
└──────────────────┘
│
sucesso │ falha
▼ │ ▼
┌──────────────┐ │ ┌──────────────────┐
│ status=SENT │ │ │ attempt+=1 │
└──────────────┘ │ │ next_retry_at=+5m│
│ └──────────────────┘
│ │
│ após 5 falhas
│ ▼
│ ┌──────────────┐
│ │ status=DEAD │
│ └──────────────┘
Schema da tabela outbox_jobs
CREATE TABLE outbox_jobs (
id BIGSERIAL PRIMARY KEY,
instance_id VARCHAR(64) NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 5,
next_retry_at TIMESTAMP NOT NULL DEFAULT NOW(),
last_error TEXT,
idempotency_key VARCHAR(128) UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
sent_at TIMESTAMP NULL
);
CREATE INDEX idx_outbox_pending ON outbox_jobs(next_retry_at)
WHERE status = 'PENDING';
CREATE INDEX idx_outbox_dead ON outbox_jobs(status)
WHERE status = 'DEAD';
Código: persistir intenção (no fluxo HTTP)
// rota POST /pedido
import { Pool } from "pg";
const db = new Pool({ connectionString: process.env.DATABASE_URL });
async function criarPedido(req, res) {
const { telefone, valor, instanceId } = req.body;
const pedidoId = generateId();
// Tudo na mesma transação
const tx = await db.connect();
try {
await tx.query("BEGIN");
await tx.query("INSERT INTO pedidos (id, telefone, valor) VALUES ($1, $2, $3)",
[pedidoId, telefone, valor]);
await tx.query(`
INSERT INTO outbox_jobs (instance_id, payload, idempotency_key)
VALUES ($1, $2, $3)
`, [
instanceId,
JSON.stringify({
type: "text",
to: telefone,
text: `Pedido #${pedidoId} confirmado.`,
}),
`pedido-${pedidoId}-confirmacao`,
]);
await tx.query("COMMIT");
res.status(201).json({ pedidoId });
} catch (err) {
await tx.query("ROLLBACK");
throw err;
} finally {
tx.release();
}
}
O ponto-chave: se o gateway WhatsApp está fora nesse momento, não importa. O job está persistido. Vai sair quando puder.
Código: worker que processa
// outbox-worker.js
import axios from "axios";
import { Pool } from "pg";
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const ZAP = axios.create({
baseURL: "https://api.zap-api.tech/v1",
headers: { Authorization: `Bearer ${process.env.ZAP_TOKEN}` },
});
const BACKOFF_MIN = [60, 300, 1800, 7200, 86400]; // 1m, 5m, 30m, 2h, 24h
async function loop() {
while (true) {
try {
await processarBatch();
} catch (err) {
console.error("Erro no loop:", err);
}
await new Promise((r) => setTimeout(r, 5000));
}
}
async function processarBatch() {
// FOR UPDATE SKIP LOCKED permite múltiplos workers paralelos
const { rows: jobs } = await db.query(`
SELECT id, instance_id, payload, attempts, max_attempts
FROM outbox_jobs
WHERE status = 'PENDING'
AND next_retry_at <= NOW()
ORDER BY next_retry_at
LIMIT 50
FOR UPDATE SKIP LOCKED
`);
for (const job of jobs) {
try {
await ZAP.post(`/instances/${job.instance_id}/messages`, job.payload);
await db.query(
"UPDATE outbox_jobs SET status = 'SENT', sent_at = NOW() WHERE id = $1",
[job.id]
);
} catch (err) {
const novosAttempts = job.attempts + 1;
const novoStatus = novosAttempts >= job.max_attempts ? "DEAD" : "PENDING";
const idxBackoff = Math.min(novosAttempts - 1, BACKOFF_MIN.length - 1);
const proximaTentativa = `NOW() + INTERVAL '${BACKOFF_MIN[idxBackoff]} seconds'`;
await db.query(`
UPDATE outbox_jobs SET
status = $1,
attempts = $2,
next_retry_at = ${proximaTentativa},
last_error = $3
WHERE id = $4
`, [novoStatus, novosAttempts, err.message, job.id]);
}
}
}
loop();
Como a ZAP API implementa Outbox internamente
A ZAP API já tem outbox interno transparente para você. Quando você chama POST /v1/instances/:id/messages, internamente:
- Validamos payload
- Persistimos em fila interna (Redis-backed)
- Worker interno consome e despacha para o gateway WhatsApp
- Retry automático com backoff exponencial em falhas transientes
- Webhook de status (sent/delivered/read/failed) dispara para sua URL configurada
Ou seja: a ZAP API já protege contra o cenário "gateway WhatsApp fora 30s". O outbox próprio que você implementa em cima protege contra "ZAP API fora 30s" — defesa em profundidade.
Quando usar outbox próprio vs confiar na ZAP API
Suficiente confiar na ZAP API (sem outbox próprio)
- Aplicação tolera latência de 0-30s entre evento e mensagem
- Volume baixo/médio (até alguns milhares por dia)
- Não tem requisito formal de "garantia at-least-once"
Outbox próprio recomendado
- Volume alto (10mil+/dia) ou pico imprevisível
- Mensagem é parte de fluxo crítico (financeiro, agendamento médico, evento de uma vez)
- Auditoria e rastreabilidade são requisitos
- Múltiplas APIs (não só WhatsApp), e você quer um padrão único
Outbox vs fila clássica (BullMQ/Redis)
| Característica | Outbox (DB) | BullMQ (Redis) |
|---|---|---|
| Atomicidade com dado de negócio | Sim (mesma transação) | Não (eventual) |
| Performance pura | ~1k/s | 10k+/s |
| Persistência | Forte | Forte (com AOF) |
| Auditoria | Trivial (SQL) | Limitada |
| Múltiplos workers | FOR UPDATE SKIP LOCKED | Nativo |
| Quando usar | Volume médio + atomicidade | Volume alto + perf |
Casos práticos
Caso 1: E-commerce em Black Friday
Loja com 50k pedidos no dia. Pico de 200/min nas primeiras 2 horas. Outbox próprio em PostgreSQL com 4 workers paralelos consumindo. Zero mensagem perdida mesmo com gateway WhatsApp tendo 2 incidentes durante o evento.
Caso 2: Agendamento médico
Clínica com 800 consultas/dia. Cada consulta gera 3 mensagens (confirmação imediata, lembrete D-1, lembrete H-2). Outbox interno da ZAP API foi suficiente. Worker próprio teria sido sobre-engenharia.
Caso 3: Plataforma de cursos
Disparos massivos em momentos específicos (lançamento de módulo, lembrete de prazo): 20k mensagens em 5 minutos. BullMQ + workers se mostrou melhor que outbox em DB, porque o pico breve não justificava transação atômica com dado de negócio.
FAQ
Diferença entre outbox e fila (BullMQ)?
Outbox vive na mesma transação que sua escrita de negócio (sem chance de inconsistência). Fila é externa (Redis), sem garantia transacional com seu DB. Para a maioria dos casos críticos, outbox é mais seguro.
Como monitorar jobs travados?
Query simples: SELECT count(*) FROM outbox_jobs WHERE status = 'PENDING' AND created_at < NOW() - INTERVAL '10 minutes'. Se passar de 100, alerta. Para jobs DEAD, alerta sempre que houver — significa falha de retry esgotada que precisa de intervenção humana.
Como fazer worker idempotente?
Use idempotency_key único (campo na tabela). Antes de processar, faça SELECT — se já está SENT, pule. Garantia: mesmo job processado 2x não duplica envio.
Posso ter múltiplos workers paralelos?
Sim, é justamente a vantagem. Use FOR UPDATE SKIP LOCKED no SELECT — cada worker pega jobs diferentes sem conflitar. Em produção, 4-8 workers atendem volume médio.
Como reprocessar mensagem em status DEAD?
Manualmente via SQL: UPDATE outbox_jobs SET status = 'PENDING', attempts = 0, next_retry_at = NOW() WHERE id = ?. Antes, investigue: se a mensagem morreu por payload inválido, fix o payload primeiro.
Próximo passo
Implemente outbox próprio em algumas horas com o exemplo acima. Criar conta grátis e proteja seu fluxo desde o primeiro dia.