# Compatibilidade Uazapi — contrato alvo e estado de implementação Auditado no código de `atendro-ai` em 15/09/2026 e revisto em 16/09/2026 para o ciclo de vida. A nomenclatura de prioridade define a ordem de desenvolvimento; contas que usam uma função só podem migrar quando ela estiver homologada. ## Implementado (ciclo de vida, mensagens, mídia, ações, menus e webhooks; homologado com número de laboratório em 16–17/09/2026) | Rota | Resposta entregue | |---|---| | `POST /instance/init` | `{response, connected:false, loggedIn:false, instance:{id, token, name, systemName, status, ...}}`; o `token` só aparece aqui | | `GET /instance/all` | Array de instâncias com `id, name, systemName, status, owner, profileName, lastDisconnectReason, worker`; sem `token` e sem `qrcode` | | `POST /instance/connect` | `{connected, loggedIn, jid, instance:{status, qrcode (data URI PNG), paircode, owner, profileName, lastDisconnectReason}}`. Espera até 5 s por QR ou login antes de responder; o consumidor pode continuar em `GET /instance/status` | | `GET /instance/status` | Mesma forma do connect, sem efeitos colaterais; `status` em `connected|connecting|disconnected` | | `POST /instance/disconnect` | Logout: `{response, unlinked, instance}`; a credencial é apagada mesmo sem confirmação remota | | `POST /instance/restart` | Reabre preservando credencial: `{response, connected, loggedIn, jid, instance}` | | `DELETE /instance` | `{response:"Instance deleted"}`; síncrono, sem estado `deleting` | | `POST /send/text` | `{number, text, replyid?, delay?, linkPreview?}` → `{success:true, messageid, id, timestamp}` com o ID real do WhatsApp. `number` aceita dígitos ou chat (`@s.whatsapp.net`, `@g.us`, `@lid`). `replyid` cita a mensagem guardada (`422 reply_not_found`); `delay` em ms (string ou número, teto 15 s). Erros: sem sessão logada `409 whatsapp_disconnected` ("WhatsApp disconnected … not connected", reconhecido pelo classificador); número sem conta `422` ("not on WhatsApp"); recusa do servidor `422 send_failed` com `provider_code`; bloqueio de prospecção (código 463) `500` com `error_key: WHATSAPP_REACHOUT_TIMELOCK`, `provider_code: 463` e `details.reachout_timelock.until` — a forma que o consumidor procura para a mensagem amigável; fila ocupada `429 too_many_requests`; falha transitória antes do envio (nada enviado) `429 retry_later`; frame enviado sem ack ou socket perdido após o envio `504 send_ambiguous` com `messageid`. 504 e o 500 do 463 são os únicos 5xx; 4xx são falhas definitivas ou retentáveis (429) | | `POST /instance/init` e `POST /send/text` com `Idempotency-Key` | Mesma chave e corpo → mesma resposta com `Idempotency-Replayed: true` (24 h para envios; 15 min para `init`, cuja resposta contém o token); corpo diferente → `422 idempotency_mismatch`; em andamento → `409 idempotency_in_progress`; tentativa abandonada há mais de 2 min é retomada. Respostas transitórias (409, 429, 5xx exceto 504) não são guardadas: a chave fica livre para a repetição | | `GET/POST/DELETE /v1/instances/{id}[/connect|disconnect|restart]` | Rotas administrativas por id (`admintoken`), para o painel agir sem o token da instância que `GET /instance/all` não devolve | | `GET /message/status/{id}` | `{id, status, failure, fromMe, timestamp, deliveredAt, readAt, playedAt}`; `404 message_not_found` | | `POST /send/media` | `{number, type, file, text?, docName?, replyid?, delay?}` → `{success, messageid, id, timestamp, mimetype, ptt}`. `file`: data URI (o que o consumidor envia até 5 MiB), base64 ou URL de host da allowlist — **o consumidor cai para URL acima de 5 MiB, então `ATENDROZAP_MEDIA_URL_ALLOWLIST` precisa conter o host do storage do Atendro** (https, sem redirects, sem redes privadas, até 32 MiB). O mime declarado é conciliado com os bytes (o consumidor embrulha base64 cru como `image/jpeg`); `docName` é reduzido ao nome-base. `ptt` só vira nota de voz com `audio/ogg`; outros áudios saem como arquivo, com `note`. Erros de política de URL `400`; arquivo grande `413`; fetch transitório `429 url_fetch_failed`; upload falho antes do envio `429 retry_later`; tipo × mime incompatíveis `422 invalid_media` | | `POST /send/contact`, `POST /send/location` | `{number, fullName, phoneNumber, organization?, replyid?, delay?}` e `{number, latitude, longitude, name?, address?, replyid?, delay?}` → `{success, messageid, id, timestamp}` | | `POST /message/download` | `{id}` (aceita `owner:id`) → `{fileURL, url, mimetype, mimeType, fileName, size, expiresAt, messageid}`; `mimetype` sem parâmetros (`audio/ogg`, não `audio/ogg; codecs=opus`), inferido dos bytes quando ausente. O link é um GET público assinado válido pela retenção (48 h), com `Content-Length`; só tipos passivos (imagem, áudio, vídeo, PDF) são servidos `inline`; markup/script viram `application/octet-stream` em `attachment`, com CSP `sandbox`. Mídia expirada no servidor → `409 media_reupload_pending` (não 404/503, que o consumidor trata como irrecuperável) com pedido de reenvio ao celular (um a cada 5 min); celular sem o arquivo → `404 media_unavailable` (definitivo); acima de 64 MiB → `413 media_too_large`; mensagem desconhecida `404`; sem mídia `422 not_media`; falha `502 media_failed`. Até 4 downloads simultâneos por worker | | `POST /message/markread` | `{id: [..] ou "id"}` (até 1000) → `{success, marked, count}`; um recibo por (chat, remetente); ids desconhecidos ou próprios são ignorados | | `POST /chat/read` | `{number, read:true}` → `{success, marked}`: recibo para as recebidas ainda não lidas do chat (até 100) | | `POST /chat/check` | `{numbers:[..]}` ou `{number}` → array `[{query, exists, numberExists, jid, data:{exists, jid}}]` (todas as formas que o consumidor lê) | | `POST /chat/details` | `{number}` → `{jid, exists, name, pushName, displayName, verifiedName, isBusiness, profilePicUrl, profilePictureUrl, imagePreview, pictureId, imageState}`; foto com cache de 24 h e revalidação por id; número sem conta `404` | | `POST /send/menu` | `{number, type: button\|list\|poll, text, choices[], footerText?, listButton?, selectableCount?, replyid?, delay?, renderMode?}` → `{success, messageid, messageId, id, timestamp, type, renderMode, rendered}`. `choices` na gramática legada: `"[Seção]"` abre seção (listas), `"label\|id\|descrição"`, `"label\nid"` ou `"label"` (id = label), `"label\|url:"`/`"label\|https://…"` e `"label\|copy:"` (só em botões); `call:` responde `400`. Limites da plataforma: 3 botões (4 a 10 botões só de resposta — como o menu de avaliação do consumidor — saem como lista, `rendered: list`), 10 linhas, 2–12 opções de enquete; labels cortados no limite do que é renderizado (20 botão, 24 linha, 100 enquete; inteiros no modo texto); ids de resposta até 256 bytes, URLs até 2048; `carousel` responde `400`. `renderMode`: `auto` (padrão: tenta a forma nativa — `InteractiveMessage` com native flow, aceita pelo servidor e renderizada no aparelho de laboratório em 17/09/2026; os protos legados `ButtonsMessage`/`ListMessage` são recusados com 405 — uma recusa é lembrada por 6 h na sessão e o menu cai para enquete, ou para texto quando há opção `url:`/`copy:`), `native` (só a forma nativa; a recusa vira `422 send_failed` com `provider_code: 405`), `poll` (as opções viram enquete de escolha única e o voto volta com o `id` original; labels duplicados são recusados) ou `text` (lista numerada em texto; a resposta chega como texto e o consumidor casa por label). `rendered` diz o que saiu (`button`, `list`, `poll`, `text`). `selectableCount` só vale para `type: poll` (padrão 1). As opções ficam guardadas na mensagem (`choices`) em todos os modos | | `POST /message/react` | `{number?, text, id}` → `{success, messageid, messageId, id, timestamp, reaction:{id, emoji, status: sent\|removed}}`; `text` vazio remove; `number` é ignorado (o chat é o da mensagem guardada — o consumidor deriva `number` do contato, errado em grupos); `id` aceita `owner:id`. Reação própria fica em `reactions` da mensagem. Mensagem desconhecida `404`; apagada `422 message_revoked`; emoji inválido `400` | | `POST /message/delete` | `{id}` → `{success, id, messageid, status: "Deleted", timestamp (RFC 3339), recorded}`; repetido → `200` com `alreadyDeleted`. Só mensagens próprias (`422 cannot_delete` para as recebidas: apagar a de um participante exige ser admin do grupo, que o motor ainda não verifica). Apaga corpo, conteúdo bruto, opções, reações e mídia retida; gera evento `messages_update`. `recorded:false` quando o WhatsApp aceitou mas o store não gravou | | `POST`/`PUT /message/edit` | `{id\|messageId, text}` (o consumidor vivo manda `POST {id, text}`; o hook V2 `PUT {number, messageId, text}`) → `{success, id: owner:id, messageid, messageId, editId, content, messageType, messageTimestamp (ms), timestamp, status, owner, editCount, recorded}`. Só mensagens próprias de texto ou legenda (imagem, vídeo, documento com conteúdo guardado) até 20 min (`422 edit_window_expired`, `not_own_message`, `not_editable`, `message_revoked`). O texto editado vai sem a prévia de link antiga | | `POST /message/forward` | `{number, id\|messageId, delay?}` → resposta de envio. Rota própria (a UAZAPI não tem forward por id; o consumidor reenvia). Texto, contato e localização (inclusive localização ao vivo, como pino) são copiados do conteúdo guardado; mídia é baixada (store ou WhatsApp) e reenviada — `409 media_reupload_pending`, `404 media_unavailable`, `413`, falha transitória `429 retry_later`; visualização única, contato/localização sem conteúdo guardado e outros tipos `422 not_forwardable`; apagada `422 message_revoked`. Nada é gravado antes de o original ser aceito. A cópia carrega a marca "Encaminhada" e `forwardedFrom` | | `forward: true` em `/send/text`, `/send/media`, `/send/contact`, `/send/location` | Marca a mensagem como encaminhada (como na spec legada) | | `POST /message/find` | `{chatid?, id?, limit? (padrão 100, máx. 200), offset?}` → `{messages[], returnedMessages, limit, offset, nextOffset?, hasMore}`, mais recentes primeiro. Cada item no formato legado `Message`: `id` (`owner:messageid`), `messageid`, `chatid`, `sender`, `sender_pn`, `senderName`/`pushName`, `isGroup`, `fromMe`, `wasSentByApi`, `messageType` (`Conversation`, `ExtendedTextMessage`, `ImageMessage`, …, `ButtonsResponseMessage`), `type`, `messageTimestamp` (ms), `timestamp` (s), `status` (`Pending\|Sent\|Delivered\|Read\|Played\|Failed\|Received\|Deleted`), `text` (o label escolhido, em respostas interativas), `quoted`, `edited`, `content{key, text\|caption, selectedButtonId…}`, `buttonOrListid`, `reactions`, `choices`, `fileURL`/`mediaUrl` (só quando a mídia está retida; consulta só de metadados). Só mensagens desde o pareamento (histórico é etapa 5b) | | `GET /message/status/{id}` | Inclui `revokedAt`, `editedAt`, `editCount` e `reactions` | | `POST /webhook`, `GET /webhook`, `GET /webhook/errors` | Registro com o corpo exato que o consumidor envia (`enabled, url, events, excludeMessages, addUrlEvents:false, addUrlTypesMessages:false, action:add`); `ignoredEvents` para nomes aceitos e não emitidos; `deliveryStatus`. Entrega assinada, em ordem por instância, com retry, dead-letter, replay e circuit breaker — ver [EVENTS.md](https://wpp.atendro.cloud/docs/guias/eventos.md) | | `GET /v1/messages`, `GET /v1/events?status=`, `POST /v1/events/{id}/replay` | Rotas próprias de inspeção e replay (não fazem parte do contrato legado) | Falhas de banco respondem `502 storage_unavailable` com `Retry-After` (não 503, que a fila de mídia do consumidor trata como irrecuperável na primeira tentativa). Diferenças deliberadas em relação ao provedor legado: tokens não são listados (o painel usa as rotas por id); `companyId` opcional no `init` filtra `GET /instance/all?companyId=`; um registro `connected` sem lease viva é reportado como `disconnected` (`worker lost`); `disconnect` sempre limpa a credencial (o legado mantinha `owner` preso e exigia recriar a instância); `409 session_owned_elsewhere` sinaliza sessão em outro worker; `502 whatsapp_unreachable` sinaliza falha de conexão ao iniciar o pareamento; `408` não é usado. `lastDisconnectReason` usa `QR Code timeout`, `logged out: `, `connection replaced by another session`, `connection closed`, `disconnected by API (user requested)`, `worker shutdown`, `worker lost`, `restart` e `lease lost`, com o `disconnectCode` correspondente; os textos casam com os regex que o consumidor usa para classificar quedas (`logged.?out`, `connection.?replaced`, `disconnected by API`). ## Superfície restante | Prioridade | Método e caminho | Contrato esperado | |---|---|---| | P1 | `POST /message/presence` | Presença coerente com sessão (só uso interno via `delay` hoje) | | P2/gate | importação de sessão pela extensão | Sem suporte previsto | | feito (5d) | `POST /call/make` e demais rotas `/call/*` | Em instância `kind: calls`: chamada real com áudio pela ponte WebSocket ([guia de ligações](https://wpp.atendro.cloud/docs/guias/chamadas-de-voz)); em instância `whatsapp`, `501 calls_not_supported` como antes | Grupos, chats e histórico (etapa 5b) estão implementados no contrato que o consumidor usa, com as diferenças deliberadas abaixo: - `GET /group/list?noparticipants=true` → `{groups: Group[]}` com os campos PascalCase (`JID`, `Name`, `Topic`, `OwnerJID`, `ParticipantCount`, `Participants[].JID/PhoneNumber/LID/IsAdmin/IsSuperAdmin/DisplayName/Error`, `invite_link`); a lista vem do que o serviço sincronizou (no `connected`, por notificações e por history sync); `force=true` ressincroniza. - `POST /group/info {groupjid, getInviteLink, force}` → `Group`; sem `force` serve o cache por 10 min; `getInviteLink` só devolve o link se a conta é admin (senão fica vazio, sem erro). `getRequestsParticipants` é ignorado. - `POST /group/create {name, participants[]}` → `Group` direto (o consumidor lê `result.group || result`), com `Participants[].Error` para quem o servidor recusou; nome de 1–100 caracteres (o WhatsApp atual aceita 100; o `maxLength: 25` da spec é antigo). - `POST /group/updateParticipants` → `{groupUpdated: [{JID, Error}], needs_refresh}`; `approve`/`reject` respondem `400 invalid_action`. - `POST /group/updateName` e `/group/updateImage` respondem `200` com `needs_refresh`; a imagem (data URI, base64 ou URL da allowlist; JPEG, PNG ou GIF) é convertida para JPEG de até 640×640 aqui, então o consumidor pode mandar o que o usuário subiu; `"remove"` apaga. Erros: `400` (entrada), `403 not_admin`, `404 group_not_found`, `415 invalid_image`, `429` (limite do WhatsApp). - `POST /chat/find` ({} ou `{limit, offset, wa_isGroup}`) → `{chats: Chat[], hasMore, pagination}` com `wa_chatid` (forma telefone para conversas diretas), `name` (assunto do grupo / push name), `wa_name`, `wa_contactName` (só grupos: assunto), `wa_isGroup`, `wa_unreadCount`, `wa_lastMsgTimestamp` (s) e `wa_lastMessageTime` (s, o que o webhook `chats` do consumidor lê). Sem filtros de lead/etiqueta. - `/message/find` inclui o histórico importado do telefone por padrão (`includeHistory: false` exclui); o histórico chega pelo history sync do pareamento e nunca gera eventos. - `POST /admin/restart` (`admintoken`) reinicia as sessões **deste worker** (soft, `202 {message, worker}`); o processo não sai. A resposta agenda a operação em background e não inclui contagem de sessões reiniciadas. - `POST /call/make` em instância `whatsapp` responde `501 calls_not_supported`; a chamada real é da instância `calls` (etapa 5d). - Eventos `groups` são persistidos e entregues quando inscritos no webhook global ou próprio. `chats` continua sem emissão de webhook. `GET/POST /instance/proxy` está implementado no contrato que o consumidor usa (`{mode: custom, proxy_url}` antes do `connect`; `{mode: internal}` para soltar; resposta com `proxy.effective_mode` e `fallback.active`), com duas diferenças deliberadas: não há pool gerenciado (`internal` = conexão direta, `effective_mode` só `custom`/`none`, `fallback` nunca ativo) e um proxy que não alcança o WhatsApp é recusado com `400 proxy_unreachable` em vez de gravado com `validation_error`. `POST /v1/instances/{id}/token` (reemissão de token com prazo de convivência) não tem equivalente legado. `profilePicUrl` vem de `/chat/details`; `GET /instance/status` ainda não o inclui. Headers de instância usam `token`; o sender também envia `instancekey`. Compatibilidade não concede autorização com base no nome da instância. Associar token à empresa e à sessão corretas. O admin token não pode ser exposto ao cliente. O OpenAPI legado diverge do runtime em alguns pontos: há `/instance/create` no spec, mas o Atendro chama `/instance/init`. Não implementar só a especificação. `/sender/simple`, `/sender/advanced` e `/instance/updateDelaySettings` não foram encontrados como dependência runtime e não fazem parte de P0. ## Eventos e mensagens Entregues desde a 4a; o catálogo e a projeção estão em [EVENTS.md](https://wpp.atendro.cloud/docs/guias/eventos.md). Envelope observado: `{EventType,instanceName,token,chat,message}`; `token` é omitido (só o digest existe) e a autorização é a assinatura, que o consumidor ainda não verifica. Contratos alvo: - `messages`: entrada e mensagens do celular; `messageid/key.id`, `chatid`, `fromMe`, `participant`, sender, conteúdo e citação. Não fabricar telefone de LID. - `connection` e `qrcode`: estado/identidade/QR, motivo de desconexão distinguível. - `messages_update`: ACK por `MessageIDs/messageIds/ids` e `Sent/Delivered/Read/Played`, além de formatos de reação, edição e exclusão usados pelo parser. Desde a 3c, reações, revogações e edições recebidas atualizam a mensagem alvo (`reactions`, `revokedAt`, `editedAt`/`editCount`/corpo) e geram eventos internos `messages_update` (`type: reaction|revoke`, com `known:false` quando o alvo nunca foi guardado) e `messages_edit`; votos de enquete e respostas de botão/lista viram mensagens `button_reply` com `targetId` = menu e `choices` = seleção. A projeção para o envelope legado é da etapa 4a. - `history`: opt-in e separado do recebimento novo; sem disparo de IA/automação. O mesmo ID precisa casar entre REST, eco, ACK, reply, reação e exclusão. Tratar variantes `owner:id` sem perder identidade. Suprimir somente ecos de envios API; preservar mensagens manuais do celular. Recibos podem chegar repetidos ou antes da resposta REST e não podem regredir `read` para `sent`. A API legada deve devolver ID de mensagem, não sucesso com ID de fila. Se ocorrer timeout após entrega ao motor, manter resultado ambíguo e reconciliar; não reenviar automaticamente. O Atendro já possui fila/pacing: coordenar contratos entre sistemas. ## Evidências no consumidor - [Ciclo de vida](https://github.com/atendro-ai/atendro-ai/blob/main/supabase/functions/uazapi-instance-manager/index.ts) - [Envio](https://github.com/atendro-ai/atendro-ai/blob/main/supabase/functions/uazapi-send-message/index.ts) - [Parser de webhook](https://github.com/atendro-ai/atendro-ai/blob/main/supabase/functions/uazapi-webhook/index.ts) - [Ações](https://github.com/atendro-ai/atendro-ai/blob/main/supabase/functions/uazapi-message-actions/index.ts) - [Mídia](https://github.com/atendro-ai/atendro-ai/blob/main/supabase/functions/process-media-queue/index.ts) - [Grupos](https://github.com/atendro-ai/atendro-ai/blob/main/supabase/functions/uazapi-group-manager/index.ts) Fixar a revisão do consumidor quando escrever fixtures/testes de contrato. Fixtures devem ser sintéticas ou anonimizadas. Matching deve validar semântica de campos, IDs e efeitos, sem exigir ordem byte-a-byte irrelevante de propriedades JSON. ## Gates de integração O produto ainda restringe domínios a `uazapi.com` em guard de URL, migration e media-proxy. Serão necessárias allowlists explícitas, assinatura de webhook, elegibilidade de piloto e revisão do cutover antes da integração. `is_active=false` não devolve sessões ao backend anterior e `max_instances=0` significa ilimitado. Não cadastrar este bootstrap como servidor ativo no produto. O cutover precisa bloquear eventos tardios do backend antigo, invalidar cache de vínculo e reconciliar ACKs de envios anteriores. O parser atual também resolve instância por nome; trocar token/URL sozinho não isola os eventos.