Confiabilidade e entrega

Como a BotoZap reduz o risco de perda depois que um evento é recebido, quais garantias oferece e como agir sem presumir ordem ou exactly-once.

A BotoZap usa Postgres como fila durável (inbox/outbox) para tudo que chega da Meta e tudo que sai para o seu endpoint. Esta página documenta as garantias reais dessa arquitetura, inclusive as que parecem desconfortáveis, porque prometer mais do que a Cloud API permite causaria bugs de produção no seu lado.

Saúde de endpoints de webhook

active e health_status são dimensões independentes. active é a intenção manual: false desativa novas admissões e preserva o que já foi aceito. health_status é automático e assume unverified, healthy, degraded ou paused.

A criação começa em unverified, portanto não recebe eventos reais até POST /api/v1/webhooks/{id}/test obter uma resposta HTTP 2xx. Esse teste é separado das deliveries reais: 2xx verifica e ativa; falha retorna 502, preserva o streak e não dispara threshold. Para uma reativação, corrija o endpoint e use Testar e ativar; PATCH active=true sem teste é recusado com verification_required.

Depois da verificação, cada canal do endpoint (cada número do WhatsApp e cada conta do Instagram) tem a sua fila, com espera, streak e pausa próprios; eventos sem canal formam uma fila à parte. Falhas reais de transporte, configuração local, 408/429 ou HTTP não-2xx degradam o canal do evento, e um canal com problema não atrasa os outros. A exceção é o evento que o seu receptor recusa enquanto aceita os outros do mesmo canal: ele não trava os demais, segue a curva de retry à parte e vai para exhausted na tentativa 30 (cerca de 24 horas), de onde pode ser reenviado. Os alertas ao owner cruzam os streaks 5 / 10 / 15 do pior canal; no streak 15, o canal pausa. O health_status do endpoint é o resumo: degraded enquanto algum canal tem falha e paused só quando um canal pausou e nenhum outro entregou com sucesso nas 24 horas antes do início das falhas (nada indica que a URL funciona); channels_health mostra cada canal. A pausa automática não escreve active=false. O backlog de um canal pausado fica retido por 14 dias e volta após um teste 2xx, se ainda não expirou.

Entrega at-least-once

Todo evento que chega da Meta é persistido antes de a BotoZap responder 200 a ela. A durabilidade nunca depende de nenhuma etapa depois disso. O encaminhamento (forward) desse evento para o seu endpoint é at-least-once: em retries, reprocessos após queda e no replay manual, o mesmo evento pode chegar mais de uma vez.

Deduplique sempre pelo header X-Idempotency-Key: para mensagens recebidas e para as enviadas pelo app em coexistência (whatsapp.message.echo), é o wamid; para atualizações de status, é wamid:status (ex.: wamid.HBgM...:delivered). Os demais eventos (CRM, Régua, saúde da WABA) têm chaves próprias, listadas no Catálogo de eventos. Essa chave é estável entre retentativas e entre um replay manual. O mesmo evento sempre chega com a mesma chave. Sempre que possível, processe o efeito e registre a chave na mesma transação do seu lado: um Set em memória ou um cache sem persistência não sobrevive a um restart ou a múltiplas réplicas.

Sem ordem global garantida

Não existe ordem global de entrega, nem por conversa, nem por número de telefone. Retries, workers concorrentes e tipos de evento diferentes (mensagem vs. status) podem reordenar a chegada no seu endpoint. Duas atualizações de status da mesma mensagem podem chegar fora de ordem.

Nunca infira o estado final de algo pela ordem em que os webhooks chegaram. Use o timestamp do próprio payload, o wamid e a máquina de estados do seu domínio (ex.: um statusread vale mais que um sent chegado depois, independente da ordem de chegada).

Retry automático

Uma entrega ao seu endpoint é reagendada quando a resposta está fora do intervalo 2xx, quando a requisição atinge timeout, ou em erro de rede. Só uma resposta 2xx confirma o consumo. Qualquer outra coisa (inclusive nenhuma resposta) é tratada como falha. No caminho health-aware, as próximas tentativas seguem exatamente 1 / 2 / 4 / 8 / 16 / 32 / 60 minutos (a última duração se repete). Isso também vale para um bloqueio local de configuração em uma delivery real já verificada.

