Payloads e entrega de eventos

Envelope, tipos de evento, recibos, retries e reprocessamento.

O serviço persiste eventos no PostgreSQL (events) antes de qualquer entrega e os entrega ao webhook da instância em ordem de seq, pelo menos uma vez. O formato persistido (/v1/events) é o próprio do serviço; o corpo entregue ao webhook é a projeção legada, no envelope que o parser do consumidor (uazapi-webhook) lê. A projeção é uma função pura de internal/webhook, coberta por fixtures fixadas na revisão do parser (projector_test.go).

Registro

POST /webhook (token da instância):

json
{"enabled": true, "url": "https://seu-atendro.example/webhooks/atendrozap",
 "events": ["connection", "messages", "messages_update", "groups", "qrcode"],
 "excludeMessages": ["wasSentByApi"], "addUrlEvents": false, "addUrlTypesMessages": false,
 "action": "add", "secret": "opcional"}
  • Um webhook por instância; action: update é o mesmo upsert, delete desativa inclusive em mudanças globais; inherit volta ao destino global. Reregistrar a mesma URL mantém o cursor, a saúde e o secret (que nunca aparece nas respostas). Uma URL nova começa depois dos eventos já existentes: o consumidor nunca recebe o backlog de outra vida.
  • url: https para host público, sem credenciais, sem redirects. Hosts em ATENDROZAP_WEBHOOK_HOST_ALLOWLIST (o receptor do laboratório) podem ser privados e http. IPs privados por DNS são recusados na conexão.
  • events: os nomes entregues são connection, messages, messages_update, messages_edit, groups, limits, undecryptable, call; history, chats, qrcode, presence, contacts, labels, chat_labels, blocks, sender e newsletter_messages são aceitos e devolvidos em ignoredEvents. Nomes desconhecidos → 400 invalid_event.
  • addUrlEvents/addUrlTypesMessages: true400 unsupported_option (o nome do evento vai no corpo e em X-AtendroZAP-Event-Type).
  • excludeMessages: wasSentByApi, wasNotSentByApi, fromMeYes, fromMeNo, isGroupYes, isGroupNo — filtram só messages. Envios pela API nunca geram messages de qualquer forma.
  • O cadastro global persistido usa GET/POST /v1/webhook com admintoken. O console oferece Servidor → Configurações de webhook. Instâncias sem exceção herdam o destino; as futuras o recebem na transação de criação. ATENDROZAP_WEBHOOK_URL e ATENDROZAP_WEBHOOK_SECRET apenas inicializam a configuração uma vez; alterações salvas prevalecem nos reinícios. O contrato do novo receptor detalha essas regras.

GET /webhook devolve o registro (registered, inheritedGlobal, url, events, excludeMessages, enabled, hasSecret, deliveryStatus{pending, dead, oldestPendingSeconds, paused, pausedUntil, consecutiveFailures, lastStatus, lastError, lastDeliveredAt}) e webhooks: [o mesmo]. GET /webhook/errors lista as últimas 20 falhas deste worker, sem payload. GET /instance/status traz o resumo em webhooks.

Entrega

  • POST JSON, timeout 10 s, sem redirects, User-Agent: AtendroZAP/….
  • Headers: X-AtendroZAP-Event-Id, X-AtendroZAP-Event-Type, X-AtendroZAP-Event-Seq, X-AtendroZAP-Instance-Id, X-AtendroZAP-Attempt, X-AtendroZAP-Timestamp e, com secret, X-AtendroZAP-Signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + corpo)>. O novo receptor do Atendro deve verificar assinatura e vínculo servidor/instância antes de aceitar o evento; o parser legado não satisfaz esse contrato.
  • Um entregador por instância por vez, em qualquer worker: a linha de webhooks é reclamada com FOR UPDATE SKIP LOCKED e um lease de 30 s renovado a cada evento; cada claim trabalha no máximo 20 s e devolve o webhook, para que um receptor lento ocupe uma vaga e nunca o worker. Desabilitar ou trocar a URL para de entregar no evento seguinte. Ordem por seq; um evento que precisa de retry bloqueia os seguintes da instância (o parser tolera recibo antes da mensagem só por heurística). Recibos da mesma mensagem enfileirados juntos são condensados no estado mais alto (o consumidor descarta atualizações do mesmo id em < 5 s).
  • Política por resposta: 2xx entregue; 400, 410, 413, 422 dead-letter imediato; 401/403 dead após 3 tentativas; o resto (rede, timeout, 3xx, 404, 429, 5xx) retenta com backoff exponencial de 5 s, teto 10 min, jitter, Retry-After honrado; dead após 20 tentativas ou 48 h.
  • Circuit breaker: 10 falhas consecutivas (dead-letters não contam) pausam o webhook por 5 min sem perder eventos. Sucesso zera o contador.
  • Falha transitória do próprio serviço ao montar o envelope (banco indisponível) retenta como uma falha de rede; só um defeito permanente (mensagem inexistente, payload ilegível) vira dead-letter.
  • GET /v1/events?status=pending|delivered|dead (pendentes do mais antigo para o mais novo: o primeiro é o que bloqueia); POST /v1/events/{id}/replay põe um evento (dead ou entregue) de volta na fila com orçamento novo de tentativas; eventos de antes do registro atual respondem 409 event_before_registration.
  • Retenção: entregues 7 d, dead 30 d, pendentes que nenhum webhook pode entregar (sem registro, ou anteriores a ele) 30 d; poda horária. Instância apagada perde o webhook e tem o backlog marcado dead.
  • Eventos de antes do registro não são entregues (start_seq).
  • Receptores devem tratar a entrega como pelo menos uma vez: desduplicar por X-AtendroZAP-Event-Id e recusar X-AtendroZAP-Timestamp fora de uma janela (5 min) quando verificarem a assinatura.

