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):
{"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,deletedesativa inclusive em mudanças globais;inheritvolta ao destino global. Reregistrar a mesma URL mantém o cursor, a saúde e osecret(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 emATENDROZAP_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ãoconnection,messages,messages_update,messages_edit,groups,limits,undecryptable,call;history,chats,qrcode,presence,contacts,labels,chat_labels,blocks,senderenewsletter_messagessão aceitos e devolvidos emignoredEvents. Nomes desconhecidos →400 invalid_event.addUrlEvents/addUrlTypesMessages: true→400 unsupported_option(o nome do evento vai no corpo e emX-AtendroZAP-Event-Type).excludeMessages:wasSentByApi,wasNotSentByApi,fromMeYes,fromMeNo,isGroupYes,isGroupNo— filtram sómessages. Envios pela API nunca gerammessagesde qualquer forma.- O cadastro global persistido usa
GET/POST /v1/webhookcomadmintoken. 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_URLeATENDROZAP_WEBHOOK_SECRETapenas 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
POSTJSON, 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-Timestampe, comsecret,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 comFOR UPDATE SKIP LOCKEDe 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 porseq; 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,422dead-letter imediato;401/403dead após 3 tentativas; o resto (rede, timeout, 3xx, 404, 429, 5xx) retenta com backoff exponencial de 5 s, teto 10 min, jitter,Retry-Afterhonrado; 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}/replaypõe um evento (dead ou entregue) de volta na fila com orçamento novo de tentativas; eventos de antes do registro atual respondem409 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-Ide recusarX-AtendroZAP-Timestampfora de uma janela (5 min) quando verificarem a assinatura.
Catálogo (formato persistido → projeção legada)
| Tipo persistido | Payload | Projeçã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, undecryptable | próprio | Só 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.groupNamequando o assunto é conhecido (sincronizado noconnected, 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 gerammessages: são lidas por/message/find(padrão) e/chat/find. - Chats LID sem forma telefônica conhecida saem com
@lidemchatid; nenhum telefone é fabricado.
Limites conhecidos do consumidor
- Recibos para o mesmo id em < 5 s são descartados pelo consumidor (debounce);
Deliveredseguido deReadem < 5 s perde oRead. - Um envio estacionado como ambíguo (504) só é resgatado pela heurística de 90 s do consumidor quando o
Deliveredchega comevent.Chatcujos dígitos coincidem comcontacts.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_countcresce a cada reentrega). - O guard de transferência do consumidor responde
503 Retry-After: 5quando indisponível; a entrega retenta.