Não há teto fixo de seis tentativas no contrato health-aware: a fila de cada canal repete o intervalo de 60 minutos enquanto continua elegível, e o intervalo é contado pelo streak daquele canal. Ao atingir o streak 15 num canal, a pausa automática abre o circuito e a fila do canal deixa de iniciar novas tentativas; os outros canais seguem. Só então o backlog é retido por 14 dias, sem POST, aguardando um teste 2xx; a expiração dessa retenção não é um retry. Internamente a plataforma também usa filas de processamento próprias: ingestão do evento cru (12 tentativas) e download de mídia (8 tentativas), antes de marcar uma linha como dead; são detalhe de implementação, não afetam o formato do que você recebe.

Transmissões (broadcasts) não têm retry por destinatário. Se o worker cair no meio de um lote, uma varredura que roda a cada minuto retoma a transmissão parada há mais de 2 minutos: conte com cerca de 2 a 3 minutos até a retomada. A mesma varredura inicia as transmissões agendadas e as próximas ondas de um envio em ondas, que por isso podem começar até cerca de 1 minuto depois do horário marcado. A retomada nunca reenvia um destinatário cujo resultado é incerto (veja Transmissões e limites e a seção de casos ambíguos abaixo).

Pausa, retenção e replay

No caminho health-aware, o streak de 15 abre o circuito do canal: o canal fica pausado, sem alterar active, e o endpoint só fica paused quando nenhum outro canal entregou com sucesso nas 24 horas antes do início das falhas. O backlog aceito para um canal pausado (e o que já existia quando a configuração ficou unverified ou manualmente inativa) é retido por 14 dias a partir de created_at. Depois disso, a linha recebe uma razão terminal auditável; ela não é apagada silenciosamente.

Corrija o endpoint e use Testar e ativar. Só um teste assinado 2xx muda a saúde para healthy, zera o streak de todos os canais e acorda as deliveries não expiradas de cada canal pausado. Falhas de cliques manuais não entram nos thresholds. A mesma delivery mantém payload e chave de idempotência; a garantia externa continua sendo at-least-once.

Retenção dos dados, depois da qual as linhas são apagadas por rotina:

DadoRetenção
Entregas de webhook (webhook_deliveries)30 dias
Backlog de canal pausado ou endpoint unverified14 dias
Eventos crus (fila interna de ingestão, terminais)7 dias
Jobs de mídia (fila interna, terminais)7 dias
Logs de requisição da API (/api/v1/api_logs)6 meses

Replay manual

Qualquer membro da conta (Operador ou Administrador) pode disparar um replay, pelo painel /operacao; não há rota de API para isso. Ele reprocessa em lote (no máximo 20 itens por vez) as entregas de webhook em exhausted e os jobs de mídia que esgotaram as tentativas; o replay reinicia o ciclo de tentativas da linha (volta a ser processada como se fosse a primeira vez) e é auditado sem PII. Para recuperar um evento cuja entrega já saiu da retenção, leia o stream GET /api/v1/events.

Os eventos brutos (a fila de ingestão inicial) NÃO são reprocessáveis por aqui: é uma fila compartilhada em nível de plataforma, cujo payload pode conter dados de mais de uma conta, e expor o replay por tenant abriria a possibilidade de uma conta reprocessar efeitos de outra. Esse replay é operação interna da BotoZap.

Transmissões têm um replay à parte, e é seletivo: só no painel, no detalhe de uma transmissão em /transmissoes, por qualquer membro da conta, com até 20 destinatários por vez. Só são elegíveis destinatários cuja falha comprova que a mensagem não saiu (a Meta recusou o envio). Uma falha ambígua, ou uma linha antiga sem essa classificação, nunca é elegível, porque reenviar um destinatário incerto poderia duplicar a mensagem. A classificação de segurança é interna: pela API, GET /api/v1/broadcasts/{id}/recipients mostra status e error de cada destinatário, e o painel indica quais podem ser reenviados.

Casos ambíguos de envio