Catálogo (formato persistido → projeção legada)

Tipo persistidoPayloadProjeção
messages{message: <mensagem>}EventType: messages, chat{wa_chatid, wa_isGroup, owner, name}, message no formato legado (legacy.Message): id = owner:messageid, messageid, chatid (forma telefone quando o chat é LID), sender, sender_pn, participant (grupo), pushName, fromMe, messageType PascalCase, type (text/media/…), mediaType, timestamp (s), messageTimestamp (ms), text, caption, fileName, mimetype, fileURL (só mídia retida), content{key{id, fromMe, remoteJid, participant}, text/caption, contextInfo{stanzaId, participant}, selectedButtonId, selectedDisplayText, degreesLatitude…}, edited: "", reaction: "". Envios pela API nunca são projetados
messages_update recibo{ids, status, chatid, timestamp}EventType: messages_update, state na raiz (Sent, Delivered, Read, Played), event{Type, MessageIDs (bare), Chat (string), IsFromMe, Timestamp}, chat string, owner dígitos. failed não tem projeção
messages_update revogação{type: revoke, targetId, …}state: Deleted, type: DeletedMessage, event{Type: Deleted, MessageIDs: [alvo]}
messages_update "apagar para mim"{type: delete_for_me, targetId, chatid, fromMe, known} (a mensagem ganha deletedForMeAt)Não projetado: a mensagem sumiu só da visão da conta; anunciar Deleted apagaria no consumidor uma mensagem que a outra parte ainda tem. Visível em /v1/events e em GET /message/status/{id}
messages_update reação{type: reaction, targetId, text, sender, fromMe, …}forma messages com messageType: ReactionMessage, type: reaction, content.key.ID = alvo, text = emoji (vazio remove), fromMe = quem reagiu, reaction: "", message.owner. A forma messages_update do parser consulta uma coluna inexistente e não funciona
messages_edit{targetId, text, id, fromMe, …}messages_edit (originalMessageId, messageId = original, newText, text, editMessageId) quando assinado; senão in-band em messages com message.edited = id original e text novo. Edições feitas pela API não são anunciadas
connection (não emitido quando o motivo é lease lost: outro worker é o dono e anuncia o próprio estado){state, previous, reason, disconnectCode, jid, owner, pushName, worker, timestamp}state/status/connection (connected, connecting, disconnected), phoneNumber (dígitos, só quando conhecido), lastDisconnectReason (textos que casam os regex do consumidor), instance{status, owner, profileName, lastDisconnect, lastDisconnectReason}, reasonCode, needsHumanQr
groups{action: joined|update, groupJid, name?, topic?, sender, join[], leave[], promote[], demote[], deleted, timestamp}Entregue como {EventType: groups, event: groups, data} quando inscrito. O assunto do grupo também sai em chat.name e message.groupName das mensagens
limits, undecryptablepróprioSó para quem assina o nome; {EventType, event, data}
call (só instâncias kind: calls){callId, direction (outbound/inbound), status (ringing/connecting/active/ended), number, jid, seq, timestamp, startedAt, answeredAt?, endedAt?, endReason?, endedBy? (local/remote)}Só para quem assina call; {EventType: call, event: call, data: <payload>}. number é a identidade registrada no WhatsApp (celular BR pode vir sem o nono dígito)

Eventos internos e qrcode nunca são entregues; o QR nunca entra em events.

Limites conhecidos deste serviço

  • Mensagens de grupo saem com chat.name/message.groupName quando o assunto é conhecido (sincronizado no connected, mantido por notificações e history sync); um grupo que a conta ainda não viu descrito sai sem nome, nunca com o push name de um participante.
  • Mensagens importadas do histórico do telefone (from_history) nunca geram messages: são lidas por /message/find (padrão) e /chat/find.
  • Chats LID sem forma telefônica conhecida saem com @lid em chatid; nenhum telefone é fabricado.

Limites conhecidos do consumidor

  • Recibos para o mesmo id em < 5 s são descartados pelo consumidor (debounce); Delivered seguido de Read em < 5 s perde o Read.
  • Um envio estacionado como ambíguo (504) só é resgatado pela heurística de 90 s do consumidor quando o Delivered chega com event.Chat cujos dígitos coincidem com contacts.phone; números brasileiros guardados pelo WhatsApp sem o nono dígito não coincidem.
  • A edição in-band não é idempotente no consumidor (edit_count cresce a cada reentrega).
  • O guard de transferência do consumidor responde 503 Retry-After: 5 quando indisponível; a entrega retenta.