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, 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 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, event, answered, startedAt, answeredAt?, mediaStartedAt?, endedAt?, endReason?, endedBy? (local/remote), durationSeconds?, video, videoState?}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). 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: <payload>}. 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.

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.

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.