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)
| 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:<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/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 |
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: <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
| 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); 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 (noconnected, por notificações e por history sync);force=trueressincroniza.POST /group/info {groupjid, getInviteLink, force}→Group; semforceserve o cache por 10 min;getInviteLinksó devolve o link se a conta é admin (senão fica vazio, sem erro).getRequestsParticipantsé ignorado.POST /group/create {name, participants[]}→Groupdireto (o consumidor lêresult.group || result), comParticipants[].Errorpara quem o servidor recusou; nome de 1–100 caracteres (o WhatsApp atual aceita 100; omaxLength: 25da spec é antigo).POST /group/updateParticipants→{groupUpdated: [{JID, Error}], needs_refresh};approve/rejectrespondem400 invalid_action.POST /group/updateNamee/group/updateImagerespondem200comneeds_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}comwa_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) ewa_lastMessageTime(s, o que o webhookchatsdo consumidor lê). Sem filtros de lead/etiqueta./message/findinclui o histórico importado do telefone por padrão (includeHistory: falseexclui); 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/makeem instânciawhatsappresponde501 calls_not_supported; a chamada real é da instânciacalls(etapa 5d).- Eventos
groupssão persistidos e entregues quando inscritos no webhook global ou próprio.chatscontinua 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. 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.connectioneqrcode: estado/identidade/QR, motivo de desconexão distinguível.messages_update: ACK porMessageIDs/messageIds/idseSent/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 internosmessages_update(type: reaction|revoke, comknown:falsequando o alvo nunca foi guardado) emessages_edit; votos de enquete e respostas de botão/lista viram mensagensbutton_replycomtargetId= menu echoices= 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.