# Eventos e webhooks 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: true` → `400 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](https://wpp.atendro.cloud/docs/guias/receber-eventos.md) 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=`. 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 persistido | Payload | Projeção | |---|---|---| | `messages` | `{message: }` | `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, event, answered, startedAt, answeredAt?, mediaStartedAt?, endedAt?, endReason?, endedBy? (local/remote), durationSeconds?}` | Só para quem assina `call`; `{EventType: call, event: call, data: }`. `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. ## Atendimento e gravação de ligações Para instâncias `calls`, `data.event=answered` confirma o aceite com `status=connecting`, `answered=true` e `answeredAt`, antes de começar a mídia. `event=active` acrescenta `mediaStartedAt`. O encerramento mantém os dados do atendimento e informa `durationSeconds`. `ringing` não é aceite. `recording_ready` e `recording_failed` são subeventos de `call`, com `callId` e objeto `recording`; atualizam apenas a gravação. A API fornece `GET /call/history` e `GET /call/{id}/recording` para consulta persistente e download privado. Deduplicar pelo `eventId` do envelope e ordenar pelo `seq` do envelope. Contrato e exemplo em [Chamadas de voz](https://wpp.atendro.cloud/docs/guias/chamadas-de-voz.md).