Um erro 4xx devolvido pela própria API antes de chamar a Meta (ex.: outside_window, ambiguous_phone, quota_exceeded) é uma recusa comprovada: a mensagem definitivamente não saiu, e você pode corrigir e reenviar sem medo de duplicar.

Já um timeout, uma falha de rede ou um 5xx da própria Meta depois que a chamada de envio já foi feita são ambíguos: a Meta pode ter aceitado a mensagem do lado dela mesmo assim. O que acontece daqui em diante é diferente para um envio avulso e para uma transmissão. Não misture os dois.

Envio avulso (POST /api/v1/messages)

  • A resposta de erro da BotoZap não prova que a mensagem falhou. A Meta pode tê-la aceitado do lado dela mesmo assim.
  • A quota do plano não é debitada: o débito só ocorre depois que a chamada à Meta retorna com sucesso, e num erro ambíguo o fluxo nunca chega lá.
  • A mensagem não fica registrada em GET /api/v1/messages: ela só é persistida depois do sucesso, então uma falha ambígua não deixa rastro nenhum no seu lado. E mesmo que a Meta tenha aceitado o envio e mais tarde relate um status para ele, esse status é ignorado pelo pipeline: a BotoZap só encaminha status de mensagens que ela mesma persistiu, e essa não foi.
  • Não há retry automático desse envio.

Ou seja: não há como confirmar pelo BotoZap se um envio avulso ambíguo saiu ou não. Reenviar é uma decisão consciente de negócio, não uma chamada de verificação, e reenviar pode duplicar a mensagem para o contato, porque a Cloud API não tem uma chave de idempotência nativa no envio.

O BotoZap tem: com o header Idempotency-Key, repetir a mesma chave depois de um caso ambíguo responde 409 idempotency_pending em vez de enviar de novo. Se o timeout foi só do seu lado, a nova tentativa devolve a resposta original quando o primeiro envio já terminou, ou o mesmo 409 enquanto ele está em andamento. Reenviar de propósito exige outra chave. Veja Evitar envio em dobro.

Erro: a Meta falhou ao processar o envio

A Graph API respondeu com um erro de servidor (5xx) ao tentar enviar a mensagem.

curl https://botozap.com.br/api/v1/messages \
  -X POST \
  -H "X-API-Key: $BOTOZAP_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "to": "+5511955554444",
  "type": "template",
  "template": {
    "name": "status_pedido",
    "language": {
      "code": "pt_BR"
    }
  }
}'

Resposta 502

{
  "error": {
    "code": "meta_error",
    "message": "Erro interno do Graph.",
    "outcome": "unknown",
    "retry": "reconcile_first",
    "next_step": "Consulte a mensagem e os Eventos da Conta antes de repetir. Aceitação não prova entrega; um resultado desconhecido pode ter sido aceito pela Meta."
  }
}

`outcome: "unknown"` e `retry: "reconcile_first"`: confira o resultado antes de repetir. Quando a Meta informa um código de erro, ele vem em `meta_code`.

Um 502 (ou timeout) aqui NÃO prova que a mensagem não saiu — a Meta pode tê-la aceitado do lado dela mesmo assim.

Sem identificador confirmado, a mensagem não aparece em `GET /api/v1/messages` e a cota do período não é debitada. O BotoZap mantém um recibo de envio desconhecido e bloqueia novos envios nessa conversa com 409 `conversation_dispatch_busy`. Um gestor deve conferir o resultado e registrar a evidência em Envios pendentes antes de tentar novamente.

Transmissão (broadcast)

Aqui o resultado ambíguo fica registrado: pela API, o destinatário aparece com status: "failed" e error: "unconfirmed_send"; no painel, o detalhe da transmissão o marca como "pode ter sido entregue". A quota do lote já reservada não é devolvida nesse caso. Também não há retry automático nem replay: esse destinatário fica permanentemente inelegível para reenvio pelo painel (só a recusa comprovada é elegível, como descrito acima). A diferença para o envio avulso é que aqui existe uma linha registrada para você auditar e decidir manualmente.

Ninguém (nem a BotoZap, nem qualquer integração sobre a Cloud API) pode prometer exactly-once em cima dela; o design aqui é ser honesto sobre isso em vez de fingir uma garantia que não existe.