# 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`, `call_settings`; `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?, video, videoState?}` | 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). `video` diz se a chamada é/foi de vídeo; `videoState` `{active, sending, receiving, requested, pending, orientation}` só enquanto viva; `data.event: video` marca mudanças de vídeo sem mudança de `status` | | `call_settings` (só instâncias `kind: calls`) | `{kind: calls, callsMaxConcurrent, incomingCallMode, callsConfigVersion, updatedAt}` | Só para quem assina `call_settings`; `{EventType: call_settings, event: call_settings, data: }`. Snapshot gravado atomicamente com cada POST de configuração aceito, mesmo sem mudança de valor ou com a instância desconectada | Com `capabilities.calls_incoming_policy=true`, chamadas recebidas também carregam `incomingCallMode` e `callsConfigVersion`, congelados quando a oferta chega. Recusas confirmadas incluem `rejectionSource: policy|busy|manual`. `autoReplyRequested=true` exige recusa bem-sucedida pela política `reject_reply`; é uma solicitação ao consumidor, não confirmação de envio de texto. Recusas por ocupação também geram histórico/evento. O consumidor deve deduplicar a solicitação pela identidade composta servidor/instância/chamada e manter o resultado de envio separado do encerramento da ligação. Não responder a `ring_timeout`, atendimento em outro dispositivo ou recusa manual só por observar `ended`. Eventos internos e `qrcode` nunca são entregues; o QR nunca entra em `events`. Em instâncias `calls`, a projeção bloqueia eventos de mensagens, recibos, edições, grupos e `undecryptable`, inclusive os que já estavam na fila antes do filtro. A sessão também deixa de persistir novos eventos dessas categorias e histórico de mensagens. `connection` e `limits` continuam disponíveis além de `call` e `call_settings`, conforme a inscrição. ## Sincronização do limite de chamadas As adições desta seção aguardam publicação e são anunciadas por `capabilities.calls_settings_webhook=true`. O envelope e sua assinatura não mudam. Exemplo sintético parcial: ```json { "EventType": "call_settings", "event": "call_settings", "eventId": "00000000-0000-4000-8000-000000000002", "instanceId": "00000000-0000-4000-8000-000000000001", "seq": 42, "data": { "kind": "calls", "callsMaxConcurrent": 2, "incomingCallMode": "allow", "callsConfigVersion": 3, "updatedAt": "2026-09-19T15:00:00.123456789Z" } } ``` `callsMaxConcurrent` é inteiro de 1 a 8. `callsConfigVersion` é inteiro de 64 bits, crescente por instância; não é a sequência do envelope. `updatedAt` é UTC em RFC3339Nano. Cada POST aceito em `/instance/call-settings` ou `/v1/instances/{id}/call-settings` incrementa a versão e grava o evento na mesma transação. Falha de persistência não deixa configuração sem evento. Retries do webhook preservam o mesmo `eventId` e a mesma versão; repetir o POST cria outra versão, mesmo enviando o mesmo limite. Objetos HTTP `instance` de tipo `calls` expõem `callsMaxConcurrent`, `incomingCallMode` e `callsConfigVersion`, inclusive versão `0`; a data HTTP mantém o nome `updated`. O evento `connection` acrescenta esses campos no objeto `instance`, junto de `kind: calls`, quando consegue ler o estado persistido. Limite, modo e versão são opcionais em `connection`; sua ausência não representa zero e não autoriza substituir uma configuração conhecida. Após assinar `call_settings`, consulte `GET /instance/status`: o cadastro do webhook não gera snapshot inicial nem reenvia o backlog. Aplique snapshots de HTTP, `connection` e `call_settings` por versão para resolver disputas com respostas em trânsito. Ignore versões menores e trate a mesma versão de forma idempotente. Não use `updatedAt` ou horário de chegada para decidir qual vence. Na redução do limite, chamadas vivas continuam; novas admissões ficam bloqueadas até haver vaga. Veja o [prompt de integração](https://wpp.atendro.cloud/docs/guias/atendro-ligacoes-simultaneas.md). ## 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. `data.event=video` é um subevento de `call` para mudanças do vídeo sem mudança de `status`: pedido do contato (`videoState.requested`), aceite, câmera ligada ou desligada de cada lado. Todo evento de chamada traz `video` (booleano, permanente após negociado) e, enquanto a chamada vive, `videoState`. Exige `capabilities.calls_video`. `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). `video_recording_ready` e `video_recording_failed` também são subeventos de `call`, entregues a quem assina `call`. Trazem `callId`, `timestamp` e `videoRecording: {status, contentType: "video/mp4", durationMs, bytes, failureReason}`. Atualizam somente a gravação de vídeo, independentemente de `recording` do WAV. Não são `ended`, não alteram `endReason`/`endedBy`, não liberam vagas de ligações e não exigem `data.status` da chamada. A entrega pode ser processada antes ou depois do encerramento. O resultado e seu evento são gravados atomicamente, preservando o primeiro resultado terminal e o mesmo ID nas tentativas de entrega. Enquanto o MP4 está na fila ou sendo composto, a consulta informa `videoRecording.status=processing`; não existe webhook de progresso obrigatório. O MP4 pronto usa `GET /call/{id}/recording/video`, privado e com suporte a `Range`. Na consulta a duração usa `durationSeconds`; nos subeventos usa `durationMs`. Consulte o [guia de gravação de vídeo](https://wpp.atendro.cloud/docs/guias/captura-video.md).