Skip to content

Dossiê: Evolution GO

Atenção de nomenclatura: "Evolution GO" (este dossiê) é um projeto distinto do mais conhecido "Evolution API" (Node/Baileys), da mesma fundação. Apesar da marca semelhante, o wire-format (casing de campos, forma dos webhooks) é diferente entre os dois. Não reaproveitar adapter/fixtures de um para o outro.

Autenticação

Um único header HTTP custom, apikey (não é Authorization, não é Bearer, não é query string), carrega um de dois tipos de segredo:

  1. GLOBAL_API_KEY (env var do servidor) — exigido nas rotas admin (criar/listar/consultar/ deletar instância). Comparação literal contra o valor configurado no servidor.
  2. token da instância (escolhido pelo chamador ao criar a instância) — exigido nas rotas operacionais: connect, status, qr, pair, disconnect, reconnect, logout e todas as rotas /send/*, /message/*, /user/*, /group/*, /chat/*, /label/*, /newsletter/*, /community/*. O servidor resolve a instância dona do token a partir do próprio header — não existe instanceId separado no header/rota nessas chamadas: o token já identifica a instância.

Ambos os tipos de segredo compartilham o mesmo nome de header; a diferença é puramente qual valor vai nele e qual família de rotas está sendo chamada. Algumas rotas admin adicionais também levam :instanceId como parâmetro de path (ex.: GET /instance/info/:instanceId, DELETE /instance/delete/:instanceId) mantendo apikey=GLOBAL_API_KEY no header.

Este adapter (F1) só implementa capabilities que usam rotas operacionais — portanto EvolutionOptions.apiKey deve ser o token da instância, não o GLOBAL_API_KEY.

Modelo de instância/sessão

Terminologia do provider: "instance" (documentação em português usa "instância"). Não usa "session"/"channel".

  • POST /instance/create (header apikey=GLOBAL_API_KEY) — cria a instância, define name e token. Fora do escopo deste adapter em F1 (rota admin).
  • POST /instance/connect (header apikey=<token da instância>) — inicia o client whatsmeow e começa a geração do QR. Corpo aceita webhookUrl, subscribe (categorias de evento ou ALL), immediate, phone, rabbitmqEnable/websocketEnable/natsEnable. Resposta: {"message":"success","data":{"jid":"","webhookUrl":"...","eventString":"..."}}não inclui o QR code em si.
  • GET /instance/qr (header apikey=<token da instância>) — busca o QR gerado separadamente. data: {"qrcode":"data:image/png;base64,...","code":"2@AbCdEf..."} (campos minúsculos, confirmados via struct/json tags). Pode retornar passkeyStage/passkeyOpenUrl/ passkeyCode quando a conta exige cerimônia de passkey em vez de QR escaneável.
  • POST /instance/pair — suporta pairing code (data.PairingCode, sem json tag — serializa capitalizado). Confirmado como suportado pelo provider, porém fora do escopo declarado desta fase (F1 não lista instance.pairingCode); não implementado neste adapter.
  • GET /instance/status (header apikey=<token>) — data é um struct Go sem json tags, serializa capitalizado: {"Connected": bool, "LoggedIn": bool, "Name": string}.
  • DELETE /instance/logout (header apikey=<token>, corpo vazio) — logout completo, apaga a sessão; exige novo QR/pairing depois. Distinto de POST /instance/disconnect (soft disconnect, mantém sessão, retomável via POST /instance/reconnect) e de DELETE /instance/delete/{instanceId} (admin, GLOBAL_API_KEY, apaga a instância permanentemente — irreversível). Somente logout está no escopo F1.

Mapeamento de estado (GET /instance/statusInstanceState canônico)

ConnectedLoggedInSignificado do providerInstanceState canônico
falsefalsenunca conectada / deslogadadisconnected
truefalsesocket aberto, aguardando escanear QR/pairingqr
truetrueconectada e autenticada (sessão ativa)connected
falsetruecredenciais existem, socket caiu temporariamente (precisa de /instance/reconnect)connecting (suposição — ver "Limites e particularidades")
qualquer valor não-booleano/ausenteformato inesperadounknown (nunca lança)

Operações core

Operação canônicaEndpointObservações
instance.connectPOST /instance/connect + GET /instance/qrO adapter encadeia as duas chamadas: connect inicia a geração do QR no servidor, mas quem devolve o QR é GET /instance/qr. A segunda chamada é best-effort (se falhar ou o QR ainda não estiver pronto, ConnectResult.qr fica undefined, sem lançar).
instance.statusGET /instance/statusVer tabela de mapeamento acima.
instance.logoutDELETE /instance/logoutCorpo vazio; resposta {"message":"success"}.
messages.sendTextPOST /send/textCampo number aceita dígitos crus ("5511999999999", sem +/@) OU JID completo (...@s.whatsapp.net, ...@g.us) já formado — compatível 1:1 com o chatId canônico do waconector (normalizeChatId), então o adapter repassa sem transformação. formatJid (default true) normaliza dígitos crus no servidor.
messages.sendMediaPOST /send/mediaDuas variantes no mesmo endpoint, escolhidas pelo Content-Type: JSON ({number, type, url, caption?, filename?, ...}) ou multipart/form-data (campo binário file). Este adapter implementa a variante JSON — o HttpClient compartilhado sempre serializa o corpo como JSON, não há suporte a multipart/form-data nele. O campo url da variante JSON é overloaded pelo servidor (pkg/sendMessage/handler/send_handler.go): quando o valor não começa com http:///https://, é tratado como base64 e decodificado (base64.StdEncoding.DecodeString) antes do envio — então o adapter envia media.base64 no mesmo campo url quando media.url está ausente. Lança WaConnectorError('INVALID_INPUT', ...) apenas quando nem url nem base64 são informados.
messages.sendReactionPOST /message/reactVer seção "Reações" abaixo.
messages.editPOST /message/editVer seção "Edição e exclusão de mensagem" abaixo.
messages.deletePOST /message/deleteVer seção "Edição e exclusão de mensagem" abaixo.
messages.markReadPOST /message/markreadADR-0013, nível de MENSAGEM. Ver seção "Edição e exclusão de mensagem" abaixo.
messages.sendLocationPOST /send/locationADR-0014. Ver seção "Conteúdo estruturado" abaixo.
messages.sendContactCardPOST /send/contactADR-0014. Ver seção "Conteúdo estruturado" abaixo.
messages.sendPollPOST /send/pollADR-0014. Ver seção "Conteúdo estruturado" abaixo.
groups.createPOST /group/createVer seção "Grupos (núcleo)" abaixo.
groups.getInfoPOST /group/infoVer seção "Grupos (núcleo)" abaixo.
groups.listGET /group/listVer seção "Grupos (núcleo)" abaixo.
groups.addParticipants / groups.removeParticipants / groups.promoteParticipants / groups.demoteParticipantsPOST /group/participantMesmo endpoint para as 4 operações; só o campo action muda. Ver seção "Grupos (núcleo)" abaixo.
groups.updateSubjectPOST /group/nameVer seção "Grupos (núcleo)" abaixo.
groups.updateDescriptionPOST /group/descriptionVer seção "Grupos (núcleo)" abaixo.
groups.updatePicturePOST /group/photoVer seção "Grupos (núcleo)" abaixo.
contacts.listGET /user/contactsVer seção "Contatos" abaixo.
contacts.getPOST /user/infoVer seção "Contatos" abaixo.
contacts.checkExistsPOST /user/checkVer seção "Contatos" abaixo.
contacts.getProfilePicturePOST /user/avatarVer seção "Contatos" abaixo.
contacts.getAboutPOST /user/infoMesmo endpoint de contacts.get — ver seção "Contatos" abaixo.
contacts.blockPOST /user/blockVer seção "Contatos" abaixo.
contacts.unblockPOST /user/unblockVer seção "Contatos" abaixo.
contacts.listBlockedGET /user/blocklistVer seção "Contatos" abaixo.
chats.archivePOST /chat/archiveVer seção "Conversas (chats.*)" abaixo.
chats.mutePOST /chat/muteVer seção "Conversas (chats.*)" abaixo.
chats.pin / chats.unpinPOST /chat/pin / POST /chat/unpinVer seção "Conversas (chats.*)" abaixo.

Reações

  • Endpoint: POST /message/react (pkg/message/service/message_service.go, ReactStruct + pkg/message/handler/message_handler.go) — fica em um pacote separado de /send/* (pkg/message/*, não pkg/sendMessage/*); não confundir os dois ao portar lógica ou procurar o handler no código-fonte.
  • Corpo: {number: string, reaction: string, id: string, fromMe: bool, participant?: string}.
    • number: mesmo formato do campo number de /send/text//send/media (dígitos crus ou JID completo) — o adapter repassa via toProviderNumber, sem transformação.
    • id: o ID da mensagem-alvo da reação. Atenção de nomenclatura: este campo se chama id, não messageId (diferente de EditMessage/DeleteMessageEveryone, no mesmo arquivo, que usam messageId).
    • reaction: o emoji (ex.: "👍"). O servidor rejeita reaction: "" com 400 ("message reaction is required") — para remover uma reação já enviada, o sentinel literal "reaction": "remove" deve ser usado (o serviço traduz isso internamente para texto vazio no protocolo whatsmeow, que é o que de fato sinaliza remoção ao WhatsApp). O modelo canônico do waconector usa emoji: '' para remoção (ADR-0008) — o adapter traduz ''"remove" antes de enviar.
    • fromMe (obrigatório pelo shape do provider): indica se a mensagem-alvo foi originalmente enviada pela própria instância. SendReactionInput (contrato canônico) não carrega essa informação nem participant (autor, necessário em grupos quando fromMe=false) — mesma limitação já documentada para quotedId em sendText/sendMedia. Este adapter sempre envia fromMe: false, o valor seguro para o caso mais comum (reagir a uma mensagem recebida); reagir a uma mensagem enviada pela própria instância é uma limitação conhecida deste adapter em F2 (pode não localizar a mensagem corretamente no protocolo WhatsApp).
  • Resposta 200: {"message": "success", "data": <MessageSendStruct>} — mesmo formato de /send/text//send/media (data.Info.ID/ServerID/Timestamp), reaproveitado por toSentMessage. Erros: 400 (número/reaction ausentes) ou 500 (sem sessão ativa, número inválido, id ausente, falha de envio), ambos {"error": "..."}.
  • Funciona tanto para reagir a mensagens recebidas quanto enviadas pela própria instância (via fromMe), em chats individuais e em grupos (via participant) — nenhuma limitação direcional documentada pelo provider em si; as limitações acima são do adapter, não da API.

Edição e exclusão de mensagem

Ver ADR-0012. Ambas confirmadas com confiança Alta via pesquisa dedicada de capabilities novas (2026-07-12), desta vez a partir dos arquivos OpenAPI oficiais que a própria plataforma expõe (docs.evolutionfoundation.com.br/llms.txtapi-reference/openapi/Evolution-Go/evo-go-message.yaml), não só código-fonte Go — mesmo padrão de confiança já usado no resto deste dossiê quando o site "concorda com o código-fonte".

messages.editPOST /message/edit

  • Fonte: evo-go-message.yaml, schema EditMessage. Corpo: {chat: string, message: string, messageId: string}. Atenção de nomenclatura: o novo texto vai no campo message, não text (diferente de /send/text) nem caption — o adapter traduz EditMessageInput.textmessage.
  • Resposta: {"message":"success","data":{"messageId":"3EB0000000000000000002","timestamp": "0001-01-01 00:00:00 +0000 UTC"}} — um envelope próprio (data.messageId/data.timestamp minúsculos), diferente do {data:{Info:{ID,ServerID,Timestamp}}} usado por /send/text//send/media//message/react (toSentMessage) — por isso o adapter usa um mapeamento dedicado para messages.edit, sem reaproveitar toSentMessage. O próprio exemplo do provider usa um timestamp zero-value (0001-01-01...), então SentMessage.timestamp pode ficar undefined na prática se o provider não popular um valor real — comportamento seguro (timestamp é opcional no contrato canônico).
  • Sem janela de tempo documentada: o spec não valida nenhum prazo para editar (o WhatsApp real tipicamente restringe edição a mensagens recentes, ~15min, mas essa regra é decidida do lado cliente/servidor do próprio WhatsApp, fora do spec do provider) — um eventual limite só se manifestaria como erro 400/404/500 em runtime, nunca validado localmente por este adapter.
  • Erros documentados: 400 (payload inválido), 404 (mensagem não encontrada), 500.

messages.deletePOST /message/delete

  • Fonte: evo-go-message.yaml, schema Message: {chat: string, messageId: string} (nome de schema próprio — não confundir com o objeto de evento de webhook Message, nem com o schema ReactStruct usado por sendReaction). Corpo: {chat, messageId}.
  • A descrição do endpoint no OpenAPI é literalmente "Delete a message for everyone" — ou seja, esta operação é sempre revogação para todos os participantes, nunca um "apagar só localmente"; não existe uma variante de escopo documentada. Isso é coerente com o contrato canônico (DeleteMessageInput não carrega nenhum campo de escopo/onlyLocal — ver ADR-0012) e com o nome do handler Go correspondente (DeleteMessageEveryone, já citado na seção "Reações" acima ao diferenciar o campo id/messageId).
  • Resposta (exemplo real do OpenAPI): {"message":"success","data":{"messageId":"...", "timestamp":"0001-01-01 00:00:00 +0000 UTC"}} — mesmo formato de messages.edit (data presente). A operação canônica retorna Promise<void>, então o adapter ignora o corpo da resposta deliberadamente; o shape exato de data é irrelevante ao comportamento.
  • Mesma ausência de janela de tempo documentada que em messages.edit.

messages.markReadPOST /message/markread (ADR-0013, nível de MENSAGEM)

  • Confirmado via evo-go-message.yaml, schema MarkRead: {id: string[], number: string} — confiança Alta. id é array — permite marcar várias mensagens de uma vez; este adapter sempre envia um array com 1 elemento.
  • Resposta {"message":"success"} — ignorada, Promise<void>.
  • Sem chats.markRead (nível de conversa): a pesquisa dedicada de capabilities novas não encontrou um endpoint de "marcar CHAT INTEIRO como lido/não lido" em evo-go-chat.yaml — só o endpoint de nível de mensagem acima.

Candidatas NÃO confirmadas: messages.forward/star/pin/unpin

Busca na mesma pesquisa (evo-go-message.yaml e evo-go-chat.yaml completos) não encontrou endpoint para nenhuma das 4 — diferente de messages.markRead, que tem confirmação direta no schema, forward/star/pin/unpin simplesmente não aparecem em nenhum dos dois specs. Tratado como limitação real do provider (busca exaustiva nos dois arquivos OpenAPI relevantes), não gap de pesquisa.

Conteúdo estruturado (messages.sendLocation/sendContactCard/sendPoll, ADR-0014)

Cobertura 3/3 — fonte send-message.yaml, confiança Alta para as 3.

messages.sendLocationPOST /send/location

  • Schema SendLocation: {address, delay, id, latitude, longitude, mentionAll, mentionedJid, name, number, quoted}. SendLocationInput.name/.address mapeiam direto para os campos homônimos (ambos opcionais no schema); os demais campos (delay/mentionAll/mentionedJid/ quoted) não têm de onde vir no contrato canônico e não são enviados.
  • Resposta real do provider confirma Type: LocationMessage, Message.locationMessage: {degreesLatitude, degreesLongitude, name, address}.

messages.sendContactCardPOST /send/contact

  • Schema SendContact + VCard: {number, vcard: {fullName, organization, phone}, ...}. O provider não recebe um vCard bruto — só 3 campos soltos e monta o BEGIN:VCARD...END:VCARD completo no servidor (confirmado no exemplo de resposta: FN=fullName, ORG=organization;, TEL;type=CELL;type=VOICE;waid=phone:phone). SendContactCardInput não tem campo de organização — omitido (o schema não o declara obrigatório). Suporta um único telefone por contato, sem múltiplos números/campos extra de vCard.

messages.sendPollPOST /send/poll

  • Schema SendPoll: {number, question, options, maxAnswer, ...}. maxAnswer controla quantas opções o respondente pode marcar (0 = escolha única, no whatsmeow selectableOptionsCount); SendPollInput.allowMultipleAnswers mapeia para maxAnswer: options.length (qualquer número de opções) quando true, 0 quando false/ausente. Resposta confirma Type: PollCreationMessage.

Presença (presence.setTyping, ADR-0015)

Cobertura 1/3 — só setTyping. Sem presence.set/presence.subscribe: nenhum endpoint equivalente encontrado em evo-go-message.yaml/evo-go-chat.yaml.

presence.setTypingPOST /message/presence

  • Schema ChatPresence, confiança Alta. Body: {isAudio, number, state} — vive fisicamente em evo-go-message.yaml, não em evo-go-chat.yaml (onde estão archive/mute/pin/unpin) — mesma inconsistência de organização do provider já notada para sendLocation/etc.
  • state provavelmente aceita os valores whatsmeow padrão (composing/recording/paused), mas os valores exatos não são enumerados no spec — mapeado 1:1 com TypingState por analogia.
  • isAudio (assunção não confirmada por exemplo literal) é enviado como true só quando state === 'recording' — best-effort para distinguir "gravando áudio" de "digitando".

Etiquetas (labels.*, ADR-0016)

Cobertura 6/6 — a pesquisa original (evo-go-label.yaml, spec estático) só confirmava 5/6 (sem list); verificação ao vivo do código-fonte real (label_handler.go/label_service.go, buscado via gh api) encontrou GET /label/list, que não estava no spec estático.

Operação canônicaEndpointObservações
labels.listGET /label/listAchado ao vivo (ver acima). Resposta: array CRU (sem o envelope {message, data} do resto do provider) de registros label_model.Label persistidos no banco do Evolution GO a partir de eventos de app-state sync: {id, instance_id, label_id, label_name, label_color, predefined_id}. label_id/label_name/label_color mapeiam para LabelInfo.id/name/colorlabel_color já vem como STRING no banco.
labels.createPOST /label/editSchema EditLabel: {labelId, name, color: integer, deleted}é o ÚNICO endpoint de escrita de label, compartilhado por create/update/delete. Handler exige labelId já escolhido pelo CHAMADOR ("label id is required") — não há atribuição automática pelo servidor, diferente de um "create" tradicional. Este adapter gera um labelId novo via randomUUID() (Node crypto) a cada create.
labels.updatePOST /label/editMesmo endpoint de create, com deleted: false. UpdateLabelInput.name é sempre obrigatório no contrato canônico (ADR-0016), o que já cobre a exigência de name não vazio do handler sem round-trip.
labels.deletePOST /label/editMesmo endpoint, com deleted: true (SOFT-delete — não existe exclusão física). O handler exige name não vazio em TODA chamada, inclusive delete — como o contrato canônico delete(labelId) não carrega name, este adapter busca o name/color atuais via GET /label/list antes de enviar o deleted: true (exceção deliberada à convenção de uma chamada por operação — ver caveat abaixo).
labels.addToChatPOST /label/chatSchema ChatLabel: {jid, labelId}.
labels.removeFromChatPOST /unlabel/chatMesmo schema ChatLabel de addToChat.

color é opaco no contrato canônico (ADR-0016), mas o Evolution GO exige um integer no corpo de /label/edittoLabelColor converte a string opaca para número (Number(color), 0 como default quando ausente/não numérico; sem paleta documentada no spec estático).

Diferente do resto do provider, o campo de destino em addToChat/removeFromChat é jid, não number — e label_service.go chama utils.ParseJID(data.JID) diretamente, sem a mesma normalização tolerante que number recebe em messages.*/chats.* (utils.CreateJID). Este adapter garante um JID totalmente qualificado antes de enviar, reaproveitando a mesma lógica de toMentionJid (adiciona @s.whatsapp.net só quando o chatId ainda não é JID).

