Dossiê: Evolution GO
- Docs oficiais: https://docs.evolutionfoundation.com.br/evolution-go
- Versão testada: documentação consultada em 2026-07-10
- Hospedagem: self-hosted (imagem Docker; requer ativação de licença via Manager UI antes do uso)
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:
- 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.
- 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 existeinstanceIdseparado 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(headerapikey=GLOBAL_API_KEY) — cria a instância, definenameetoken. Fora do escopo deste adapter em F1 (rota admin).POST /instance/connect(headerapikey=<token da instância>) — inicia o client whatsmeow e começa a geração do QR. Corpo aceitawebhookUrl,subscribe(categorias de evento ouALL),immediate,phone,rabbitmqEnable/websocketEnable/natsEnable. Resposta:{"message":"success","data":{"jid":"","webhookUrl":"...","eventString":"..."}}— não inclui o QR code em si.GET /instance/qr(headerapikey=<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 retornarpasskeyStage/passkeyOpenUrl/passkeyCodequando 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 listainstance.pairingCode); não implementado neste adapter.GET /instance/status(headerapikey=<token>) —dataé um struct Go sem json tags, serializa capitalizado:{"Connected": bool, "LoggedIn": bool, "Name": string}.DELETE /instance/logout(headerapikey=<token>, corpo vazio) — logout completo, apaga a sessão; exige novo QR/pairing depois. Distinto dePOST /instance/disconnect(soft disconnect, mantém sessão, retomável viaPOST /instance/reconnect) e deDELETE /instance/delete/{instanceId}(admin,GLOBAL_API_KEY, apaga a instância permanentemente — irreversível). Somentelogoutestá no escopo F1.
Mapeamento de estado (GET /instance/status → InstanceState canônico)
Connected | LoggedIn | Significado do provider | InstanceState canônico |
|---|---|---|---|
false | false | nunca conectada / deslogada | disconnected |
true | false | socket aberto, aguardando escanear QR/pairing | qr |
true | true | conectada e autenticada (sessão ativa) | connected |
false | true | credenciais existem, socket caiu temporariamente (precisa de /instance/reconnect) | connecting (suposição — ver "Limites e particularidades") |
| qualquer valor não-booleano/ausente | — | formato inesperado | unknown (nunca lança) |
Operações core
| Operação canônica | Endpoint | Observações |
|---|---|---|
instance.connect | POST /instance/connect + GET /instance/qr | O 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.status | GET /instance/status | Ver tabela de mapeamento acima. |
instance.logout | DELETE /instance/logout | Corpo vazio; resposta {"message":"success"}. |
messages.sendText | POST /send/text | Campo 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.sendMedia | POST /send/media | Duas 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.sendReaction | POST /message/react | Ver seção "Reações" abaixo. |
messages.edit | POST /message/edit | Ver seção "Edição e exclusão de mensagem" abaixo. |
messages.delete | POST /message/delete | Ver seção "Edição e exclusão de mensagem" abaixo. |
messages.markRead | POST /message/markread | ADR-0013, nível de MENSAGEM. Ver seção "Edição e exclusão de mensagem" abaixo. |
messages.sendLocation | POST /send/location | ADR-0014. Ver seção "Conteúdo estruturado" abaixo. |
messages.sendContactCard | POST /send/contact | ADR-0014. Ver seção "Conteúdo estruturado" abaixo. |
messages.sendPoll | POST /send/poll | ADR-0014. Ver seção "Conteúdo estruturado" abaixo. |
groups.create | POST /group/create | Ver seção "Grupos (núcleo)" abaixo. |
groups.getInfo | POST /group/info | Ver seção "Grupos (núcleo)" abaixo. |
groups.list | GET /group/list | Ver seção "Grupos (núcleo)" abaixo. |
groups.addParticipants / groups.removeParticipants / groups.promoteParticipants / groups.demoteParticipants | POST /group/participant | Mesmo endpoint para as 4 operações; só o campo action muda. Ver seção "Grupos (núcleo)" abaixo. |
groups.updateSubject | POST /group/name | Ver seção "Grupos (núcleo)" abaixo. |
groups.updateDescription | POST /group/description | Ver seção "Grupos (núcleo)" abaixo. |
groups.updatePicture | POST /group/photo | Ver seção "Grupos (núcleo)" abaixo. |
contacts.list | GET /user/contacts | Ver seção "Contatos" abaixo. |
contacts.get | POST /user/info | Ver seção "Contatos" abaixo. |
contacts.checkExists | POST /user/check | Ver seção "Contatos" abaixo. |
contacts.getProfilePicture | POST /user/avatar | Ver seção "Contatos" abaixo. |
contacts.getAbout | POST /user/info | Mesmo endpoint de contacts.get — ver seção "Contatos" abaixo. |
contacts.block | POST /user/block | Ver seção "Contatos" abaixo. |
contacts.unblock | POST /user/unblock | Ver seção "Contatos" abaixo. |
contacts.listBlocked | GET /user/blocklist | Ver seção "Contatos" abaixo. |
chats.archive | POST /chat/archive | Ver seção "Conversas (chats.*)" abaixo. |
chats.mute | POST /chat/mute | Ver seção "Conversas (chats.*)" abaixo. |
chats.pin / chats.unpin | POST /chat/pin / POST /chat/unpin | Ver 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ãopkg/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 camponumberde/send/text//send/media(dígitos crus ou JID completo) — o adapter repassa viatoProviderNumber, sem transformação.id: o ID da mensagem-alvo da reação. Atenção de nomenclatura: este campo se chamaid, nãomessageId(diferente deEditMessage/DeleteMessageEveryone, no mesmo arquivo, que usammessageId).reaction: o emoji (ex.:"👍"). O servidor rejeitareaction: ""com400("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 usaemoji: ''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 nemparticipant(autor, necessário em grupos quandofromMe=false) — mesma limitação já documentada paraquotedIdemsendText/sendMedia. Este adapter sempre enviafromMe: 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 portoSentMessage. Erros:400(número/reaction ausentes) ou500(sem sessão ativa, número inválido,idausente, 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 (viaparticipant) — 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.txt → api-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.edit — POST /message/edit
- Fonte:
evo-go-message.yaml, schemaEditMessage. Corpo:{chat: string, message: string, messageId: string}. Atenção de nomenclatura: o novo texto vai no campomessage, nãotext(diferente de/send/text) nemcaption— o adapter traduzEditMessageInput.text→message. - Resposta:
{"message":"success","data":{"messageId":"3EB0000000000000000002","timestamp": "0001-01-01 00:00:00 +0000 UTC"}}— um envelope próprio (data.messageId/data.timestampminú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 paramessages.edit, sem reaproveitartoSentMessage. O próprio exemplo do provider usa um timestamp zero-value (0001-01-01...), entãoSentMessage.timestamppode ficarundefinedna 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/500em runtime, nunca validado localmente por este adapter. - Erros documentados:
400(payload inválido),404(mensagem não encontrada),500.
messages.delete — POST /message/delete
- Fonte:
evo-go-message.yaml, schemaMessage: {chat: string, messageId: string}(nome de schema próprio — não confundir com o objeto de evento de webhookMessage, nem com o schemaReactStructusado porsendReaction). 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 (
DeleteMessageInputnã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 campoid/messageId). - Resposta (exemplo real do OpenAPI):
{"message":"success","data":{"messageId":"...", "timestamp":"0001-01-01 00:00:00 +0000 UTC"}}— mesmo formato demessages.edit(datapresente). A operação canônica retornaPromise<void>, então o adapter ignora o corpo da resposta deliberadamente; o shape exato dedataé irrelevante ao comportamento. - Mesma ausência de janela de tempo documentada que em
messages.edit.
messages.markRead — POST /message/markread (ADR-0013, nível de MENSAGEM)
- Confirmado via
evo-go-message.yaml, schemaMarkRead: {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" emevo-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.sendLocation — POST /send/location
- Schema
SendLocation:{address, delay, id, latitude, longitude, mentionAll, mentionedJid, name, number, quoted}.SendLocationInput.name/.addressmapeiam 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.sendContactCard — POST /send/contact
- Schema
SendContact+VCard:{number, vcard: {fullName, organization, phone}, ...}. O provider não recebe um vCard bruto — só 3 campos soltos e monta oBEGIN:VCARD...END:VCARDcompleto no servidor (confirmado no exemplo de resposta:FN=fullName,ORG=organization;,TEL;type=CELL;type=VOICE;waid=phone:phone).SendContactCardInputnã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.sendPoll — POST /send/poll
- Schema
SendPoll:{number, question, options, maxAnswer, ...}.maxAnswercontrola quantas opções o respondente pode marcar (0= escolha única, no whatsmeowselectableOptionsCount);SendPollInput.allowMultipleAnswersmapeia paramaxAnswer: options.length(qualquer número de opções) quandotrue,0quandofalse/ausente. Resposta confirmaType: 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.setTyping — POST /message/presence
- Schema
ChatPresence, confiança Alta. Body:{isAudio, number, state}— vive fisicamente emevo-go-message.yaml, não emevo-go-chat.yaml(onde estão archive/mute/pin/unpin) — mesma inconsistência de organização do provider já notada parasendLocation/etc. stateprovavelmente aceita os valores whatsmeow padrão (composing/recording/paused), mas os valores exatos não são enumerados no spec — mapeado 1:1 comTypingStatepor analogia.isAudio(assunção não confirmada por exemplo literal) é enviado comotruesó quandostate === '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ônica | Endpoint | Observações |
|---|---|---|
labels.list | GET /label/list | Achado 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/color — label_color já vem como STRING no banco. |
labels.create | POST /label/edit | Schema 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.update | POST /label/edit | Mesmo 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.delete | POST /label/edit | Mesmo 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.addToChat | POST /label/chat | Schema ChatLabel: {jid, labelId}. |
labels.removeFromChat | POST /unlabel/chat | Mesmo schema ChatLabel de addToChat. |
color é opaco no contrato canônico (ADR-0016), mas o Evolution GO exige um integer no corpo de /label/edit — toLabelColor 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.creategera umlabelIdviarandomUUID()— 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.deletefaz umGET /label/listantes doPOST /label/edit(2 chamadas HTTP) — não é uma emulação opcional (diferente da recusa deaddToChat/removeFromChatda WAHA), é uma NECESSIDADE do único endpoint disponível, que exigenameem toda chamada e não faz merge parcial.- Não confirmado nesta pesquisa: se
GET /label/listfiltra registros comdeleted: truedo lado do servidor, ou se um label apagado continua aparecendo na listagem (o repositório que persistelabel_model.Labelnã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).
| Capability | Endpoint | Observações |
|---|---|---|
messages.download | POST /message/downloadimage | Apesar 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ônica | Endpoint | Observações |
|---|---|---|
channels.list | GET /newsletter/list | Resposta {message, data: NewsletterMetadata[]}. |
channels.create | POST /newsletter/create | Schema CreateNewsletterStruct {name, description}. Resposta rica {message, data: NewsletterMetadata} — ver mapeamento abaixo. |
channels.getInfo | POST /newsletter/info | Schema GetNewsletterStruct {jid}. |
channels.follow | POST /newsletter/subscribe | Mesmo 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/ TextUnmarshaler — encoding/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:
{
"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ônica | Endpoint | Observações |
|---|---|---|
calls.reject | POST /call/reject | Body 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.create — POST /group/create
- Corpo:
{groupName: string, participants: string[]}. Atenção de nomenclatura: o campo égroupName, nãoname— diverge do padrão do resto do provider (que costuma usarname/numberpara os campos principais).participantsaceita dígitos crus ou JID completo (mesmo formato do camponumberde/send/text), e já chega normalizado pelo conector. - Resposta:
{"message":"success","data":{"jid":"...","name":"...","owner":"...","added": ["..."],"failed":["..."]}}— um envelope dedatadiferente do whatsmeowGroupInfousado porgetGroupInfo/listGroups(chaves minúsculas aqui:jid/name/owner; capitalizadas lá:JID/Name/OwnerJID). Não há lista detalhada de participantes (comisAdmin/isSuperAdmin) na resposta de criação — o adapter constróiGroupInfo.participantsa partir do arrayadded(todos entram comoisAdmin:false, já que acabaram de ser adicionados), com fallback para a lista de entrada (input.participants) casoaddedvenha vazio (mesmo padrão de fallback já usado emtoSentMessage, ex.:chatId ?? requestedNumber).
groups.getInfo — POST /group/info
- POST mesmo sendo uma operação de leitura — mesmo padrão do provider para as demais rotas de grupo (nenhuma delas usa
GETexcetolist). - Corpo:
{groupJid: string}.groupJidaqui é ogroupIdopaco do waconector (ver ADR-0009): neste provider,GroupInfo.idjá É 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 structGroupInfodo whatsmeow, serializado verbatim (sem json tags, chaves capitalizadas — mesmo padrão deGET /instance/status):{JID, OwnerJID, Name, Topic (= descrição), IsLocked, GroupCreated, Participants: [{JID, PhoneNumber, LID, IsAdmin, IsSuperAdmin, DisplayName, Error, AddRequest}], ...}. O adapter mapeiaJID→id,Name→subject,Topic→description,OwnerJID→owner, e cada item deParticipantsparaGroupParticipant(JID→id, com fallback defensivo emPhoneNumber;IsAdmin/IsSuperAdmindireto).
groups.list — GET /group/list
- Sem corpo. Resposta:
{"message":"success","data": GroupInfo[]}— um array do mesmo shape whatsmeow degetGroupInfo, 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.demoteParticipants — POST /group/participant
- Endpoint único compartilhado pelas 4 operações: corpo
{groupJid: string, participants: string[], action: "add"|"remove"|"promote"|"demote"}— só o campoactionmuda entre elas. O adapter implementa as 4 como wrappers finos sobre uma função interna (updateGroupParticipants) parametrizada poraction. participants: dígitos crus ou JID completo, mesmo formato denumber/groupId— já chegam normalizados pelo conector (comportam-se como umtode mensagem comum, diferente degroupJid, 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 retornamPromise<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 oHttpClientlançar em caso de erro HTTP.
groups.updateSubject — POST /group/name
- Corpo:
{groupJid: string, name: string}(SetGroupNameStruct). Atenção de nomenclatura: o campo éname, nãosubject— o adapter traduzUpdateGroupSubjectInput.subject→name.groupJidsegue a mesma conversão opaca detoProviderGroupJidusada pelo resto degroups.*. - Resposta:
{"message":"success"}, semdata. A operação canônica retornaPromise<void>, então o adapter só dispara a chamada.
groups.updateDescription — POST /group/description
- Corpo:
{groupJid: string, description: string}.descriptionpode 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 — verWaConnector.prepareUpdateGroupDescription). - Resposta:
{"message":"success"}, semdata.Promise<void>, mesmo padrão deupdateSubject.
groups.updatePicture — POST /group/photo
- Corpo:
{groupJid: string, image: string}. Divergência importante de formato em relação amessages.sendMedia/POST /send/media(que aceita base64 cru, sem prefixo, no campourl— ver seçãomessages.sendMediaacima): o campoimagedeste endpoint só reconhece OU uma URLhttp(s)://...OU uma data-URI com prefixo exatodata:image/jpeg;base64,oudata:image/png;base64,. Base64 cru sem esse prefixo não é aceito aqui. O adapter (toGroupPictureImage) repassamedia.urldiretamente quando presente; caso contrário monta a data-URI a partir demedia.base64, escolhendo o prefixo pormedia.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 quandomimeTypeestá 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>"}— opictureIDretornado é ignorado (a operação canônicagroups.updatePictureretornaPromise<void>).
groups.getInviteLink / groups.revokeInviteLink — POST /group/invitelink
- Endpoint único compartilhado pelas duas operações (mesmo padrão de
updateGroupParticipants): corpo{groupJid: string, reset: boolean}—reset:falseobtém o link de convite atual,reset:truerevoga o link atual e gera um novo. O adapter implementa as duas como uma função interna (getGroupInviteLink) parametrizada porreset. - Resposta:
{"message":"success","data":"<link completo>"}— diferente de outras rotas de grupo cujodataé um objeto/struct, aquidatajá É 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 pornormalizeInviteLinkmesmo assim antes de devolverGroupInviteLink.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.joinViaInviteLink — POST /group/join
- Corpo:
{code: string}. Confiança alta (comportamento do whatsmeow, biblioteca usada internamente pelo Evolution GO): o campocodeaceita tanto o código bare quanto o link completo — o whatsmeow remove o prefixohttps://chat.whatsapp.com/automaticamente se presente antes de resolver o convite. O conector já normalizainput.invitepara o link completo antes de chamar o adapter (verWaConnector.prepareJoinViaInviteLink); o adapter repassa esse valor diretamente emcode, sem usarextractInviteCode. - Resposta:
{"message":"success"}— sem nenhuma informação sobre qual grupo foi de fato ingressado (nemjid, nemdatade qualquer tipo). A operação canônicagroups.joinViaInviteLinkretornaPromise<void>, então isso não é uma limitação prática deste adapter.
groups.leaveGroup — POST /group/leave
- Corpo:
{groupJid: string}— mesma conversão opaca detoProviderGroupJidusada pelo resto degroups.*. Resposta:{"message":"success"}, semdata.Promise<void>, mesmo padrão deupdateSubject/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.list — GET /user/contacts
- Sem corpo. Resposta:
{"message":"success","data": ContactInfo[]}, ondeContactInfo = {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;nameescolhido pela ordem de preferênciaFullName>FirstName>PushName(o primeiro campo populado, nessa ordem, vence). - Sem
about/profilePictureUrl/hasWhatsApp: este endpoint não devolve nenhum dos três. Em particular,hasWhatsAppficaundefinedem vez de assumirtrue— 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 decontacts.checkExists). - Cada item do array carrega seu próprio
raw(o registro individual, não o envelope inteiro) — mesmo padrão degroups.list.
contacts.get — POST /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 "
getContactnã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 camponumber(string única) de/send/texte de/user/avatar. - Resposta:
{"message":"success","data":{"Users": {"<jid>": {"VerifiedName", "Status", "PictureID", "Devices", "LID"}}}}—Usersé um MAP indexado por JID aqui (diferente decontacts.checkExists, ondeUsersé 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 (viaformatJid), usado comoContact.id(com fallback defensivo para ochatIdde entrada caso o mapa venha vazio). - Limitações documentadas (não são bugs — a resposta do provider genuinamente não traz esses dados):
namefica sempreundefined: a resposta não inclui nenhum nome de exibição comum (nemPushNamenemFullName) — quem precisar do nome deve chamarcontacts.list()em vez decontacts.get().profilePictureUrlfica sempreundefined:PictureIDé só um id/hash interno da foto atual do contato, não uma URL utilizável — popularprofilePictureUrla 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 chamarcontacts.getProfilePictureseparadamente.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 dename.abouté populado a partir deStatus.
contacts.getAbout — POST /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.checkExists — POST /user/check
- Corpo:
{number: [phone], formatJid: true}—numbertambém é array aqui. - Resposta:
{"message":"success","data":{"Users": [{"Query", "IsInWhatsapp", "JID", "RemoteJID", "LID", "VerifiedName"}]}}—Usersé um ARRAY aqui (diferente decontacts.get, ondeUsersé um mapa por JID) — atenção a essa inversão de shape entre os dois endpoints do mesmo pacotepkg/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 encadearmessages.sendText/contacts.getsem precisar renormalizar o telefone).
contacts.getProfilePicture — POST /user/avatar
- Corpo:
{number: chatId, preview: false}— diferente decontacts.get/contacts.checkExists, aquinumberé 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 (
ErrProfilePictureNotSetno 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 (viraPROVIDER_ERRORviaHttpClient).
contacts.block / contacts.unblock — POST /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 decontacts.getProfilePicture, diferente do array usado porcontacts.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 retornamPromise<void>, e popularlistBlockeda 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 chamarcontacts.listBlocked()separadamente logo em seguida.
contacts.listBlocked — GET /user/blocklist
- Sem corpo. Resposta:
{"message":"success","data":{"DHash":"...","JIDs":["..."]}}— mesmo shape dedatadecontacts.block/contacts.unblock(não por coincidência: os 3 endpoints vivem no mesmopkg/user/*). - Mapeamento:
data.JIDs→ array de strings retornado diretamente, sem remapeamento por item — já vem no formato JID canônico, o mesmo formato usado comoContact.idno 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 paracontacts.getAboutna 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.archive — POST /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/unarchiveno spec oficial — só a ação de arquivar está documentada. Este adapter, portanto, não declarachats.unarchive(não é possível confirmar se chamar/chat/archivede 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.mute — POST /chat/mute
- Confiança Alta. Corpo
{number}— sem nenhum campo de duração (nemduration, nemuntil, nemhours). 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ônicoChatsApi.mutetambé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/unmuteno spec oficial — mesma ausência dearchive/unarchive. Este adapter não declarachats.unmutepelo mesmo motivo.
chats.pin / chats.unpin — POST /chat/pin / POST /chat/unpin
- Confiança Alta para os dois. Corpo
{number}em ambos. Diferente dearchive/mute, este é o único par dechats.*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.json— copiado literalmente do site de docs (docs.evolutionfoundation.com.br), cruzado compkg/whatsmeow/service/whatsmeow.go.webhook-ack.json— copiado literalmente do site de docs, cruzado com o mesmo arquivo-fonte (eventoReceipt,state∈Read/Delivered/ReadSelf).webhook-connection-update.json— copiado literalmente do site de docs (eventoConnected,data.status="open").webhook-message-image.json/webhook-message-document.json— RECONSTRUÍ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.go—ImageMessage/DocumentMessage), com especial atenção ao campo de URL: os structs tagueiam esse campo comojson:"URL,omitempty"(maiúsculo), diferente demimetype/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 liarecord.url(minúsculo) e por isso nunca populavaWaMessage.mediapara nenhuma mensagem de mídia recebida.buildMediaRefagora lêrecord.URL(com fallback defensivo pararecord.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:
{
"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): Disconnected → disconnected, LoggedOut → disconnected, ConnectFailure → disconnected, PairSuccess → connected, TemporaryBan → disconnected (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 payload | Condição | GroupUpdateEvent emitido |
|---|---|---|
Join (JID[]) | array não vazio | {groupId: data.JID, action: 'participants.add', participants: data.Join} |
Leave (JID[]) | array não vazio | action: 'participants.remove', participants: data.Leave |
Promote (JID[]) | array não vazio | action: 'participants.promote', participants: data.Promote |
Demote (JID[]) | array não vazio | action: 'participants.demote', participants: data.Demote |
Name (objeto) | truthy | action: 'subject', sem participants |
Topic (objeto) | truthy | action: '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.json—GroupInfocom sóJoinpopulado (2 participantes).webhook-group-multiple-changes.json—GroupInfocomJoinePromotesimultâneos no mesmo payload, cobrindo o caso de múltiplosGroupUpdateEventa partir de um único webhook.webhook-group-subject-update.json—GroupInfocomNamepopulado (action: 'subject').webhook-group-joined.json—JoinedGroup.
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/logincomGLOBAL_API_KEY) — relevante para bootstrap/health check, não tratado por este adapter (apenas surge comoPROVIDER_ERRORcomum). - 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/buttone/send/listsã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 realTextStruct). Diferente do camponumber(normalizado server-side viautils.CreateJIDindependente do formato),pkg/sendMessage/service/send_service.gocopiadata.MentionedJIDverbatim paraContextInfo.MentionedJIDno protobuf de saída — sem nenhuma normalização/CreateJIDno caminho de menções. Por isso o adapter não reusa a função de mapeamento deto:SendTextInput.mentionspassa portoMentionJid, que anexa@s.whatsapp.neta 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).quotedno envio de texto/mídia aceita{messageId, participant}; o modelo canônico (quotedId) não carregaparticipant, então o adapter só enviamessageId. Se o provider exigirparticipantpara citar corretamente uma mensagem de grupo, isso é uma limitação conhecida deste adapter em F1.messages.sendReaction(POST /message/react) sempre enviafromMe: falsee nunca enviaparticipant— 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 ondeparticipantseria necessário, é uma limitação conhecida deste adapter. Ver seção "Reações" acima.- Suposição (estado
Connected=false, LoggedIn=true): mapeado paraconnectingem vez dedisconnected, 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 (
PairSuccess→connected,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.gomostra que a variante JSON dePOST /send/mediadecodificadata.Urlcomo base64 quando o valor não começa comhttp:///https://. O adapter enviamedia.base64nesse mesmo campourlquandomedia.urlestá ausente. Só lançaWaConnectorError('INVALID_INPUT')quando nemurlnembase64são informados (multipart/form-datacom campo bináriofilecontinua fora do escopo, pois oHttpClientcompartilhado 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.pairingCodenão implementado nesta fase apesar de confirmado no provider — fora do escopo declarado para F1 deste adapter.chats.unarchive/chats.unmute/chats.markRead/chats.markUnreadnão implementados: sem endpoint confirmado no OpenAPI oficial (evo-go-chat.yamlsó documentaarchive/mute/pin/unpin, não os inversos dearchive/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.deletenã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/deletee o núcleo dechats.*):messages.markRead(nível de mensagem, batch de IDs),messages.getStatus,chats.setPresence,messages.sendLocation/sendContact/sendPoll/sendLink/sendSticker, os namespaceslabels.*/newsletters.*/community.*inteiros,contacts.getPrivacySettings,instance.setProfilePictureeinstance.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 comomessages.downloadna Epic 12 — ver seção "Download de mídia" acima.)