Um cliente nosso, e-commerce de suplementos, recebeu o mesmo lembrete de carrinho abandonado cinco vezes em 30 minutos. O cliente final, irritado, mandou print pro suporte com a frase "se vocês não consertarem isso, cancelo a compra". Auditoria mostrou: o webhook do checkout disparou 1 vez, o cron de retry do worker disparou 2 vezes em janela de retry exponencial, e a fila de mensagens não tinha proteção contra duplicata. Cinco eventos, mesma mensagem, no mesmo número.
Esse é o tipo de bug que destrói confiança em produção. E é evitável com um conceito simples e fundamental em sistemas distribuídos: idempotência. Operação idempotente é aquela que, executada uma ou cem vezes, produz o mesmo efeito observável. Este guia mostra como implementar idempotência ponta a ponta em integrações WhatsApp, do webhook receptor até o gateway final.
Por que duplicatas acontecem
Em sistema distribuído, três fontes garantem duplicatas se você não defende:
- Retry sem ID: worker timeout em 30s e dispara de novo. Mensagem original já saiu, mas resposta nunca voltou. Resultado: dois envios.
- Webhook duplicado: ferramenta upstream (Stripe, Woovi, Shopify) envia mesmo evento 2-3 vezes em 5 minutos pra garantir entrega. Cada um dispara um envio.
- Race condition: dois pods da sua API consomem a mesma fila SQS/RabbitMQ porque a mensagem ainda não foi acked. Ambos disparam.
Sem proteção, taxa de duplicata em volume alto fica entre 0,3% e 2,1% — parece pouco, mas em 100 mil mensagens/dia são 300-2.100 duplicatas, o que vira ticket de suporte e churn.
Solução: Idempotency-Key na ZAP API
A ZAP API aceita o header HTTP Idempotency-Key em endpoints de envio. Quando você passa uma chave única por mensagem, o servidor armazena por 24 horas. Se a mesma chave chegar de novo dentro da janela, retorna o resultado original sem disparar segundo envio.
Exemplo básico
import axios from "axios";
import crypto from "crypto";
const ZAP = axios.create({
baseURL: "https://api.zap-api.tech/v1",
headers: { Authorization: `Bearer ${process.env.ZAP_TOKEN}` },
});
async function enviarMensagemIdempotente(instanceId, to, body, eventId) {
const idempotencyKey = crypto
.createHash("sha256")
.update(`${instanceId}:${to}:${eventId}`)
.digest("hex");
return ZAP.post(
`/instances/${instanceId}/messages`,
{ to, type: "text", text: { body } },
{ headers: { "Idempotency-Key": idempotencyKey } }
);
}
Chaves determinísticas vs aleatórias
Há duas estratégias pra montar a chave. A escolha afeta semântica da idempotência:
Chave aleatória (UUID v4)
Gera UUID novo por chamada. Garante que tentativas de retry da mesma operação reusem a chave (porque o worker guarda na fila), mas duas operações lógicas distintas geram chaves diferentes. Bom quando o cliente sabe controlar exatamente uma execução.
import { v4 as uuid } from "uuid";
const job = { id: uuid(), to, body }; // gerado uma vez ao enfileirar
await queue.add(job);
// no worker, sempre que processa:
await ZAP.post(url, payload, { headers: { "Idempotency-Key": job.id } });
Chave determinística (hash de identificadores de domínio)
Calcula chave a partir dos atributos do evento: instância, destinatário, template, timestamp arredondado. Garante que qualquer caminho que tente disparar a mesma mensagem lógica use a mesma chave. Bom quando vários produtores podem disparar o mesmo evento.
function buildKey(instanceId, to, templateId, dateBucket) {
// dateBucket = "2026-05-30" ou "2026-05-30T14" (granularidade desejada)
return crypto
.createHash("sha256")
.update(`${instanceId}|${to}|${templateId}|${dateBucket}`)
.digest("hex");
}
// exemplo: lembrete de renovação só pode sair 1x por dia por aluno
const key = buildKey(instanceId, aluno.telefone, "RENOV_7D", todayISO());
await ZAP.post(url, payload, { headers: { "Idempotency-Key": key } });
Se o cron rodar duas vezes por bug ou se outro worker tentar enviar, a chave é a mesma — segundo envio é deduplicado pela ZAP API.
Idempotência ponta a ponta
Idempotência só no envio HTTP não basta. Os 4 estágios precisam tratar:
1. Webhook receptor (entrada)
app.post("/webhook/stripe", async (req, res) => {
const eventId = req.headers["stripe-signature"]; // assinatura única
const dedupKey = `stripe:${req.body.id}`;
const isDup = await redis.set(dedupKey, "1", "NX", "EX", 86400);
if (!isDup) return res.status(200).send("duplicate ignored");
await queue.add({ eventId: req.body.id, ...req.body });
res.status(200).send("ok");
});
2. Fila com message group / dedup ID
SQS FIFO, RabbitMQ com message-deduplication, BullMQ com jobId — todos suportam dedup nativa. Configure pra 1-5 minutos de janela.
// BullMQ
await queue.add(
"send-message",
{ instanceId, to, body, eventId },
{ jobId: `${instanceId}:${to}:${eventId}` } // dedup nativa do Bull
);
3. Worker idempotente
Worker calcula chave determinística baseada em domínio, NÃO em ID do job (que pode mudar entre retries).
worker.process(async (job) => {
const { instanceId, to, body, eventId } = job.data;
const key = crypto.createHash("sha256")
.update(`${instanceId}|${to}|${eventId}`)
.digest("hex");
return ZAP.post(
`/instances/${instanceId}/messages`,
{ to, type: "text", text: { body } },
{ headers: { "Idempotency-Key": key } }
);
});
4. Gateway final (ZAP API)
Recebe o header, armazena em cache por 24h, retorna mesma resposta se chave vier de novo. Resposta deduplicada vem com header X-Idempotent-Replay: true.
Casos edge: confirmação perdida
Você enviou a mensagem com sucesso (gateway retornou 200), mas o evento de confirmação (status delivered) nunca chegou no seu webhook por instabilidade de rede. Sua aplicação acha que falhou e quer reenviar. Sem idempotência, dispara duplicata.
Solução: reconciliação periódica via GET /v1/messages/:messageId/status antes de qualquer retry. Se gateway diz "entregue", marca local e não reenvia.
async function shouldRetry(localMessage) {
if (localMessage.status === "DELIVERED") return false;
const remote = await ZAP.get(`/messages/${localMessage.zapMessageId}/status`);
if (remote.data.status === "delivered" || remote.data.status === "read") {
await db.update("messages", localMessage.id, { status: remote.data.status });
return false;
}
return localMessage.attempts < 5;
}
Anti-padrões que causam duplicata mesmo com Idempotency-Key
Implementar o header não é suficiente se você cometer esses erros:
- Gerar chave nova a cada retry: o pior erro possível. Se o worker cria
uuid()dentro do loop de retry, cada tentativa tem chave única — o servidor não reconhece como duplicata. A chave deve ser gerada uma vez ao enfileirar o job, não na execução. - Incluir timestamp na chave:
sha256("inst123|55119|event456|" + Date.now())gera chave diferente a cada milissegundo. Coloque granularidade de dia ou hora no máximo, dependendo da semântica desejada ("um lembrete por dia por aluno"). - Misturar chave de idempotência com chave de unicidade de negócio: idempotência é sobre "não executar essa operação mais de uma vez". Unicidade de negócio é sobre "só pode existir um registro com esses atributos". Não confunda — são camadas diferentes.
- Não persistir a chave antes de enviar: se o processo morre entre gerar a chave e enfileirar, ao reiniciar vai gerar chave diferente. Persista no banco antes de enfileirar:
INSERT INTO pending_messages (idempotency_key, ...) ON CONFLICT DO NOTHING. - TTL curto demais: worker com backoff exponencial pode chegar em 2h de delay em cenários de erro grave. TTL de 30 minutos deixa duplicata escapar. Prefira 24h.
Checklist de produção: idempotência completa
Antes de ir a produção, verifique cada item:
- Chave gerada uma única vez ao criar o job (não dentro do worker)
- Chave baseada em identificadores estáveis de domínio (instância + destinatário + evento)
- Chave persistida no banco antes de enfileirar
- Worker lê chave do job, não gera nova
- Webhook receptor deduplica por ID nativo do evento (
req.body.id) - TTL do header configurado para 24h
- Reconciliação periódica para mensagens em estado "enviando" há mais de 5 minutos
- Alerta se taxa de replay (
X-Idempotent-Replay: true) ultrapassar 5% — indica problema no producer
Métricas para monitorar
Sem métricas você não sabe se a idempotência está funcionando:
- Taxa de replay: % de requests que retornaram
X-Idempotent-Replay: true. Saudável: < 2%. Acima de 5%: producer com problema (retry excessivo, chave errada). - Duplicatas escapadas: mensagens com mesmo conteúdo recebidas pelo mesmo destinatário em < 5 minutos. Monitorar no log de entrega. Meta: zero.
- Tentativas por job: distribuição de
job.attemptsao completar. Se mediana for > 2, a fonte de erro precisa de atenção — idempotência mascara o problema, não resolve.
// Middleware que loga replays para análise
app.use("/webhook/zap", (req, res, next) => {
res.on("finish", () => {
if (res.getHeader("X-Idempotent-Replay") === "true") {
logger.warn({
event: "idempotent_replay",
path: req.path,
key: req.headers["idempotency-key"],
statusCode: res.statusCode,
});
metrics.increment("zap.replay.count");
}
});
next();
});
FAQ
Qual o TTL ideal para Idempotency-Key?
24 horas cobre 99,9% dos cenários. Retries de fila normais acontecem em segundos a minutos; retries de erro escalado raramente passam de 1 hora. TTL maior que 24h aumenta consumo de memória sem ganho real. TTL menor que 1h pode permitir duplicata em retry exponencial agressivo. Se a janela de negócio for "só pode enviar 1 mensagem por semana para o cliente", use TTL de 7 dias para essa categoria específica de envio — mas mantenha registro no banco também, porque o cache pode ser purgado em restart do gateway.
O que acontece se duas requests com mesma chave chegarem simultaneamente?
O servidor processa a primeira e bloqueia a segunda em fila interna com timeout de 5 segundos. Quando a primeira termina, a segunda recebe a resposta cacheada com header X-Idempotent-Replay: true. Se a primeira demorar mais de 5 segundos, a segunda retorna 409 Conflict — cliente deve aguardar e tentar novamente com a mesma chave após 1-2 segundos. Se houver crash do servidor entre processar e responder, a operação não foi executada e a segunda tentativa executa normalmente (não houve cache). Garantia do modelo: a mesma chave nunca executa o efeito colateral mais de uma vez, mesmo com múltiplos callers concorrentes.
Idempotency-Key vai em header ou query param?
Sempre em header (Idempotency-Key). Query param polui logs, vai em URL (cache de proxies pode reter, CDN pode cachear junto com a resposta do recurso), e fere convenção HTTP. Header é o padrão da indústria estabelecido por Stripe, AWS, GitHub e Adyen. Em HTTP/2, headers são comprimidos por HPACK — custo de transmissão é mínimo mesmo com chave de 64 chars hex.
E para operações em transação (criar instância + enviar mensagem)?
Cada operação tem chave separada. Se você precisa atomicidade composta, encapsule no seu serviço com saga pattern: cada etapa tem chave própria, e o orchestrator rastreia quais etapas já foram completadas. Se etapa 1 (criar instância) completou e etapa 2 (enviar mensagem) falhou, o retry usa a chave da etapa 1 para confirmar que instância existe (replay), e nova chave para a etapa 2. Não dá pra estender Idempotency-Key através de múltiplos endpoints porque cada um tem cache próprio e semântica independente.
Posso usar mesma chave para retry seguro automaticamente?
Sim — esse é exatamente o caso de uso principal. Configure seu HTTP client para reusar a mesma chave entre tentativas. Em axios-retry, isso é o comportamento padrão porque a chave fica no header da request original que é reutilizada. Em got, use hooks.beforeRetry para preservar o header. Nunca regenere a chave dentro do handler de retry — é o anti-padrão mais comum e quebra toda a garantia de idempotência.
Pare de enviar mensagens duplicadas em produção. A ZAP API tem suporte nativo a Idempotency-Key com TTL de 24h e replay determinístico. Documentação completa no painel. Criar conta grátis e implemente em 15 minutos.