Compatibilidade

Diferenças de contrato e comportamento em relação ao consumidor legado.

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)

RotaResposta entregue
POST /instance/init{response, connected:false, loggedIn:false, instance:{id, token, name, systemName, status, ...}}; o token só aparece aqui
GET /instance/allArray 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/statusMesma forma do connect, sem efeitos colaterais; status em connected|connecting|disconnected
POST /instance/disconnectLogout: {response, unlinked, instance}; a credencial é apagada mesmo sem confirmação remota
POST /instance/restartReabre 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-KeyMesma 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:<u>"/"label|https://…" e "label|copy:<código>" (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/locationMarca 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/errorsRegistro 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
GET /v1/messages, GET /v1/events?status=, POST /v1/events/{id}/replayRotas 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: <código>, 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

PrioridadeMétodo e caminhoContrato esperado
P1POST /message/presencePresença coerente com sessão (só uso interno via delay hoje)
P2/gateimportação de sessão pela extensãoSem 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); 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_modecustom/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. 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

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.