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.

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, é o wamid; para atualizações de status, é wamid:status (ex.: wamid.HBgM...:delivered). 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 e reagendada com backoff exponencial: a primeira retentativa ocorre depois de 1 minuto, dobrando a cada tentativa até um teto de 1 hora entre tentativas.

O número máximo de tentativas de entrega ao seu endpoint é 6; esgotado esse total, a entrega vira exhausted (veja DLQ, abaixo). 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, o próximo ciclo retoma os lotes órfãos, e nunca reenvia um destinatário cujo resultado é incerto (veja Transmissões e limites e a seção de casos ambíguos abaixo).

DLQ: o que acontece quando as tentativas se esgotam

Esgotadas as tentativas automáticas, uma entrega de webhook vira exhausted. Isso significa "as tentativas automáticas se esgotaram", não "o evento foi descartado". A linha continua no banco, visível e reprocessável manualmente. O mesmo padrão vale para as filas internas de processamento, que marcam a linha como dead quando esgotam.

Itens nesse estado aparecem no painel /operacao, com alertas para o dono/administrador da conta. Nada fica esquecido silenciosamente.

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

DadoRetenção
Entregas de webhook (webhook_deliveries)30 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)90 dias

Replay manual

Só o owner ou admin da conta pode disparar um replay, pelo painel /operacao. Ele reprocessa em lote (no máximo 20 itens por vez) as filas de entrega de webhook e de mídia; o replay reinicia o ciclo de tentativas da linha (volta a ser processada como se fosse a primeira vez) e é auditado sem PII.

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: no detalhe de uma transmissão em /transmissoes, só destinatários classificados como failure_class = "confirmed_not_sent" podem ser reenviados: a Meta recusou o envio com um erro que comprova que a mensagem não saiu. Destinatários marcados maybe_sent/unconfirmed_send, ou linhas antigas sem essa classificação, nunca são elegíveis para reenvio automático, porque reenviar um destinatário incerto poderia duplicar a mensagem.

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.

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."
  }
}

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.

Nesse caso o envio NÃO fica registrado em `GET /api/v1/messages` (a mensagem não chegou a ser persistida) e a cota do período não é debitada — não há como confirmar pelo BotoZap se ela saiu. Trate o reenvio como decisão consciente: reenviar pode duplicar a mensagem para o contato.

Transmissão (broadcast)

Aqui o resultado ambíguo fica registrado: o destinatário é marcado failed com error: "unconfirmed_send" e failure_class: "maybe_sent", visível no detalhe da transmissão. A quota do lote já reservada não é devolvida nesse caso. Também não há retry automático nem replay. Um destinatário maybe_sent fica permanentemente inelegível para reenvio automático (só confirmed_not_sent é 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.