Caveats documentados (não resolvidos, por design, mesmo padrão do color-erasure do QuePasa):

  • labels.create gera um labelId via randomUUID() — o app oficial WhatsApp Business tradicionalmente usa ids numéricos pequenos ("1".."20") para labels; um id UUID passa na validação do Evolution GO e funciona para toda operação feita por ESTE adapter (list/update/ delete/addToChat/removeFromChat ecoam o mesmo id), mas não há confirmação de que o app oficial exibiria corretamente um label criado com um id fora desse padrão numérico — a confirmar contra uma instância real com o app oficial conectado.
  • labels.delete faz um GET /label/list antes do POST /label/edit (2 chamadas HTTP) — não é uma emulação opcional (diferente da recusa de addToChat/removeFromChat da WAHA), é uma NECESSIDADE do único endpoint disponível, que exige name em toda chamada e não faz merge parcial.
  • Não confirmado nesta pesquisa: se GET /label/list filtra registros com deleted: true do lado do servidor, ou se um label apagado continua aparecendo na listagem (o repositório que persiste label_model.Label não foi encontrado/lido nesta pesquisa).

Download de mídia (messages.download, ADR-0020)

Confiança MÉDIA — endpoint confirmado ao vivo (https://docs.evolutionfoundation.com.br/api-reference/openapi/Evolution-Go/evo-go-message.yaml, 2026-07-17), mapeamento do descritor a partir de raw com a mesma confiança MÉDIA já registrada para webhook-message-image.json/webhook-message-document.json (RECONSTRUÍDOS, não capturados ao vivo).

CapabilityEndpointObservações
messages.downloadPOST /message/downloadimageApesar do nome sugerir só imagens, os campos aceitos (directPath/fileEncSHA256/fileLength/fileSHA256/mediaKey/mimetype/url) são os campos genéricos de mídia do whatsmeow — o mesmo endpoint deve servir qualquer MediaKind. Achado que corrige a suposição original da ADR-0020: este endpoint exige o DESCRITOR BRUTO da mídia, não um messageId — o Evolution GO não confirma manter histórico server-side para esta operação (mesmo grupo do izapia, não de uazapi/Whapi). DownloadMediaInput.raw (o WaMessage.raw da mensagem original) é, na prática, obrigatório aqui. Resposta: {success, image: <base64>}.

Canais (channels.*, ADR-0017)

Cobertura 4/6 (list/create/getInfo/follow) — sem delete nem unfollow, busca no newsletter.yaml oficial e no código-fonte real (newsletter_handler.go/newsletter_service.go, verificado ao vivo via gh api) não encontrou DELETE /newsletter/{id} nem POST /newsletter/unsubscribe.

Operação canônicaEndpointObservações
channels.listGET /newsletter/listResposta {message, data: NewsletterMetadata[]}.
channels.createPOST /newsletter/createSchema CreateNewsletterStruct {name, description}. Resposta rica {message, data: NewsletterMetadata} — ver mapeamento abaixo.
channels.getInfoPOST /newsletter/infoSchema GetNewsletterStruct {jid}.
channels.followPOST /newsletter/subscribeMesmo schema GetNewsletterStruct {jid} de getInfo — a lib whatsmeow chama essa operação de "subscribe", mapeada para follow no contrato canônico (ver ADR-0017, Justificativa).

Achado ao vivo que corrige uma leitura equivocada do OpenAPI estático: o newsletter.yaml tipa o campo jid (usado em getInfo/follow) como um objeto ESTRUTURADO {user, server, device, integrator, rawAgent} — reflexo ingênuo dos campos Go de types.JID (whatsmeow) pelo gerador de spec (swaggo/similar, que não introspecta MarshalText/ UnmarshalText). Verificação ao vivo contra o código-fonte real de types.JID (tulir/whatsmeow, types/jid.go) confirma que JID implementa encoding.TextMarshaler/ TextUnmarshalerencoding/json do Go respeita essa interface e trata o campo inteiro como uma STRING opaca (jid.String()"<user>@<server>" na saída; ParseJID(string) na entrada) — o schema documentado no OpenAPI é enganoso. Este adapter envia/recebe channelId como string simples, sem decompor (toEvolutionChannelId, função identidade) — mesmo tratamento de toProviderNumber.

Mapeamento de ChannelInfo (thread_metadata)

A resposta de create/list/getInfo é o struct whatsmeow types.NewsletterMetadata serializado:

json
{
  "id": "120360000000000001@newsletter",
  "state": { "type": "active" },
  "thread_metadata": {
    "creation_time": "1700000000",
    "invite": "0029VaAbBbCcDdEeFfGgHh01",
    "name": { "text": "Nome do canal", "id": "...", "update_time": "..." },
    "description": { "text": "Descrição", "id": "...", "update_time": "..." },
    "subscribers_count": "0",
    "verification": "unverified"
  },
  "viewer_metadata": { "mute": "on", "role": "owner" }
}

name/description vêm aninhados em thread_metadata.{name,description}.text (schema NewsletterText, um formato versionado com id/update_time — só .text é usado pelo contrato canônico). subscribers_count é uma STRING no JSON (json:"subscribers_count,string" no struct Go NewsletterThreadMetadata), convertida para número por mapEvolutionChannel.

Fora de escopo desta ADR (candidatas para rodada futura): channels.getInviteLink (POST /newsletter/link, schema {key: string} — não fica claro se key é o JID, o código de convite, ou outro identificador; ambiguidade não resolvida pela doc disponível).

Mensageria de canal (channels.getMessages, ADR-0021)

Confiança BAIXA-MÉDIA — endpoint confirmado ao vivo (POST /newsletter/messages, spec newsletter.yaml), mas o schema de resposta não documenta o shape do item (messages: object[], sem tipo declarado). jid enviado como string simples (mesmo achado de toEvolutionChannelId já confirmado para os demais endpoints de channels.* — o OpenAPI documenta um objeto types.JID decomposto, mas a API aceita string simples na prática). mapEvolutionChannelPost assume o mesmo shape types.NewsletterMessage do whatsmeow (MessageServerID/Timestamp/ViewsCount/ ReactionCounts/Message, casing PascalCase — mesmo padrão de Info/Message no evento de mensagem recebida), com fallback defensivo para camelCase. Não confirmado contra uma instância real.

Perfil comercial (business.*, ADR-0018) — NÃO implementado

Busca negativa confirmada ao vivo: user_service.go (fonte já cacheada nesta sessão) só tem um campo BusinessName no perfil de UM CONTATO (indicando se esse contato é uma conta Business) — nenhum endpoint para ler/editar o PRÓPRIO perfil comercial da instância. Limitação real de plataforma (mesma raiz whatsmeow do Wuzapi, ver dossiê desse provider), não gap de pesquisa. WaAdapter.business não implementado.

Chamadas de voz (calls.reject, ADR-0019)

Cobertura 1/2 — só reject, achado ao vivo (call_handler.go/call_service.go, não mencionado no relatório original).

Operação canônicaEndpointObservações
calls.rejectPOST /call/rejectBody RejectCallStruct {callCreator: types.JID, callId: string}, AMBOS obrigatórios. callCreator é serializado como STRING simples (mesmo achado do jid de channels.*: types.JID implementa MarshalText/UnmarshalText) — callerId repassado sem transformação (toEvolutionChannelId).

Sem calls.make: a interface CallService só expõe RejectCall(data, instance) error — nenhum método para originar chamada. callId/callerId só disponíveis inspecionando o payload bruto do webhook de chamada recebida (este pacote não faz parsing desse evento ainda) — lança INVALID_INPUT se faltarem.

Grupos (núcleo)

As 14 operações de groups.* (ver ADR-0009) são suportadas por este adapter. Nenhuma das 4 operações de participantes (add/remove/promote/demote), nem POST /group/create, nem as 3 operações de configuração (updateSubject/updateDescription/updatePicture), nem as 4 operações de convite/saída (getInviteLink/revokeInviteLink/joinViaInviteLink/leaveGroup) aparecem na documentação oficial do site (docs.evolutionfoundation.com.br) — foram confirmadas apenas lendo o código-fonte Go (pkg/group/handler + pkg/group/service). getGroupInfo/listGroups seguem o mesmo padrão de já-verificado-no-código usado no resto deste dossiê. Tratamos como alta confiança mesmo assim (fonte primária: o código do provider), mas sem uma captura "ao vivo" — diferente das fixtures de webhook de mensagem/ack/conexão, copiadas literalmente do site.

groups.createPOST /group/create

  • Corpo: {groupName: string, participants: string[]}. Atenção de nomenclatura: o campo é groupName, não name — diverge do padrão do resto do provider (que costuma usar name/number para os campos principais). participants aceita dígitos crus ou JID completo (mesmo formato do campo number de /send/text), e já chega normalizado pelo conector.
  • Resposta: {"message":"success","data":{"jid":"...","name":"...","owner":"...","added": ["..."],"failed":["..."]}} — um envelope de data diferente do whatsmeow GroupInfo usado por getGroupInfo/listGroups (chaves minúsculas aqui: jid/name/owner; capitalizadas lá: JID/Name/OwnerJID). Não há lista detalhada de participantes (com isAdmin/isSuperAdmin) na resposta de criação — o adapter constrói GroupInfo.participants a partir do array added (todos entram como isAdmin:false, já que acabaram de ser adicionados), com fallback para a lista de entrada (input.participants) caso added venha vazio (mesmo padrão de fallback já usado em toSentMessage, ex.: chatId ?? requestedNumber).

groups.getInfoPOST /group/info

  • POST mesmo sendo uma operação de leitura — mesmo padrão do provider para as demais rotas de grupo (nenhuma delas usa GET exceto list).
  • Corpo: {groupJid: string}. groupJid aqui é o groupId opaco do waconector (ver ADR-0009): neste provider, GroupInfo.id já É o JID do whatsmeow (...@g.us, às vezes na forma legada <criador>-<timestamp>@g.us), então nenhuma conversão é necessária — diferente da Z-API, que usa um ID sintético sem @.
  • Resposta: data é o struct GroupInfo do whatsmeow, serializado verbatim (sem json tags, chaves capitalizadas — mesmo padrão de GET /instance/status): {JID, OwnerJID, Name, Topic (= descrição), IsLocked, GroupCreated, Participants: [{JID, PhoneNumber, LID, IsAdmin, IsSuperAdmin, DisplayName, Error, AddRequest}], ...}. O adapter mapeia JID→id, Name→subject, Topic→description, OwnerJID→owner, e cada item de Participants para GroupParticipant (JID→id, com fallback defensivo em PhoneNumber; IsAdmin/ IsSuperAdmin direto).

groups.listGET /group/list

  • Sem corpo. Resposta: {"message":"success","data": GroupInfo[]} — um array do mesmo shape whatsmeow de getGroupInfo, um item por grupo. O adapter reaproveita o mesmo mapeamento (JID/Name/Topic/OwnerJID/Participants) item a item.

groups.addParticipants / groups.removeParticipants / groups.promoteParticipants / groups.demoteParticipantsPOST /group/participant

  • Endpoint único compartilhado pelas 4 operações: corpo {groupJid: string, participants: string[], action: "add"|"remove"|"promote"|"demote"} — só o campo action muda entre elas. O adapter implementa as 4 como wrappers finos sobre uma função interna (updateGroupParticipants) parametrizada por action.
  • participants: dígitos crus ou JID completo, mesmo formato de number/groupId — já chegam normalizados pelo conector (comportam-se como um to de mensagem comum, diferente de groupJid, que é o identificador opaco de grupo).
  • Resposta: {"message":"success"}sem detalhe por participante (o provider descarta essa informação internamente; não é uma limitação deste adapter). As 4 operações canônicas retornam Promise<void> (não precisam devolver o grupo atualizado), então o adapter só dispara a chamada e não extrai nada da resposta além de deixar o HttpClient lançar em caso de erro HTTP.

groups.updateSubjectPOST /group/name

  • Corpo: {groupJid: string, name: string} (SetGroupNameStruct). Atenção de nomenclatura: o campo é name, não subject — o adapter traduz UpdateGroupSubjectInput.subjectname. groupJid segue a mesma conversão opaca de toProviderGroupJid usada pelo resto de groups.*.
  • Resposta: {"message":"success"}, sem data. A operação canônica retorna Promise<void>, então o adapter só dispara a chamada.

groups.updateDescriptionPOST /group/description

  • Corpo: {groupJid: string, description: string}. description pode ser string vazia para limpar a descrição do grupo — o handler permite isso explicitamente, sem validação de tamanho mínimo no servidor (e o conector também trata string vazia como entrada válida, não erro — ver WaConnector.prepareUpdateGroupDescription).
  • Resposta: {"message":"success"}, sem data. Promise<void>, mesmo padrão de updateSubject.

groups.updatePicturePOST /group/photo

  • Corpo: {groupJid: string, image: string}. Divergência importante de formato em relação a messages.sendMedia/POST /send/media (que aceita base64 cru, sem prefixo, no campo url — ver seção messages.sendMedia acima): o campo image deste endpoint só reconhece OU uma URL http(s)://... OU uma data-URI com prefixo exato data:image/jpeg;base64, ou data:image/png;base64,. Base64 cru sem esse prefixo não é aceito aqui. O adapter (toGroupPictureImage) repassa media.url diretamente quando presente; caso contrário monta a data-URI a partir de media.base64, escolhendo o prefixo por media.mimeType (image/png → prefixo PNG; qualquer outro valor, incluindo ausente/image/jpeg, → prefixo JPEG). Suposição não validada contra uma instância real: o default para JPEG quando mimeType está ausente ou não é reconhecido; se o servidor rejeitar um base64 que na verdade não é JPEG com esse prefixo, isso é uma limitação conhecida deste adapter a revisar.
  • Resposta: {"message":"success","data":"<novo pictureID string>"} — o pictureID retornado é ignorado (a operação canônica groups.updatePicture retorna Promise<void>).

groups.getInviteLink / groups.revokeInviteLinkPOST /group/invitelink

  • Endpoint único compartilhado pelas duas operações (mesmo padrão de updateGroupParticipants): corpo {groupJid: string, reset: boolean}reset:false obtém o link de convite atual, reset:true revoga o link atual e gera um novo. O adapter implementa as duas como uma função interna (getGroupInviteLink) parametrizada por reset.
  • Resposta: {"message":"success","data":"<link completo>"} — diferente de outras rotas de grupo cujo data é um objeto/struct, aqui data já É a string do link, e já vem completo (https://chat.whatsapp.com/<código>), não o código bare. O adapter roda o valor por normalizeInviteLink mesmo assim antes de devolver GroupInviteLink.link — idempotente para um valor que já tem o prefixo, e serve de rede de segurança caso uma versão futura do provider passe a devolver só o código.

groups.joinViaInviteLinkPOST /group/join

  • Corpo: {code: string}. Confiança alta (comportamento do whatsmeow, biblioteca usada internamente pelo Evolution GO): o campo code aceita tanto o código bare quanto o link completo — o whatsmeow remove o prefixo https://chat.whatsapp.com/ automaticamente se presente antes de resolver o convite. O conector já normaliza input.invite para o link completo antes de chamar o adapter (ver WaConnector.prepareJoinViaInviteLink); o adapter repassa esse valor diretamente em code, sem usar extractInviteCode.
  • Resposta: {"message":"success"}sem nenhuma informação sobre qual grupo foi de fato ingressado (nem jid, nem data de qualquer tipo). A operação canônica groups.joinViaInviteLink retorna Promise<void>, então isso não é uma limitação prática deste adapter.

groups.leaveGroupPOST /group/leave

  • Corpo: {groupJid: string} — mesma conversão opaca de toProviderGroupJid usada pelo resto de groups.*. Resposta: {"message":"success"}, sem data. Promise<void>, mesmo padrão de updateSubject/updateDescription.

Contatos

As 5 operações de contacts.* cobertas pelo PR1 do ADR-0010 (list, get, checkExists, getProfilePicture, getAbout) são suportadas por este adapter — todas confirmadas via código-fonte Go (pkg/user/handler + pkg/user/service, não documentadas no site oficial docs.evolutionfoundation.com.br). Mesmo padrão de confiança já usado na seção "Grupos (núcleo)": alta confiança (fonte primária é o código do provider), mas sem captura "ao vivo" de payload real.

Regra de ouro (ADR-0010): cada operação mapeia para exatamente UMA chamada HTTP ao provider — nunca compomos 2+ chamadas por trás de uma única operação canônica, mesmo quando isso deixaria campos do Contact mais completos. Os campos que o endpoint mais próximo não confirma ficam undefined — documentado abaixo, não é bug do adapter.

contacts.listGET /user/contacts

  • Sem corpo. Resposta: {"message":"success","data": ContactInfo[]}, onde ContactInfo = {Jid, Found, FirstName, FullName, PushName, BusinessName}. Este é o contact store local do whatsmeow (contatos já conhecidos pela sessão), não uma busca "ao vivo" no WhatsApp.
  • Mapeamento: Jid → id; name escolhido pela ordem de preferência FullName > FirstName > PushName (o primeiro campo populado, nessa ordem, vence).
  • Sem about/profilePictureUrl/hasWhatsApp: este endpoint não devolve nenhum dos três. Em particular, hasWhatsApp fica undefined em vez de assumir true — todo item da lista já é um contato conhecido da sessão, mas isso não é o mesmo que uma confirmação explícita de "tem WhatsApp" (essa confirmação vem de contacts.checkExists).
  • Cada item do array carrega seu próprio raw (o registro individual, não o envelope inteiro) — mesmo padrão de groups.list.

contacts.getPOST /user/info

  • Não existe um endpoint único "getContact" ideal no Evolution GO — este é o melhor match disponível de uma única chamada (ver ADR-0010, achado "getContact não é uma única chamada completa em 3 dos 5 providers").
  • Corpo: {number: [chatId], formatJid: true}atenção: number é um array aqui (mesmo para consultar um único contato), diferente do campo number (string única) de /send/text e de /user/avatar.
  • Resposta: {"message":"success","data":{"Users": {"<jid>": {"VerifiedName", "Status", "PictureID", "Devices", "LID"}}}}Users é um MAP indexado por JID aqui (diferente de contacts.checkExists, onde Users é um array). Como o corpo só pede um número, o adapter extrai a primeira (e única) entrada do mapa; a própria chave do mapa é o JID já resolvido pelo servidor (via formatJid), usado como Contact.id (com fallback defensivo para o chatId de entrada caso o mapa venha vazio).
  • Limitações documentadas (não são bugs — a resposta do provider genuinamente não traz esses dados):
    • name fica sempre undefined: a resposta não inclui nenhum nome de exibição comum (nem PushName nem FullName) — quem precisar do nome deve chamar contacts.list() em vez de contacts.get().
    • profilePictureUrl fica sempre undefined: PictureID é só um id/hash interno da foto atual do contato, não uma URL utilizável — popular profilePictureUrl a partir dele seria inventar um dado que a resposta não contém (violaria a regra de ouro do ADR-0010). Quem precisar da URL deve chamar contacts.getProfilePicture separadamente.
    • VerifiedName (quando presente) só é preenchido para contas Business verificadas — não é o nome comum de exibição de um contato qualquer, por isso não é usado como fallback de name.
    • about é populado a partir de Status.

contacts.getAboutPOST /user/info (mesmo endpoint de contacts.get)

  • Reaproveita a MESMA chamada/função interna de contacts.get (fetchUserInfo) — nenhuma requisição adicional é disparada. data.Users[jid].Status → about.

contacts.checkExistsPOST /user/check

  • Corpo: {number: [phone], formatJid: true}number também é array aqui.
  • Resposta: {"message":"success","data":{"Users": [{"Query", "IsInWhatsapp", "JID", "RemoteJID", "LID", "VerifiedName"}]}}Users é um ARRAY aqui (diferente de contacts.get, onde Users é um mapa por JID) — atenção a essa inversão de shape entre os dois endpoints do mesmo pacote pkg/user/*. O adapter pega o primeiro item do array (o corpo só pede um número).
  • Mapeamento: IsInWhatsapp → exists; JID → chatId (o JID resolvido pelo servidor — útil para o chamador encadear messages.sendText/contacts.get sem precisar renormalizar o telefone).

contacts.getProfilePicturePOST /user/avatar

  • Corpo: {number: chatId, preview: false}diferente de contacts.get/contacts.checkExists, aqui number é uma string única, não um array.
  • Resposta: {"message":"success","data":{"ID", "URL", "Type", "DirectPath", "Hash"}}; URL → url.
  • Se o contato não tiver foto definida (ErrProfilePictureNotSet no whatsmeow), o servidor normalmente responde com erro HTTP 4xx/5xx — o adapter não captura nem mascara esse erro; ele propaga como qualquer outra falha HTTP deste adapter (vira PROVIDER_ERROR via HttpClient).

contacts.block / contacts.unblockPOST /user/block / POST /user/unblock

  • Dois endpoints distintos (diferente do padrão "endpoint único com parâmetro" já usado em groups.addParticipants/removeParticipants/.../getInviteLink/revokeInviteLink), mas com o MESMO shape de corpo/resposta — o adapter implementa como duas funções finas irmãs (blockContact/unblockContact), sem uma função interna compartilhada.
  • Corpo: {number: chatId}number é uma string única (mesmo padrão de contacts.getProfilePicture, diferente do array usado por contacts.get/contacts.checkExists).
  • Resposta: {"message":"success","data":{"DHash":"...","JIDs":["..."]}}data.JIDs é a lista COMPLETA e já atualizada de contatos bloqueados após a operação (não só o item recém- bloqueado/desbloqueado). O adapter ignora esse retorno por completo: ambas as operações canônicas retornam Promise<void>, e popular listBlocked a partir daqui violaria a regra de ouro do ADR-0010 (uma operação não deveria inferir o resultado de outra a partir de um efeito colateral incidental) — quem precisar da lista atualizada deve chamar contacts.listBlocked() separadamente logo em seguida.

contacts.listBlockedGET /user/blocklist

  • Sem corpo. Resposta: {"message":"success","data":{"DHash":"...","JIDs":["..."]}} — mesmo shape de data de contacts.block/contacts.unblock (não por coincidência: os 3 endpoints vivem no mesmo pkg/user/*).
  • Mapeamento: data.JIDs → array de strings retornado diretamente, sem remapeamento por item — já vem no formato JID canônico, o mesmo formato usado como Contact.id no resto deste adapter.
  • Suportado neste provider (diferente de WAHA e Z-API, que não expõem um endpoint dedicado de listagem de bloqueados e por isso não declaram contacts.listBlocked — ver ADR-0010, mesmo padrão de omissão seletiva de capability já usado para contacts.getAbout na uazapi).

Conversas (chats.*)

Namespace inteiramente novo (ver ADR-0012), confirmado via OpenAPI oficial evo-go-chat.yaml (pesquisa dedicada de 2026-07-12). Os 4 endpoints documentados ali compartilham exatamente o mesmo schema ChatBody: {number: string} — mesmo formato do campo number de /send/text (dígitos crus ou JID completo).

chats.archivePOST /chat/archive

  • Confiança Alta. Corpo {number}. Resposta (exemplo real do OpenAPI): {"message":"success", "data":{"timestamp":"0001-01-01 00:00:00 +0000 UTC"}} — ignorada pelo adapter (Promise<void>).
  • Não existe POST /chat/unarchive no spec oficial — só a ação de arquivar está documentada. Este adapter, portanto, não declara chats.unarchive (não é possível confirmar se chamar /chat/archive de novo funciona como toggle, e inventar esse comportamento sem confirmação violaria o mesmo princípio já usado em ADR-0010 para não popular campos não confirmados).

chats.mutePOST /chat/mute

  • Confiança Alta. Corpo {number}sem nenhum campo de duração (nem duration, nem until, nem hours). Diferente de outros providers do waconector que suportam "mutar por 8h/1 semana/sempre", aqui o schema só identifica o chat — sugere mute permanente/indefinido até uma ação de desmutar. Como nenhum formato de duração converge entre os providers pesquisados (ver ADR-0012), o contrato canônico ChatsApi.mute também não tem parâmetro de duração, então isso não é uma limitação própria deste adapter.
  • Não existe POST /chat/unmute no spec oficial — mesma ausência de archive/unarchive. Este adapter não declara chats.unmute pelo mesmo motivo.

chats.pin / chats.unpinPOST /chat/pin / POST /chat/unpin

  • Confiança Alta para os dois. Corpo {number} em ambos. Diferente de archive/mute, este é o único par de chats.* completo (as duas direções existem no spec oficial) — por isso é o único par simétrico implementado por este adapter.

chats.markRead / chats.markUnread — fora de escopo deste adapter (nesta fase)

Nenhum endpoint de "marcar conversa inteira como lida/não lida" foi encontrado em evo-go-chat.yaml. O único endpoint de "marcar como lida" confirmado na pesquisa é POST /message/markread (evo-go-message.yaml, schema MarkRead: {id: string[], number: string}) — que opera por uma lista de messageId, não pelo chat inteiro. Por definição do próprio ADR-0012 (chats.markRead/ markUnread são operações de nível de CHAT, distintas de um eventual messages.markRead por mensagem), esse endpoint mapeia para uma capability diferente (messages.markRead, fora do escopo desta ADR) — não para chats.markRead. Sem um endpoint de nível de chat confirmado, este adapter não declara nem chats.markRead nem chats.markUnread.

Webhooks

Duas camadas independentes e simultâneas: (a) global via env WEBHOOK_URL do servidor (todas as instâncias); (b) por instância via webhookUrl no corpo de POST /instance/connect (só aquela instância). Filtragem de eventos via array subscribe (mesma chamada): categorias MESSAGE, SEND_MESSAGE, READ_RECEIPT, PRESENCE, HISTORY_SYNC, CHAT_PRESENCE, CALL, CONNECTION, LABEL, CONTACT, GROUP, NEWSLETTER, QRCODE, BUTTON_CLICK, PICTURE, USER_ABOUT, ou ALL. Entrega via HTTP POST, Content-Type: application/json, retentado até 5x a cada 30s até 2xx. Importante: o valor pedido em subscribe é a categoria (maiúscula); o event recebido no payload é o nome individual do evento em PascalCase (ex.: subscribe:['CALL'] recebe CallOffer/CallAccept/CallTerminate, não "CALL" literal).

O envelope de webhook expõe os campos do struct Go/whatsmeow verbatim (capitalizados: Info, Message, Chat, Sender, IsFromMe, PushName, ID, Timestamp, MessageIDs, Type) — isso é estruturalmente diferente do envelope estilo Baileys (key/message/messageTimestamp minúsculos) do projeto "Evolution API" (não confundir os dois ao portar lógica).

Qualidade da documentação: os arquivos docs/wiki/*.md do próprio repositório terminam com "Documentação gerada para Evolution GO v1.0" (auto-gerados) e divergem do código-fonte real em pontos verificáveis (ex.: mostram GET /instance/status e POST /instance/pair com chaves JSON minúsculas, e um payload de mensagem estilo Baileys — nenhum dos dois bate com os structs reais/swagger.json). O site docs.evolutionfoundation.com.br concordou com o código-fonte em todos os pontos checados nesta pesquisa. Este dossiê segue o site + código-fonte, não o wiki do repo.

Payloads de webhook (fixtures em src/adapters/evolution/fixtures/)

  • webhook-message-received.jsoncopiado literalmente do site de docs (docs.evolutionfoundation.com.br), cruzado com pkg/whatsmeow/service/whatsmeow.go.
  • webhook-ack.jsoncopiado literalmente do site de docs, cruzado com o mesmo arquivo-fonte (evento Receipt, stateRead/Delivered/ReadSelf).
  • webhook-connection-update.jsoncopiado literalmente do site de docs (evento Connected, data.status="open").
  • webhook-message-image.json / webhook-message-document.jsonRECONSTRUÍDOS (não capturados "ao vivo"; nenhuma doc/fixture de mensagem de mídia foi encontrada na pesquisa original). Montados a partir dos structs gerados do whatsmeow (waE2E/WAWebProtobufsE2E.pb.goImageMessage/DocumentMessage), com especial atenção ao campo de URL: os structs tagueiam esse campo como json:"URL,omitempty" (maiúsculo), diferente de mimetype/caption/fileName (minúsculo/camelCase). Isso foi confirmado diretamente no .pb.go, não em prosa de doc de terceiros — um bug anterior deste adapter lia record.url (minúsculo) e por isso nunca populava WaMessage.media para nenhuma mensagem de mídia recebida. buildMediaRef agora lê record.URL (com fallback defensivo para record.url).

Evento adicional documentado no dossiê de pesquisa mas RECONSTRUÍDO (extraído diretamente do código-fonte handleQRCodes, não copiado de prosa de nenhuma doc) — não incluído como fixture própria porque não foi capturado de uma fonte "ao vivo", apenas citado aqui para referência:

json
{
  "event": "QRCode",
  "data": { "qrcode": "data:image/png;base64,....", "code": "2@AbCdEf...", "count": 1, "maxCount": 0 },
  "instanceToken": "...", "instanceId": "...", "instanceName": "..."
}

O adapter trata QRCode como connection.update com state:"qr" e qr = data.qrcode (fallback data.code), mas isso não foi exercitado com um payload "ao vivo" — tratar com confiança média.

Outros eventos da categoria CONNECTION mapeados como connection.update (mesma lógica dos exemplos acima, não fixturados individualmente): Disconnecteddisconnected, LoggedOutdisconnected, ConnectFailuredisconnected, PairSuccessconnected, TemporaryBandisconnected (suposições — ver "Limites e particularidades").

Forma exata do campo Message para tipos não-texto (imageMessage, videoMessage, documentMessage/documentWithCaptionMessage, stickerMessage, ...) não foi enumerada na pesquisa original — é passthrough do encoding protobuf→JSON do whatsmeow. O adapter detecta o tipo pela chave presente e extrai url/mimetype/fileName/caption quando existirem nesse nível, sem garantia de cobertura completa (ver openQuestions abaixo).

Webhooks de grupo

Confiança MÉDIA-ALTA: assim como o restante desta seção, os dois eventos abaixo foram RECONSTRUÍDOS a partir do código-fonte da biblioteca whatsmeow (subjacente ao Evolution GO) — nenhum payload "ao vivo" foi capturado. Mesma metodologia já usada para webhook-message-image .json/webhook-message-document.json acima. Trate como confiança média-alta, não como "copiado literalmente do provider" (diferente de webhook-message-received.json/webhook-ack.json /webhook-connection-update.json); validar contra uma instância real antes de depender disso em produção crítica.

Pré-requisito de assinatura: a categoria GROUP precisa estar no array subscribe de POST /instance/connect (ver EvolutionOptions.subscribe) para que esses eventos cheguem — isso é responsabilidade do consumidor do adapter configurar, não algo que o código deste adapter possa fazer sozinho (ver seção "Webhooks" acima).

GroupInfo — evento de DIFF de grupo (events.GroupInfo do whatsmeow)

data é o struct events.GroupInfo do whatsmeow serializado verbatim (sem json tags, campos capitalizados): {JID, Notify, Sender, SenderPN, Timestamp, Name, Topic, Locked, Announce, Ephemeral, MembershipApprovalMode, Delete, Link, Unlink, NewInviteLink, PrevParticipantVersionID, ParticipantVersionID, JoinReason, Join: JID[], Leave: JID[], Promote: JID[], Demote: JID[], Suspended, Unsuspended, UnknownChanges}. É um evento de diff: pode reportar múltiplas mudanças simultâneas no mesmo payload (ex.: adicionar 2 participantes E promover um terceiro no mesmo evento) — comum em providers baseados em whatsmeow.

Implementado (mapGroupInfoEvent, em src/adapters/evolution/index.ts) — emite um GroupUpdateEvent por mudança identificada (parseWebhook já retorna CanonicalEvent[], então vários eventos a partir de um único payload de entrada é natural, não um caso especial):

Campo do payloadCondiçãoGroupUpdateEvent emitido
Join (JID[])array não vazio{groupId: data.JID, action: 'participants.add', participants: data.Join}
Leave (JID[])array não vazioaction: 'participants.remove', participants: data.Leave
Promote (JID[])array não vazioaction: 'participants.promote', participants: data.Promote
Demote (JID[])array não vazioaction: 'participants.demote', participants: data.Demote
Name (objeto)truthyaction: 'subject', sem participants
Topic (objeto)truthyaction: 'description', sem participants

Join/Leave/Promote/Demote já vêm como array de strings JID ([]types.JID no whatsmeow, não uma lista de objetos de participante) — por isso não passam por mapGroupParticipant (que existe para o shape {JID, IsAdmin, IsSuperAdmin, ...} de Participants em getGroupInfo/ listGroups; aqui não há esse shape, só a string do JID, já no mesmo formato usado no resto do adapter para chatId/participante).

Deliberadamente fora de escopo (cai em unknown em vez de inventar uma action genérica): os demais campos do diff — Locked, Announce, Ephemeral, MembershipApprovalMode, Delete, Link, Unlink, NewInviteLink, Suspended, Unsuspended, UnknownChanges. Nenhum deles tem uma action canônica correspondente hoje em GroupUpdateEvent (ver src/core/events.ts); se um payload GroupInfo só contiver essas mudanças (nenhum de Join/Leave/Promote/Demote/Name/ Topic populado), o evento inteiro vira unknown — comportamento seguro (ADR-0002/ADR-0003), não uma regressão.

Também não extraído: o novo valor de Name/Topic (ex. o novo texto do assunto/descrição do grupo). O struct real (types.GroupName/types.GroupTopic) tem campos aninhados (Name/NameSetAt/NameSetBy, Topic/TopicID/TopicSetAt/TopicSetBy/TopicDeleted), mas sem um exemplo de payload real para confirmar o formato exato, preferimos só sinalizar a mudança (action: 'subject'/'description') sem tentar ler um campo de valor que poderia estar errado — quem consumir o evento e precisar do novo valor deve chamar groups.getInfo em seguida.

JoinedGroup — a própria sessão entrou em um grupo

data mistura campos específicos do evento (Reason, Type, CreateKey, Sender, SenderPN, Notify) com os campos do GroupInfo completo do grupo ingressado, achatados no mesmo nível (JID, Name, Participants, ...).

Implementado (mapJoinedGroupEvent): mapeia para {groupId: data.JID, action: 'participants.add'} — sem participants (o evento não identifica "quem" foi adicionado além da própria sessão, que não tem um JID canônico aqui) e sem extrair Reason/Type/o GroupInfo embutido (sem campo canônico correspondente em GroupUpdateEvent para eles).

Fixtures (src/adapters/evolution/fixtures/)

Todas marcadas como RECONSTRUÍDAS (mesmo aviso de confiança acima):

  • webhook-group-participants-add.jsonGroupInfo com só Join populado (2 participantes).
  • webhook-group-multiple-changes.jsonGroupInfo com Join e Promote simultâneos no mesmo payload, cobrindo o caso de múltiplos GroupUpdateEvent a partir de um único webhook.
  • webhook-group-subject-update.jsonGroupInfo com Name populado (action: 'subject').
  • webhook-group-joined.jsonJoinedGroup.

Limites e particularidades

  • Gate de licença: servidor recém-implantado devolve HTTP 503 em endpoints de API até ativação via Manager UI (http://host:port/manager/login com GLOBAL_API_KEY) — relevante para bootstrap/health check, não tratado por este adapter (apenas surge como PROVIDER_ERROR comum).
  • Auth é 100% via header, sem query string nem Bearer em nenhum lugar do código-fonte.
  • Sem rate limiting implementado ou configurável no código/.env — apenas recomendação informal de ~50 req/s por instância.
  • /send/button e /send/list são documentados como não-funcionais fora de contas WhatsApp Business API (restrição da plataforma, não bug do Evolution GO); /send/poll é o substituto sugerido. Nenhum dos dois está no escopo deste adapter.
  • Áudio enviado via /send/media é sempre transcodificado para Opus/PTT no servidor; imagens fora de jpg/png são convertidas; vídeo é documentado como MP4-only. Nenhuma dessas conversões é feita pelo adapter — é comportamento do servidor.
  • mentionedJid é array de strings (alguns docs de terceiros mostram como string única — está errado conforme o struct real TextStruct). Diferente do campo number (normalizado server-side via utils.CreateJID independente do formato), pkg/sendMessage/service/send_service.go copia data.MentionedJID verbatim para ContextInfo.MentionedJID no protobuf de saída — sem nenhuma normalização/CreateJID no caminho de menções. Por isso o adapter não reusa a função de mapeamento de to: SendTextInput.mentions passa por toMentionJid, que anexa @s.whatsapp.net a dígitos crus e repassa JIDs explícitos intactos. Sem isso, uma menção com dígitos crus é enviada sem erro mas não destaca o participante (menção muda).
  • quoted no envio de texto/mídia aceita {messageId, participant}; o modelo canônico (quotedId) não carrega participant, então o adapter só envia messageId. Se o provider exigir participant para citar corretamente uma mensagem de grupo, isso é uma limitação conhecida deste adapter em F1.
  • messages.sendReaction (POST /message/react) sempre envia fromMe: false e nunca envia participant — o contrato canônico (SendReactionInput) não carrega nenhum dos dois. Reagir a uma mensagem enviada pela própria instância, ou a uma mensagem de grupo onde participant seria necessário, é uma limitação conhecida deste adapter. Ver seção "Reações" acima.
  • Suposição (estado Connected=false, LoggedIn=true): mapeado para connecting em vez de disconnected, por representar uma sessão válida em processo de recuperação (o provider mesmo recomenda /instance/reconnect). Não verificado com o provider real; revisar se a suposição se mostrar inadequada em uso real.
  • Suposição (PairSuccessconnected, TemporaryBan/demais falhas → disconnected): o dossiê de pesquisa não define esses mapeamentos explicitamente para o modelo canônico do waconector; escolhidos por serem os estados canônicos mais próximos semanticamente.
  • Envio de mídia via base64 é suportado neste adapter, apesar do dossiê original e da doc oficial (docs.evolutionfoundation.com.br) afirmarem o contrário: pkg/sendMessage/handler/ send_handler.go mostra que a variante JSON de POST /send/media decodifica data.Url como base64 quando o valor não começa com http:///https://. O adapter envia media.base64 nesse mesmo campo url quando media.url está ausente. Só lança WaConnectorError('INVALID_INPUT') quando nem url nem base64 são informados (multipart/form-data com campo binário file continua fora do escopo, pois o HttpClient compartilhado sempre serializa o corpo como JSON).
  • Timeout do cliente HTTP de webhook do próprio servidor Evolution GO não está explicitamente configurado no código-fonte (&http.Client{} puro) — não afeta este adapter (que só recebe webhooks, não os envia), citado apenas para contexto.
  • instance.pairingCode não implementado nesta fase apesar de confirmado no provider — fora do escopo declarado para F1 deste adapter.
  • chats.unarchive/chats.unmute/chats.markRead/chats.markUnread não implementados: sem endpoint confirmado no OpenAPI oficial (evo-go-chat.yaml só documenta archive/mute/pin/ unpin, não os inversos de archive/mute; e o único "marcar como lida" confirmado, POST /message/markread, opera por mensagem, não por chat inteiro) — ver seção "Conversas (chats.*)" acima.
  • messages.edit/messages.delete não validam nenhuma janela de tempo localmente — o spec do provider não documenta um prazo, então um eventual limite (ex.: ~15min do WhatsApp real) só se manifestaria como erro HTTP em runtime, nunca antecipado por este adapter.
  • Outras capabilities candidatas confirmadas na mesma pesquisa dedicada de 2026-07-12 mas deliberadamente fora do escopo desta rodada (ADR-0012 cobre só messages.edit/delete e o núcleo de chats.*): messages.markRead (nível de mensagem, batch de IDs), messages.getStatus, chats.setPresence, messages.sendLocation/sendContact/sendPoll/sendLink/sendSticker, os namespaces labels.*/newsletters.*/community.* inteiros, contacts.getPrivacySettings, instance.setProfilePicture e instance.disconnect — candidatos a ADRs/fases futuras, não perdidos, apenas não fazem parte desta mudança. (messages.downloadMedia, citado aqui na pesquisa original com o bug de rate-limit 429 autodocumentado, foi promovido e implementado como messages.download na Epic 12 — ver seção "Download de mídia" acima.)

Client HTTP para APIs de terceiros — sem afiliação com Meta/WhatsApp.