# Lobato API API REST multi-instância para WhatsApp, construída sobre o Baileys v7. Uma "instância" = um número/conexão do WhatsApp (com seu próprio QR code, sessão, contatos, grupos, histórico de mensagens e webhooks). O MySQL é a fonte da verdade; o Redis é só um cache de leitura/escrita — se ele cair, a API continua funcionando, só mais devagar. Base URL: a mesma origem que serve esta documentação (ex. `https://`). Todos os endpoints da API ficam sob `/api/*`. O health check está em `/health` (sem prefixo `/api`, sem auth). Mídia recebida é servida estaticamente em `/media/*`. O painel administrativo (SPA em React) fica em `/` — não confunda com a API. Tudo abaixo reflete a implementação real, não é aspiracional. Se você é um LLM integrando com esta API, este arquivo mais os exemplos curl é tudo que você precisa — nenhuma outra página é necessária. ## Endpoints de documentação Estas cinco rotas servem a documentação em si. Nenhuma exige autenticação, e nenhuma fica sob `/api`. ### GET /docs Referência da API em HTML, para leitura humana (este mesmo conteúdo, renderizado como página). Sem auth. ### GET /llms.txt Este arquivo, ipsis litteris, como `text/plain`. Sem auth. ### GET /docs.md Atalho para `GET /llms.txt` — conteúdo idêntico, servido como `text/markdown` em vez de `text/plain` (para ferramentas que esperam um `.md` irmão de uma página HTML de docs). Sem auth. ### GET /docs/explorer Explorador de API interativo (Redoc), renderizado no navegador a partir do `openapi.yaml` abaixo. Sem auth. ### GET /openapi.yaml O spec OpenAPI 3 puro que descreve esta API — mesma fonte que o explorador acima lê. Importe direto no Postman, Insomnia, ou qualquer cliente compatível com `openapi-generator`. Sem auth. ## Modelo de autenticação Todo endpoint `/api/*` exige uma API key num header — **nunca** numa query string (pra não vazar em logs de acesso). Duas formas equivalentes de header: ``` Authorization: Bearer apikey: ``` Dois tipos de chave: - **Admin key** (`ADMIN_API_KEY`, configurada no servidor): exigida pro ciclo de vida de instâncias — criar instância, listar instâncias, excluir instância, gerar nova chave de uma instância, listar donos. A admin key também funciona em *qualquer* endpoint de instância específica (é aceita em todo lugar onde uma instance key é aceita). - **Instance key**: retornada na criação da instância (corpo da resposta de `POST /api/instances`, campo `apiKey`) e de novo em toda regeneração. É guardada no servidor como hash irreversível, então o corpo dessa resposta normalmente é o único lugar onde você vê o valor em texto puro — a menos que o servidor tenha `API_KEY_ENCRYPTION_SECRET` configurado, caso em que `GET /instances/{id}/api-key` (admin key exigida) também pode retorná-la depois (veja abaixo). Sem isso configurado, se a chave se perder, chame o endpoint de regenerar chave (admin key exigida) pra emitir uma nova e invalidar a antiga. Exigida em todo endpoint que opera uma instância específica (enviar mensagem, conectar, grupos, webhooks, etc). A admin key também funciona aqui. Um 401 (`UNAUTHORIZED`) é retornado pra chave ausente, malformada, ou errada, e também quando uma instance key é apresentada pro ID de outra instância (a chave é vinculada a uma única instância). ## Ciclo de vida da instância ### POST /api/instances Admin key exigida. Cria uma instância e retorna sua API key. Guarde-a imediatamente de qualquer forma — se dá pra buscá-la de novo depois via `GET /instances/{id}/api-key` depende da configuração do servidor (veja "Modelo de autenticação" acima), então não conte com isso. Corpo da requisição: ```json { "name": "vendas-01", "owner": { "name": "Cliente Acme", "email": "optional@example.com" }, "syncFullHistory": false } ``` `name` e `owner.name` obrigatórios (1–100 caracteres). `owner.email` opcional, validado como email se presente. Se já existir um dono com esse `name` exato, ele é reaproveitado; senão, é criado. `syncFullHistory` opcional (padrão `false`): quando `true`, o histórico completo de mensagens é pedido ao telefone no momento do pareamento por QR; as mensagens recuperadas são persistidas e ficam disponíveis pelo endpoint de histórico, e um resumo é encaminhado por bloco via o evento de webhook `messaging-history.set`. Pode ser mudado depois via `PUT /api/instances/{id}/settings` `{ "syncFullHistory": true|false }` — vale a partir do **próximo** pareamento (logout + connect de novo pra parear outra vez). Resposta `201`: ```json { "id": "clx1a2b3c...", "name": "vendas-01", "status": "disconnected", "phoneNumber": null, "createdAt": "2026-07-01T12:00:00.000Z", "connectedAt": null, "owner": { "id": "...", "name": "Cliente Acme", "email": "optional@example.com" }, "apiKey": "a-api-key-da-instância-mostrada-só-agora" } ``` ```bash curl -X POST "$BASE/api/instances" \ -H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \ -d '{"name":"vendas-01","owner":{"name":"Cliente Acme"}}' ``` ### GET /api/instances Admin key exigida. Lista todas as instâncias. Resposta: `{ "instances": [Instance, ...] }` (mesmo formato da resposta de criação, sem o `apiKey`). Paginação é opcional: omita `limit` e todas as instâncias voltam, mais recente primeiro. Passe `limit` (inteiro, máx. `2000`) pra pegar uma página, e `before` (data/hora ISO 8601, do `nextCursor` da página anterior) pra buscar mais antigas. ### GET /api/owners Admin key exigida. Lista donos (usado pelo formulário de criar instância do painel). Resposta: `{ "owners": [{ "id", "name", "email" }, ...] }`. Mesma paginação opcional do `/instances` acima, ordenado alfabeticamente por nome: omita `limit` pra lista completa, ou passe `limit`/`before` (o `nextCursor` da página anterior, um nome) pra paginar. ### GET /api/instances/{id} Instance key (dessa instância) ou admin key. Retorna o objeto `Instance` (sem o campo `apiKey`). 404 `NOT_FOUND` se a instância não existir. ### DELETE /api/instances/{id} Admin key exigida. Remove a instância por completo: sessão, todas as linhas no banco (mensagens, contatos, grupos, webhooks). Irreversível. Resposta: `{ "removed": true }`. ### GET /api/instances/{id}/connect Inicia a conexão da instância (ou reconecta se já houver uma sessão válida). Espera até ~15s no servidor por um QR code ou pela conexão completa (ex. reconexão no boot com sessão ainda válida) antes de responder — chame de novo se a resposta ainda vier `connecting`. Formatos de resposta (um destes): ```json { "status": "connected", "phoneNumber": "5511999999999" } { "status": "qr_pending", "qr": "data:image/png;base64,..." } { "status": "connecting" } ``` ```bash curl "$BASE/api/instances/$INSTANCE_ID/connect" -H "Authorization: Bearer $INSTANCE_KEY" ``` ### POST /api/instances/{id}/reconnect Destrava uma instância (ex.: presa em `connecting` sem nunca chegar a um QR/conexão) derrubando o socket/timer de reconexão atual e começando uma tentativa nova — sem excluir a instância. Mesmos formatos de resposta e ~15s de espera do `/connect` acima. Corpo (opcional): ```json { "wipeSession": false } ``` `wipeSession: true` também apaga a sessão salva, forçando um pareamento por QR totalmente novo em vez de tentar retomar o existente — use quando suspeitar que a própria sessão está corrompida. ```bash curl -X POST "$BASE/api/instances/$INSTANCE_ID/reconnect" \ -H "Authorization: Bearer $INSTANCE_KEY" -H "Content-Type: application/json" \ -d '{"wipeSession": true}' ``` ### POST /api/instances/{id}/connect/pairing-code Alternativa ao pareamento por QR: conecta a instância pelo código de 8 caracteres mostrado no app em Aparelhos conectados → Conectar com número de telefone. Sempre começa um pareamento limpo (apaga qualquer sessão existente, igual a `/reconnect` com `wipeSession: true`) — um código de pareamento só faz sentido pra um aparelho ainda não registrado. Espera até ~20s (um pouco mais que o QR puro, já que pedir o código exige o socket cru terminar o handshake primeiro). Corpo: ```json { "phoneNumber": "5511999998888" } ``` `phoneNumber`: só dígitos, 8 a 15, com código do país incluído, sem `+`, espaços ou pontuação. Formatos de resposta (um destes): ```json { "status": "connected", "phoneNumber": "5511999999999" } { "status": "qr_pending", "pairingCode": "ABCD1234", "phoneNumber": "5511999998888" } { "status": "connecting" } ``` ```bash curl -X POST "$BASE/api/instances/$INSTANCE_ID/connect/pairing-code" \ -H "Authorization: Bearer $INSTANCE_KEY" -H "Content-Type: application/json" \ -d '{"phoneNumber":"5511999998888"}' ``` ### GET /api/instances/{id}/status Status atual da conexão, lido do Redis (com fallback pro MySQL se o Redis estiver fora). Resposta: ```json { "status": "connected", "phoneNumber": "5511999999999", "connectedAt": "2026-07-01T12:00:00.000Z" } ``` `status` é um de `connecting`, `connected`, `disconnected`, `qr_pending`. 404 se a instância não existir. ### PUT /api/instances/{id}/settings Atualiza configurações da instância. Todo campo é opcional — envie só o que você está mudando. ```json { "syncFullHistory": true, "autoMarkRead": true, "metaAdsEnabled": true, "metaAdsToken": "EAAG..." } ``` - `syncFullHistory`: se um novo pareamento pede o histórico completo do WhatsApp em vez de só as conversas recentes. Vale a partir do *próximo* pareamento por QR/código — mudar numa instância já conectada exige logout + reconexão pra valer. - `autoMarkRead`: marca toda mensagem recebida como lida (tique azul) assim que chega. Vale imediatamente, sem precisar reconectar. - `metaAdsEnabled`: enriquece mensagens de anúncio Click-to-WhatsApp com o nome do anúncio/adset/campanha buscado na Marketing API da Meta. Inerte sem um token configurado (veja abaixo). - `metaAdsToken`: write-only — um token de acesso da Meta com `ads_read` na conta de anúncios relevante. Nunca é retornado por este ou qualquer outro endpoint (a resposta só informa `metaAdsTokenConfigured: true|false`); envie `""` pra limpar um token salvo. Exige `API_KEY_ENCRYPTION_SECRET` configurado no servidor, senão a requisição falha com `503 ENCRYPTION_NOT_CONFIGURED`. Resposta: o objeto `Instance` atualizado (mesmo formato de `GET /api/instances/{id}`). ### POST /api/instances/{id}/logout Desconecta a sessão do WhatsApp mas mantém o registro da instância (e sua API key) — chame `/connect` de novo depois pra parear com um QR novo. Resposta: `{ "status": "disconnected" }`. ### POST /api/instances/{id}/regenerate-key Admin key exigida. Gera uma nova API key pra instância; a antiga para de funcionar na hora. Resposta: `{ "apiKey": "nova-chave" }`. ### GET /api/instances/{id}/api-key Admin key exigida. Retorna a API key *atual* da instância (não gera uma nova). ```json { "apiKey": "wa_..." } ``` Exige `API_KEY_ENCRYPTION_SECRET` configurado no servidor; se não estiver, ou se esta instância foi criada/regenerada antes dessa configuração existir, a resposta é `{ "apiKey": null }` — um `200` normal, não um erro. Gere a chave uma vez pra torná-la recuperável dali em diante. ### PUT /api/instances/{id}/profile-picture Define a foto de perfil do WhatsApp desta instância. Mesmo formato de entrada dos endpoints de envio de mídia: `multipart/form-data` (uma parte `file`) ou um corpo JSON com um campo `url`. O Baileys redimensiona/reconverte a imagem sozinho — nenhum pré-processamento no cliente é necessário, qualquer formato de imagem razoável funciona. Resposta: `{ "updated": true }`. `409 INSTANCE_NOT_CONNECTED` se não estiver conectada. ### DELETE /api/instances/{id}/profile-picture Remove a foto de perfil do WhatsApp desta instância. Resposta: `{ "removed": true }`. ### PUT /api/instances/{id}/profile-name Define o nome de exibição do WhatsApp desta instância. Corpo: `{ "name": "Acme Support" }` (obrigatório, não vazio). Nenhum limite de tamanho é aplicado localmente — o WhatsApp aplica o próprio limite no servidor dele. Resposta: `{ "updated": true }`. `409 INSTANCE_NOT_CONNECTED` se não estiver conectada. ### PUT /api/instances/{id}/profile-status Define o status/recado do WhatsApp desta instância. Corpo: `{ "status": "Atendimento 9h-18h" }` (obrigatório, mas `""` é válido e limpa o campo). Resposta: `{ "updated": true }`. ### POST /api/instances/{id}/check-number Verifica se números de telefone estão registrados no WhatsApp antes de tentar enviar mensagem. Requisição: ```json { "numbers": ["5511999999999", "5511888888888"] } ``` Resposta: ```json { "results": [ { "number": "5511999999999", "exists": true, "jid": "5511999999999@s.whatsapp.net" } ] } ``` `jid` está sempre no formato de número de telefone (nunca `@lid`) — veja "Garantia de formato do JID" abaixo. `jid` é `null` quando `exists` é `false`. ```bash curl -X POST "$BASE/api/instances/$INSTANCE_ID/check-number" \ -H "Authorization: Bearer $INSTANCE_KEY" -H "Content-Type: application/json" \ -d '{"numbers":["5511999999999"]}' ``` ## Mensagens Todo endpoint de envio exige que a instância esteja `connected` — senão `409 INSTANCE_NOT_CONNECTED` ("Instance is not connected. Call /connect first."). Todos são também limitados por instância (veja "Limite de requisições"). `to` aceita um número de telefone puro (só dígitos, com código do país, sem `+` ou pontuação — caracteres não numéricos são removidos) ou um JID completo; nunca envie um JID `@lid` (veja seção de JID — é rejeitado com `400`). **Números de celular brasileiros (o "9" extra):** antes de enviar, um número puro é resolvido pela própria busca do WhatsApp (`onWhatsApp()`, o mesmo mecanismo que `check-number` usa) pro JID que de fato foi registrado pra aquele número — que nem sempre bate com a quantidade de dígitos que você mandou, já que se o "9" extra do celular entra ou não no número depende do histórico de portabilidade/operadora, não de algo derivável só dos dígitos. Então `"to": "5511987654321"` e `"to": "551187654321"` chegam na mesma pessoa se for isso que o WhatsApp tem registrado — você não precisa saber ou adivinhar qual formato está certo. Isso é best-effort e nunca bloqueia o envio: se a busca falhar (instabilidade de rede) ou o número não bater, o endpoint envia pro número exatamente como foi passado, igual a antes dessa resolução existir. **Fila de retentativa automática (Redis):** os endpoints de texto/imagem/vídeo/áudio/ documento/localização/reação nunca simplesmente falham uma mensagem por um erro passageiro (instabilidade de rede, WhatsApp momentaneamente inacessível). Se a primeira tentativa falha, a mensagem é enfileirada no Redis e reenviada automaticamente com backoff exponencial — o endpoint responde `202` em vez de erro: ```json { "status": "queued", "messageId": "3EB0...", "reason": "Send failed (...); queued for automatic retry." } ``` Antes de cada retentativa, a API checa se essa mensagem exata (rastreada pelo próprio `messageId`, atribuído de antemão) já foi marcada como enviada — então uma resposta perdida nunca vira uma entrega duplicada, mesmo que o envio original tenha de fato chegado no WhatsApp. Consulte `GET /api/instances/{id}/chats/{chatJid}/messages` (ou o webhook) por esse `messageId` pra ver quando ela chega. Depois de um número limitado de tentativas ela é marcada como falha permanente em vez de retentar pra sempre; isso é best-effort como tudo que depende de Redis neste projeto — se o Redis estiver indisponível, um envio que falhar simplesmente retorna o erro direto, igual a antes dessa fila existir. **Envios lentos (grupos grandes/frios):** enviar pra um grupo grande, ou um pro qual esta sessão do WhatsApp ainda não montou sessões de criptografia com todo aparelho de todo participante, pode legitimamente demorar bem mais que um envio normal. Em vez de segurar a conexão HTTP aberta tempo suficiente pra um proxy reverso na frente da API matá-la com seu próprio `502`/`504` (indistinguível de uma falha real), o envio é corrido contra `SEND_SYNC_TIMEOUT_MS` (padrão 15s): se isso passar primeiro, o endpoint responde `202 { "status": "queued", "messageId", "reason": "Send is taking longer than ...ms ..." }` na hora enquanto o envio continua rodando em segundo plano — não é uma falha, só ainda está em andamento. Do jeito que ela terminar, é tratado exatamente como o caminho da fila de retentativa acima (marcada como enviada, ou enfileirada/retentada se falhar), então consultar pelo `messageId` funciona do mesmo jeito não importa qual motivo de `202` você recebeu. **Enviando vários arquivos numa requisição:** o formulário multipart de `/messages/image`, `/video`, `/audio`, e `/document` aceita mais de uma parte `file`. O WhatsApp não tem uma mensagem nativa de "álbum", então cada arquivo vira sua própria mensagem — enviadas uma de cada vez, com um intervalo mínimo entre elas (`SEND_BATCH_INTER_MESSAGE_DELAY_MS`, padrão 1200ms) em vez de todas de uma vez. Disparar várias mensagens seguidas pro mesmo chat sem intervalo é um jeito conhecido do WhatsApp descartar ou limitar silenciosamente algumas delas (sem erro, elas só nunca chegam). A resposta pra uma requisição com vários arquivos é `{ "results": [...] }`, uma entrada por arquivo na ordem de upload, cada uma no formato da resposta de arquivo único acima (`{status: "sent"|"queued", messageId, key?, reason?}`); `caption`/`fileName` valem por arquivo pra documentos (cada um mantém seu próprio nome de arquivo enviado) e só pro primeiro item em imagens/vídeos, enquanto `quotedMessageId` (veja acima) vale pra toda mensagem do lote — todas respondem à mesma mensagem original. Se o lote inteiro não terminar dentro de `SEND_SYNC_TIMEOUT_MS`, a resposta é `202` com o que já terminou até ali mais uma contagem `"remaining"` — o resto continua enviando em segundo plano pela mesma lógica, nada no lote é descartado silenciosamente. **Enviar pra um grupo** usa exatamente os mesmos endpoints — não existe um endpoint separado de "enviar pra grupo". Coloque o JID do grupo (termina em `@g.us`, ex. `"123456789-1600000000@g.us"` — pegue nas respostas de criar/listar/consultar grupo, veja "Grupos" abaixo) em `to` no lugar de um número de telefone: ```json { "to": "123456789-1600000000@g.us", "text": "Olá, grupo!" } ``` Diferente de um número de telefone, um JID de grupo **não** é auto-formatado a partir de dígitos puros — inclua o JID completo com `@g.us`. Se `to` for só dígitos e tiver mais de 15 caracteres (o máximo pra um número de telefone real segundo o E.164), a API assume que você quis dizer um JID de grupo/outro e esqueceu o sufixo de domínio, e rejeita com `400 BAD_REQUEST` em vez de montar silenciosamente um JID `@s.whatsapp.net` inválido que só falharia na entrega sem um erro útil. **Marcar (@mencionar) alguém num grupo:** campo opcional `mentions` em `POST .../messages/text` — array de números de telefone (nunca `@lid`/jid cru; a API resolve internamente o identificador que o Baileys/WhatsApp realmente precisa pra aquele participante naquele grupo, que às vezes é um LID por trás dos panos). Escreva `@` no próprio `text` pra cada entrada de `mentions`, alinhando visualmente com o que o cliente WhatsApp mostra — o Baileys não exige isso tecnicamente (a marcação em si vem do array), mas é a convenção que todo cliente espera ver: ```json { "to": "123456789-1600000000@g.us", "text": "Bom dia @5511999999999!", "mentions": ["5511999999999"] } ``` Só vale quando `to` é um grupo (`400 BAD_REQUEST` se não for) e todo número em `mentions` precisa ser participante de verdade do grupo (`400 BAD_REQUEST` se não for — evita mandar uma menção que não vai aparecer pra ninguém). Mensagens recebidas que te marcam também trazem `mentions` com número de telefone, nunca id/jid cru — ver o evento `messages.upsert` abaixo. ### POST /api/instances/{id}/messages/text ```json { "to": "5511999999999", "text": "Olá!" } ``` `to`/`text` são obrigatórios, não vazios; `mentions` é opcional (ver acima). Resposta `200`: ```json { "messageId": "3EB0...", "key": { "remoteJid": "5511999999999@s.whatsapp.net", "id": "3EB0...", "fromMe": true } } ``` ```bash curl -X POST "$BASE/api/instances/$INSTANCE_ID/messages/text" \ -H "Authorization: Bearer $INSTANCE_KEY" -H "Content-Type: application/json" \ -d '{"to":"5511999999999","text":"Olá!"}' ``` **Respondendo (citando) uma mensagem:** todo endpoint de envio abaixo — `text`, `image`, `video`, `audio`, `document`, `location` — aceita um `quotedMessageId` opcional: o id da mensagem do WhatsApp de uma mensagem anterior na mesma conversa (o `messageId` que esta API retornou quando ela foi enviada, ou seu `id`/`waMessageId` no histórico de conversa). A nova mensagem é enviada como resposta a ela, exatamente como tocar "Responder" no próprio WhatsApp — a citação aparece acima da nova mensagem em todo cliente. A mensagem citada precisa pertencer à mesma conversa que `to`; um id desconhecido, ou de uma conversa diferente, responde `404`. Citar uma mensagem que esta própria API enviou preserva o conteúdo original exato (incluindo uma miniatura ao vivo, pra mídia); citar uma mensagem recebida de outra pessoa reconstrói um resumo a partir do tipo/ texto/legenda salvos — ícone e texto corretos, mas sem miniatura de mídia ao vivo. ```bash curl -X POST "$BASE/api/instances/$INSTANCE_ID/messages/text" \ -H "Authorization: Bearer $INSTANCE_KEY" -H "Content-Type: application/json" \ -d '{"to":"5511999999999","text":"Chega em 10 min","quotedMessageId":"3EB0..."}' ``` ### POST /api/instances/{id}/messages/image ### POST /api/instances/{id}/messages/video ### POST /api/instances/{id}/messages/audio ### POST /api/instances/{id}/messages/document Os quatro aceitam **ou** `multipart/form-data` (uma parte de arquivo mais campos do formulário) **ou** um corpo JSON com um campo `url` (o servidor busca a URL). Mesmo formato de resposta do texto: `{ "messageId", "key" }`. Campos comuns: `to` (obrigatório nos dois modos), `caption` (imagem/vídeo/documento, opcional), `quotedMessageId` (opcional, veja acima — responder a uma mensagem anterior na conversa). - **image**: `file` ou `url`. `caption` opcional. - **video**: `file` ou `url`. `caption` opcional. `ptv` opcional (`"true"`/`"false"` como string no multipart, boolean no JSON) — envia como nota em vídeo (PTV redondo) em vez de vídeo normal. - **audio**: `file` ou `url`. Sempre enviado como nota de voz (ptt) — a bolha nativa gravada por microfone com onda sonora, nunca como anexo de áudio baixável. Não existe a opção de enviar como anexo normal. **Qualquer formato de entrada é aceito** (mp3, wav, m4a, ogg, o que você tiver) — o servidor converte com `ffmpeg` pra OGG/Opus mono antes de enviar, sempre respondendo com `mimetype: "audio/ogg; codecs=opus"` independente do que foi enviado. O WhatsApp só toca áudio/nota de voz de forma confiável em todo aparelho (Android, iOS, Web/Desktop) codificado assim, então não pré-converta no cliente — só envie o arquivo original. Se o servidor não tiver `ffmpeg` no PATH, a requisição falha com `500 FFMPEG_UNAVAILABLE`; se o dado enviado não for decodificável como áudio, falha com `500 AUDIO_CONVERSION_FAILED`. - **document**: `file` ou `url`, mais **obrigatório** `fileName` (a menos que uma parte multipart já traga um nome de arquivo). `mimetype` opcional (padrão `application/octet-stream` se não for possível inferir). `caption` opcional. ```bash # via URL pública (JSON) curl -X POST "$BASE/api/instances/$INSTANCE_ID/messages/image" \ -H "Authorization: Bearer $INSTANCE_KEY" -H "Content-Type: application/json" \ -d '{"to":"5511999999999","url":"https://example.com/foto.jpg","caption":"Olha isso"}' # via upload multipart curl -X POST "$BASE/api/instances/$INSTANCE_ID/messages/image" \ -H "Authorization: Bearer $INSTANCE_KEY" \ -F "to=5511999999999" -F "caption=Olha isso" -F "file=@/path/foto.jpg" # áudio -- sempre enviado como nota de voz (PTT); qualquer formato funciona, ex. mp3; # o servidor converte pra OGG/Opus automaticamente curl -X POST "$BASE/api/instances/$INSTANCE_ID/messages/audio" \ -H "Authorization: Bearer $INSTANCE_KEY" \ -F "to=5511999999999" -F "file=@/path/audio.mp3" # documento curl -X POST "$BASE/api/instances/$INSTANCE_ID/messages/document" \ -H "Authorization: Bearer $INSTANCE_KEY" -H "Content-Type: application/json" \ -d '{"to":"5511999999999","url":"https://example.com/contrato.pdf","fileName":"contrato.pdf"}' ``` ### POST /api/instances/{id}/messages/location ```json { "to": "5511999999999", "latitude": -23.55, "longitude": -46.63, "name": "São Paulo" } ``` `to`, `latitude`, `longitude` obrigatórios; `name` opcional. Resposta: `{ "messageId", "key" }`. ### POST /api/instances/{id}/messages/buttons Envia uma mensagem interativa com 1 a 3 botões. ```json { "to": "5511999999999", "text": "Como podemos ajudar?", "footer": "Responda em até 24h", "buttons": [ { "type": "reply", "id": "opt1", "displayText": "Falar com vendas" }, { "type": "url", "displayText": "Ver catálogo", "url": "https://exemplo.com/catalogo" }, { "type": "call", "displayText": "Ligar agora", "phoneNumber": "5511999999999" } ] } ``` `buttons`: de 1 a 3 entradas. Cada uma tem um `type` (`reply`, `url`, `call`, ou `copy`) que determina os outros campos obrigatórios — `reply` precisa de `id`+`displayText`; `url` precisa de `displayText`+`url`; `call` precisa de `displayText`+`phoneNumber`; `copy` precisa de `displayText`+`copyCode` (copia esse código pra área de transferência ao tocar). `footer` é opcional. Renderizar mais de 3 botões é inconsistente entre versões do cliente WhatsApp, então isso limita em 3 mesmo o protocolo tecnicamente permitindo mais. Não aceita `quotedMessageId` (diferente dos outros endpoints de envio). Resposta: `{ "messageId", "key" }`. ### POST /api/instances/{id}/messages/product Envia um "cartão" de produto numa conversa — diferente de `/catalog/products`, que cria/edita/remove produtos no catálogo em si. Aceita `multipart/form-data` (upload de arquivo) ou JSON com `imageUrl`; só aceita uma imagem (mais de um arquivo retorna `400`). ```json { "to": "5511999999999", "title": "Camiseta Preta M", "description": "100% algodão", "price": 49.9, "currency": "BRL", "imageUrl": "https://exemplo.com/camiseta.jpg", "url": "https://exemplo.com/produtos/camiseta-preta-m" } ``` `to` e `title` obrigatórios. `price` é decimal em unidade "normal" da moeda (ex: `49.9` para R$49,90) — **não** é o mesmo formato inteiro que `price` usa em `/catalog/products`. `url` é o link do produto no catálogo/loja mostrado no cartão; `imageUrl` é a URL da imagem em si, um campo separado de propósito pra nunca se confundir com `url`. `productId` (id do produto no catálogo do WhatsApp, retornado por `GET /catalog`) e `retailerId` (id próprio do lojista) são opcionais e independentes entre si. `businessOwnerJid` é opcional e usa o jid da própria instância por padrão. `body`/`footer` aparecem como texto abaixo do cartão. Resposta: `{ "messageId", "key" }`. ### DELETE /api/instances/{id}/messages/{messageId} Apaga uma mensagem para todos. Exige o parâmetro de query `remoteJid` (o JID da conversa onde a mensagem está). Parâmetro `fromMe` opcional (`"true"`/`"false"`, padrão `"true"`) — use `"false"` só se estiver apagando uma mensagem que NÃO foi enviada por esta instância (raro/incomum). Bloqueado com `409 DELETE_WINDOW_EXPIRED` se a mensagem for mais antiga que a janela de apagar-para-todos do WhatsApp (~2,5 dias, aplicada no lado da API pra você ter um erro claro em vez de um no-op silencioso do WhatsApp). Essa checagem só vale pra mensagens que esta própria API enviou e registrou; se a mensagem não foi registrada por esta API, a checagem de janela é pulada. ```bash curl -X DELETE "$BASE/api/instances/$INSTANCE_ID/messages/$MSG_ID?remoteJid=5511999999999@s.whatsapp.net" \ -H "Authorization: Bearer $INSTANCE_KEY" ``` ### PUT /api/instances/{id}/messages/{messageId} Edita uma mensagem que esta instância já enviou — o recurso "Editar" do próprio WhatsApp. ```json { "remoteJid": "5511999999999@s.whatsapp.net", "text": "preço correto: R$ 79,90" } ``` `remoteJid` e `text` obrigatórios, não vazios. Só texto (o WhatsApp não permite editar legenda ou mídia). Só funciona numa mensagem que esta API mesma enviou e ainda tem registrada — um id que ela não reconhece como seu responde `404`. Bloqueado com `409 EDIT_WINDOW_EXPIRED` se a mensagem for mais antiga que a janela de edição do WhatsApp (~15 minutos, aplicada no lado da API pra um erro claro em vez de um no-op silencioso). O histórico mantido por esta API — e qualquer citação futura por `quotedMessageId` dessa mensagem — reflete o texto editado automaticamente assim que a edição é confirmada. ```bash curl -X PUT "$BASE/api/instances/$INSTANCE_ID/messages/$MSG_ID" \ -H "Authorization: Bearer $INSTANCE_KEY" -H "Content-Type: application/json" \ -d '{"remoteJid":"5511999999999@s.whatsapp.net","text":"preço correto: R$ 79,90"}' ``` ### POST /api/instances/{id}/messages/{messageId}/react Reage a uma mensagem com um emoji. ```json { "remoteJid": "5511999999999@s.whatsapp.net", "emoji": "👍", "fromMe": false } ``` `remoteJid` e `emoji` obrigatórios. `emoji: ""` (string vazia) **remove** uma reação existente — isso é convenção do próprio WhatsApp, não algo inventado por esta API. `fromMe` opcional, padrão `false`: reagir a uma mensagem recebida de outra pessoa é o caso comum (ex. um bot confirmando a mensagem de um cliente); use `true` só ao reagir a uma mensagem que esta própria instância enviou. Resposta: `{ "messageId", "key" }` (a key da própria reação, não da mensagem alvo). ```bash curl -X POST "$BASE/api/instances/$INSTANCE_ID/messages/$MSG_ID/react" \ -H "Authorization: Bearer $INSTANCE_KEY" -H "Content-Type: application/json" \ -d '{"remoteJid":"5511999999999@s.whatsapp.net","emoji":"👍"}' ``` ### POST /api/instances/{id}/messages/read Marca mensagens recebidas como lidas (tique azul). ```json { "keys": [ { "remoteJid": "5511999999999@s.whatsapp.net", "id": "3EB0...", "fromMe": false } ] } ``` `keys` obrigatório, mínimo 1 item. `fromMe` opcional por key. Resposta: `{ "marked": 1 }` (quantidade de keys enviadas). ### GET /api/instances/{id}/chats/{chatJid}/messages Histórico paginado de uma conversa, vindo da própria tabela de mensagens desta API (não é uma busca ao vivo no WhatsApp) — veja a nota de escopo abaixo. O `createdAt` de cada linha é o timestamp da mensagem no WhatsApp quando ele fornece um, não só o momento em que o servidor gravou. `chatJid` aceita um número de telefone puro (ex. `5511999999999`), um JID de número (`5511999999999@s.whatsapp.net`), ou um JID de grupo (`...@g.us`) — mesmos formatos aceitos em todo outro lugar onde um JID é esperado (veja `to` nos endpoints de envio). Query: `limit` (inteiro, padrão `50`, máx. `100`), `before` (data/hora ISO 8601 — retorna mensagens com `createdAt` anterior a isso, pra paginação pra trás). Resposta: ```json { "messages": [ /* mais recentes primeiro */ ], "nextCursor": "2026-06-30T09:00:00.000Z" } ``` `nextCursor` é `null` quando a página retornou menos linhas que `limit` (não há mais páginas); senão é o `createdAt` da linha mais antiga retornada — passe como `before` pra buscar a próxima página. Mensagens de figurinha são persistidas com `type: "sticker"`, `mimetype: "image/webp"` quando o WhatsApp não manda um mimetype, e `isMedia: true`, então `messages.upsert` baixa a figurinha e retorna/guarda `mediaUrl` como mídia de imagem/áudio/vídeo/documento. Mensagens de reação são persistidas com `type: "reaction"` e incluem `reaction`, `targetMessageId`, e `targetChatJid` em `content`, pra interfaces de histórico renderizarem reações sem tratá-las como mensagens desconhecidas. Nota de escopo: só mensagens recebidas de outros, e mensagens enviadas *por esta API*, são registradas. Mensagens enviadas de outro aparelho conectado à mesma conta do WhatsApp (não por esta API) não são persistidas aqui. ### GET /api/instances/{id}/messages/import/{phone} Exportação em massa do histórico completo de um número, do mais antigo pro mais recente — pra importar a conversa de um contato em outro sistema, diferente do endpoint de histórico acima (paginado do mais recente pro mais antigo, pra renderizar uma tela de conversa). O `content` de cada mensagem já traz `mediaUrl` pra mensagens de mídia, então quem chama nunca precisa tocar no CDN do próprio WhatsApp. `phone` aceita os mesmos formatos que `chatJid` acima: um número puro, um JID de número, ou um JID de grupo. Query: `limit` (inteiro, padrão `2000`, máx. `5000`), `since` (data/hora ISO 8601 — só mensagens a partir desse timestamp, pra paginação pra frente). Resposta: ```json { "phone": "5511999999999", "chatJid": "5511999999999@s.whatsapp.net", "count": 2000, "truncated": true, "nextSince": "2026-06-30T09:00:00.000Z", "messages": [ /* mais antigas primeiro */ ] } ``` Quando `truncated` é `true`, passe `nextSince` de volta como `since` pra buscar a próxima página. `since` é inclusivo, então a mensagem do cursor da página anterior pode aparecer de novo no início da próxima — remova duplicatas por `waMessageId` ao paginar. ## Presença ### POST /api/instances/{id}/presence Define a própria presença da instância. ```json { "presence": "composing", "to": "5511999999999" } ``` `presence` é um de `composing`, `recording`, `available`, `unavailable`. `to` é **obrigatório** quando `presence` é `composing` ou `recording` (400 senão), opcional/ignorado pra `available`/`unavailable`. Resposta: `{ "ok": true }`. ### POST /api/instances/{id}/presence/subscribe Assina as atualizações de presença de um contato (o status online/digitando dele passa a chegar pelo evento de webhook `presence.update`). ```json { "jid": "5511999999999" } ``` Resposta: `{ "subscribed": true }`. ## Grupos ### POST /api/instances/{id}/groups ```json { "name": "Time de Vendas", "participants": ["5511999999999", "5511888888888"] } ``` `name` obrigatório (1–150 caracteres). `participants` obrigatório, mínimo 1, números de telefone ou JIDs. Resposta `201`: objeto `GroupMetadata` completo (veja formato abaixo), JIDs em formato de número de telefone em todo lugar. ```bash curl -X POST "$BASE/api/instances/$INSTANCE_ID/groups" \ -H "Authorization: Bearer $INSTANCE_KEY" -H "Content-Type: application/json" \ -d '{"name":"Time de Vendas","participants":["5511999999999"]}' ``` ### GET /api/instances/{id}/groups Lista todos os grupos em que esta instância participa. Cache de ~45s no Redis (por instância). Resposta: ```json { "groups": [ /* GroupMetadata, ... */ ] } ``` ### GET /api/instances/{id}/groups/messages Lista todos os grupos sincronizados e inclui as mensagens persistidas de cada um, vindas da própria tabela de mensagens desta API. Não é uma busca ao vivo no WhatsApp e não exige o socket conectado; lê os grupos já sincronizados no MySQL e anexa mensagens onde o `chatJid` bate com o `groupJid` do grupo. Query: `limit` (inteiro, padrão `1000`, mín. `1`, máx. `1000`) limita mensagens por grupo; `messageLimit` ainda é aceito como apelido retrocompatível de `limit`; `before` (data/hora ISO 8601) retorna só mensagens com `createdAt` anterior a isso, pra paginação pra trás. Mensagens dentro de cada grupo vêm mais recentes primeiro. `groupLimit`/`groupBefore` são separados do acima — eles limitam quantos *grupos* entram na resposta (não mensagens dentro de um grupo). Os dois são opcionais, sem padrão: omita-os pra todo grupo sincronizado ser retornado, exatamente como antes desses dois parâmetros existirem. Resposta: ```json { "groups": [ { "id": "1", "instanceId": "clx1a2b3c...", "groupJid": "123456789-1600000000@g.us", "name": "Time de Vendas", "syncedAt": "2026-07-01T12:00:00.000Z", "messages": [ /* linhas de mensagem deste grupo, mais recentes primeiro */ ], "nextCursor": "2026-06-30T09:00:00.000Z" } ] } ``` `nextCursor` é por grupo: é `null` quando aquele grupo retornou menos mensagens que `limit`; senão é o `createdAt` da mensagem mais antiga retornada pra aquele grupo — passe como `before` pra buscar mensagens mais antigas. Grupos sem mensagens persistidas ainda voltam com `messages: []` e `nextCursor: null`. Um `groupsNextCursor` no nível raiz só aparece quando `groupLimit` foi passado e pode haver mais grupos — passe-o como `groupBefore` pra buscar a próxima página de grupos; fica totalmente ausente (não `null`) quando `groupLimit` não foi usado. ```bash curl "$BASE/api/instances/$INSTANCE_ID/groups/messages?limit=1000" \ -H "Authorization: Bearer $INSTANCE_KEY" # paginar a lista de grupos em si (instância com muitos grupos), 50 por vez curl "$BASE/api/instances/$INSTANCE_ID/groups/messages?groupLimit=50" \ -H "Authorization: Bearer $INSTANCE_KEY" ``` ### GET /api/instances/{id}/groups/{groupJid}/messages Lista mensagens persistidas de um grupo sincronizado específico, pelo seu JID. Também vem da tabela de mensagens (não é busca ao vivo) e não exige o socket conectado. O grupo precisa já existir na tabela de grupos sincronizados desta API; senão a resposta é `404 NOT_FOUND`. Cada mensagem retornada vem enriquecida com `senderJid`, `senderNumber`, `senderName`, um objeto `sender` aninhado, e `mediaUrl` quando a mídia já foi espelhada em armazenamento local. `senderName` está sempre preenchido nessa resposta: nome de contato salvo ou nome do participante no grupo ao vivo (se conectado) primeiro, depois o pushName salvo, depois o nome da instância só pra mensagens enviadas por ela, depois número/JID do remetente como último recurso. JIDs/números de remetente estão em formato de número de telefone, não LID, quando a mensagem foi persistida depois desse enriquecimento existir. Query: `limit` (inteiro, padrão `1000`, mín. `1`, máx. `1000`) e `before` (data/hora ISO 8601) usam a mesma semântica de paginação do endpoint de todos-os-grupos-com-mensagens. `messageLimit` é aceito como apelido retrocompatível de `limit`. Resposta: ```json { "id": "1", "instanceId": "clx1a2b3c...", "groupJid": "123456789-1600000000@g.us", "name": "Time de Vendas", "syncedAt": "2026-07-01T12:00:00.000Z", "messages": [ { "waMessageId": "3EB0...", "chatJid": "123456789-1600000000@g.us", "senderJid": "5511999999999@s.whatsapp.net", "senderNumber": "5511999999999", "senderName": "Maria Sender", "sender": { "jid": "5511999999999@s.whatsapp.net", "number": "5511999999999", "name": "Maria Sender" }, "mediaUrl": "https://api.example.com/media/2026/07/file.jpg" } ], "nextCursor": null } ``` ```bash curl "$BASE/api/instances/$INSTANCE_ID/groups/$GROUP_JID/messages?limit=1000" \ -H "Authorization: Bearer $INSTANCE_KEY" ``` ### POST /api/instances/{id}/groups/{groupJid}/sync Sincronização de histórico sob demanda: pede ao WhatsApp mensagens mais antigas deste grupo, ancoradas na mensagem mais antiga que esta API já tem persistida. Sem corpo na requisição. O grupo precisa já estar sincronizado (`404 NOT_FOUND` senão), e precisa haver ao menos uma mensagem já persistida pra ancorar (`400 BAD_REQUEST` senão — sincronize um QR/pareamento primeiro). Os resultados chegam de forma assíncrona pelo evento de webhook `messaging-history.set` e são persistidos do mesmo jeito que a sincronização inicial de histórico; este endpoint só dispara o pedido. ```json { "status": "requested", "anchor": { "waMessageId": "3EB0...", "createdAt": "2026-06-30T09:00:00.000Z" } } ``` ```bash curl -X POST "$BASE/api/instances/$INSTANCE_ID/groups/$GROUP_JID/sync" \ -H "Authorization: Bearer $INSTANCE_KEY" ``` ### GET /api/instances/{id}/groups/{groupJid} Metadados de um único grupo. Cache de ~60s no Redis. `409 INSTANCE_NOT_CONNECTED` se a instância não estiver conectada (metadados de grupo exigem um socket ao vivo/cache aquecido, diferente do endpoint de lista acima). Formato do `GroupMetadata` (campos relevantes, todo campo identificador de dono/autor/ participante é um JID em formato de número de telefone, nunca `@lid`): ```json { "id": "123456789-1600000000@g.us", "subject": "Time de Vendas", "owner": "5511999999999@s.whatsapp.net", "desc": "Descrição do grupo", "participants": [ { "id": "5511999999999@s.whatsapp.net", "admin": "superadmin" }, { "id": "5511888888888@s.whatsapp.net", "admin": null } ] } ``` ### PUT /api/instances/{id}/groups/{groupJid} Atualiza as configurações do grupo. Pelo menos um campo é obrigatório. ```json { "subject": "Nome novo", "description": "Descrição nova", "announcement": true, "locked": false, "ephemeralExpiration": 604800 } ``` - `subject` (1–150 caracteres), `description`: como antes. - `announcement`: `true` = só admins podem enviar mensagens no grupo; `false` = todo mundo pode. - `locked`: `true` = só admins podem editar informações/configurações do grupo; `false` = todo mundo pode. - `ephemeralExpiration`: segundos até a mensagem desaparecer (o recurso de mensagens temporárias do próprio WhatsApp); `0` desativa. Qualquer inteiro não negativo é aceito aqui e passado direto pro WhatsApp — esta API não força quais durações específicas a interface do WhatsApp costuma oferecer. Resposta: `{ "updated": true }`. ### PUT /api/instances/{id}/groups/{groupJid}/picture Define ou muda a foto do grupo. Mesmo formato de entrada do endpoint de foto de perfil: `multipart/form-data` (uma parte `file`) ou um corpo JSON com um campo `url`. Exige que esta instância seja admin do grupo — o WhatsApp aplica isso no servidor dele, não há checagem local separada. Resposta: `{ "updated": true }`. `409 INSTANCE_NOT_CONNECTED` se não estiver conectada. ### DELETE /api/instances/{id}/groups/{groupJid}/picture Remove a foto do grupo. Mesma exigência de admin do PUT acima. Resposta: `{ "removed": true }`. `409 INSTANCE_NOT_CONNECTED` se não estiver conectada. ### GET /api/instances/{id}/groups/{groupJid}/invite-code Obtém (ou gera, se ainda não existir) o código de convite atual do grupo. Exige que esta instância seja admin do grupo — senão `502 INVITE_CODE_UNAVAILABLE`. ```json { "code": "AbCdEfGhIjK", "inviteUrl": "https://chat.whatsapp.com/AbCdEfGhIjK" } ``` ### POST /api/instances/{id}/groups/{groupJid}/invite-code/revoke Revoga o código de convite atual e gera um novo (o link antigo para de funcionar). Mesmo formato de resposta e exigência de admin do GET acima. ### POST /api/instances/{id}/groups/join Entra num grupo usando um código de convite ou um link completo. ```json { "code": "https://chat.whatsapp.com/AbCdEfGhIjK" } ``` `code` aceita tanto o código puro (`"AbCdEfGhIjK"`) quanto a URL completa `https://chat.whatsapp.com/...` que alguém compartilhou — os dois funcionam igual. Resposta: `{ "groupJid": "123456789-1600000000@g.us" }`. `502 GROUP_JOIN_FAILED` se o código for inválido, expirado, ou já revogado. ```bash curl -X POST "$BASE/api/instances/$INSTANCE_ID/groups/join" \ -H "Authorization: Bearer $INSTANCE_KEY" -H "Content-Type: application/json" \ -d '{"code":"https://chat.whatsapp.com/AbCdEfGhIjK"}' ``` ### POST /api/instances/{id}/groups/{groupJid}/participants Adiciona participantes. ```json { "participants": ["5511999999999"] } ``` Resposta: `{ "result": [ { "jid": "5511999999999@s.whatsapp.net", "status": "200" } ] }` (uma entrada por participante, `jid` normalizado pro formato de número de telefone; `status` é o código de resultado do WhatsApp por participante, como string — ex. `"200"` ok, `"403"` não permitido, `"408"` não está no WhatsApp/timeout, `"409"` já é participante). ### DELETE /api/instances/{id}/groups/{groupJid}/participants Remove participantes. Mesmo formato de requisição/resposta do adicionar, acima. ```bash curl -X POST "$BASE/api/instances/$INSTANCE_ID/groups/$GROUP_JID/participants" \ -H "Authorization: Bearer $INSTANCE_KEY" -H "Content-Type: application/json" \ -d '{"participants":["5511999999999"]}' ``` ## Contatos e conversas ### GET /api/instances/{id}/contacts Contatos sincronizados (cache de ~45s no Redis). Resposta: `{ "contacts": [ { "jid", "name", "syncedAt", ... } ] }`. Paginação é opcional: omita `limit` e todo contato é retornado, mais recente sincronizado primeiro, exatamente como antes da paginação existir. Passe `limit` (inteiro, máx. `2000`) pra pegar uma página, e `before` (data/hora ISO 8601, do `nextCursor` da página anterior) pra buscar mais antigos. `nextCursor` só aparece na resposta quando `limit` foi passado. ```bash curl "$BASE/api/instances/$INSTANCE_ID/contacts?limit=200" -H "Authorization: Bearer $INSTANCE_KEY" ``` ### GET /api/instances/{id}/chats Conversas derivadas do próprio log de mensagens desta API (cache de ~45s no Redis) — uma linha por `chatJid` distinto que tenha ao menos uma mensagem registrada, com o timestamp da mensagem mais recente. Resposta: ```json { "chats": [ { "chatJid": "5511999999999@s.whatsapp.net", "name": "Some Contact", "lastMessageAt": "2026-07-01T12:00:00.000Z" } ] } ``` Mesma paginação opcional do `/contacts` acima: omita `limit` pra lista completa (comportamento padrão inalterado), ou passe `limit`/`before` (comparado contra `lastMessageAt`) pra paginar. (Veja "Mensagens" acima pro `GET /api/instances/{id}/chats/{chatJid}/messages`, o endpoint de histórico por conversa.) ### GET /api/instances/{id}/profile-picture/{jid} Busca a foto de perfil de um usuário ou grupo. `jid` aceita os mesmos formatos de todo outro lugar nesta API: um número puro, um JID de número, ou um JID de grupo (`@g.us`) — nunca um valor `@lid`. Opcional `?type=preview` (padrão, miniatura de baixa resolução) ou `?type=image` (resolução completa). Pra JIDs de usuário (não grupos), isso primeiro confirma o JID canônico do WhatsApp pra aquele número via `onWhatsApp()` antes de pedir a foto — um JID montado ingenuamente a partir de dígitos crus nem sempre bate com o que o WhatsApp de fato registrou pra aquele número (a ambiguidade histórica do "9" extra dos celulares brasileiros é o caso clássico). Se o número não estiver registrado no WhatsApp de forma alguma, isso responde `200` com um motivo dedicado, e nenhuma requisição de foto de perfil sequer é tentada nesse caso: ```json { "jid": "5511999999999@s.whatsapp.net", "url": null, "reason": "This number is not registered on WhatsApp" } ``` Senão, a foto é baixada do WhatsApp e espelhada no armazenamento local de mídia desta API — igual à mídia de mensagens recebidas — então `url` é sempre um link estável sob `/media/*` no próprio domínio desta API, nunca um link cru do CDN `pps.whatsapp.net` (esses não têm garantia de continuar acessíveis, e isso mantém toda URL de mídia que esta API retorna consistente, em todo endpoint). ```json { "jid": "5511999999999@s.whatsapp.net", "url": "https://api-wpp.example.com/media/1751370000000-9b1e2c-c3.jpg" } ``` Se o contato/grupo genuinamente não tem foto definida, isso responde `200` com `"url": null` e sem o campo `reason` — não retorna 404, já que isso é um resultado normal e esperado, não um erro. Se a busca falhar por qualquer outro motivo (configuração de privacidade bloqueando, timeout, token de contato confiável ausente, o próprio download do CDN do WhatsApp falhando, etc), isso ainda responde `200` com `"url": null`, mas adiciona um campo `reason` com a mensagem de erro subjacente pra você distinguir os dois casos: ```json { "jid": "5511999999999@s.whatsapp.net", "url": null, "reason": "item-not-found" } ``` Só resultados limpos (uma URL real, ou um "sem foto" genuíno) são cacheados (~5min — fotos de perfil não mudam com frequência e cada falta de cache é uma consulta ao vivo ao WhatsApp mais um novo download); uma falha com `reason` nunca é cacheada, então a próxima requisição tenta de novo do zero em vez de repetir um erro obsoleto. Como toda mídia guardada localmente, o arquivo salvo é limpo automaticamente depois da janela de retenção configurada no servidor (veja "Mídia" abaixo) — não trate essa `url` como permanente, e note que uma requisição repetida depois dessa janela baixa e salva uma cópia nova sob uma URL nova. ## Catálogo Catálogo de produtos do WhatsApp Business. Exige que a conta por trás seja de fato uma conta Business com catálogo habilitado — estes endpoints fazem proxy direto pra API de catálogo do próprio WhatsApp e falham se não for. ### GET /api/instances/{id}/catalog Lista produtos de um catálogo. Query: `jid` (opcional — usa o catálogo da própria instância por padrão), `limit` (1-100), `cursor` (do `nextCursor` de uma resposta anterior, pra paginação). ```json { "products": [ { "...": "objeto de produto do WhatsApp" } ], "nextCursor": "..." } ``` `nextCursor` é `null` na última página. ### POST /api/instances/{id}/catalog/products Cria um produto no catálogo da própria instância. `multipart/form-data` (uma ou mais partes `file` pra imagens) ou JSON (um array `images` de URLs). Campos: `name`, `description`, `price` (inteiro, menor unidade da moeda, ex. centavos), `currency` (código de 3 letras, ex. `"BRL"`), `retailerId` (opcional), `url` (link do produto, opcional), `isHidden` (boolean opcional), `originCountryCode` (opcional, código de 2 letras). ```bash curl -X POST "$BASE/api/instances/$INSTANCE_ID/catalog/products" \ -H "Authorization: Bearer $INSTANCE_KEY" \ -F "name=Camiseta Azul" -F "description=100% algodão" -F "price=4990" -F "currency=BRL" \ -F "file=@/caminho/foto.jpg" ``` Resposta: `201` com o objeto do produto criado. ### PUT /api/instances/{id}/catalog/products/{productId} Atualiza um produto. Mesmos campos do create (sem `originCountryCode`, imutável depois de criado), e as imagens são *totalmente substituídas*, não mescladas — reenvie toda imagem que quer manter. Resposta: o objeto do produto atualizado. ### DELETE /api/instances/{id}/catalog/products Exclui um ou mais produtos por id. ```json { "productIds": ["123456789"] } ``` Resposta: `{ "deleted": 1 }` (quantidade de fato confirmada como excluída pelo WhatsApp). `502 PRODUCT_DELETE_FAILED` se o WhatsApp não confirmou nenhuma delas. ## Webhooks ### POST /api/instances/{id}/webhooks Cria uma assinatura de webhook pra uma instância. ```json { "url": "https://yoursite.com/webhook", "events": ["messages.upsert", "connection.update"], "secret": "optional-hmac-secret", "active": true } ``` `url` obrigatório, precisa ser uma URL `http(s)://` válida, e **precisa resolver pra um IP público** — veja a proteção contra SSRF abaixo (rejeitado com `400` senão). `events` obrigatório, mínimo 1, cada um dos 9 nomes de evento listados abaixo. `secret` opcional — se definido, as entregas são assinadas com HMAC (veja abaixo). `active` opcional, padrão `true`. Resposta `201`: o objeto `Webhook` criado: ```json { "id": "wh_...", "instanceId": "...", "url": "https://yoursite.com/webhook", "events": ["messages.upsert", "connection.update"], "active": true, "secret": "optional-hmac-secret" } ``` ```bash curl -X POST "$BASE/api/instances/$INSTANCE_ID/webhooks" \ -H "Authorization: Bearer $INSTANCE_KEY" -H "Content-Type: application/json" \ -d '{"url":"https://yoursite.com/webhook","events":["messages.upsert","connection.update"],"secret":"opcional"}' ``` ### GET /api/instances/{id}/webhooks Lista todos os webhooks da instância. Resposta: `{ "webhooks": [Webhook, ...] }`. ### PUT /api/instances/{id}/webhooks/{webhookId} Atualização parcial — qualquer subconjunto de `url`, `events`, `secret`, `active`. Se `url` estiver presente, passa pela mesma checagem de SSRF. Resposta: o objeto `Webhook` atualizado. `404 NOT_FOUND` se o webhook não pertencer a esta instância. ### DELETE /api/instances/{id}/webhooks/{webhookId} Resposta: `{ "removed": true }`. `404 NOT_FOUND` se não encontrado. ### GET /api/instances/{id}/webhooks/{webhookId}/logs Últimas 100 tentativas de entrega de um webhook, mais recente primeiro. ```json { "logs": [ { "id": "123", "webhookId": "wh_...", "eventType": "messages.upsert", "payload": { }, "statusCode": 200, "attempt": 1, "success": true, "error": null, "createdAt": "..." } ] } ``` Nota: `id` é uma chave primária `BigInt`, serializada como string JSON. ### POST /api/instances/{id}/webhooks/logs/{logId}/resend Reenvia um evento já registrado (mesmo payload, requisição HTTP nova, registrada como uma nova linha de tentativa). Resposta: `{ "resent": true }`. ## Envelope do payload do webhook Toda entrega de webhook é um `POST` HTTP pra `url` configurada, `Content-Type: application/json`, com este envelope: ```json { "instance_id": "clx1a2b3c...", "event": "messages.upsert", "timestamp": 1751370000000, "data": { } } ``` - `instance_id`: a instância que gerou o evento. - `event`: um dos 9 nomes de evento abaixo. - `timestamp`: milissegundos desde o epoch, hora do evento no servidor. - `data`: payload específico do evento, documentado por evento abaixo. Nota: os formatos de `data` abaixo refletem os objetos de evento do Baileys v7; a API não os altera exceto pela normalização de JID (todo JID dentro de `data` está em formato de número de telefone, nunca `@lid` — veja "Garantia de formato do JID"). ### Evento: qr.updated Disparado quando um novo QR code está disponível pra pareamento. ```json { "instance_id": "clx1a2b3c...", "event": "qr.updated", "timestamp": 1751370000000, "data": { "qr": "data:image/png;base64,iVBORw0KGgo..." } } ``` ### Evento: connection.update Disparado nas transições de estado da conexão (connecting, open, close, reconnecting). ```json { "instance_id": "clx1a2b3c...", "event": "connection.update", "timestamp": 1751370000000, "data": { "status": "connected", "phoneNumber": "5511999999999", "connectedAt": "2026-07-01T12:00:00.000Z" } } ``` `status` é um de `connecting`, `connected`, `disconnected`, `qr_pending` — mesmo enum de `GET /api/instances/{id}/status`. ### Evento: pairing_code.updated Disparado quando um novo código de pareamento está disponível (o fluxo `POST /api/instances/{id}/connect/pairing-code` — a alternativa a escanear um QR code). ```json { "instance_id": "clx1a2b3c...", "event": "pairing_code.updated", "timestamp": 1751370000000, "data": { "code": "ABCD1234", "phoneNumber": "5511999998888" } } ``` ### Evento: messages.upsert Disparado pra mensagens novas/recebidas **e** pra mensagens que esta API envia (`key.fromMe: true`) ou que foram enviadas de outro aparelho conectado à mesma conta do WhatsApp — toda mensagem que passa por esta instância aparece aqui, não só as recebidas. ```json { "instance_id": "clx1a2b3c...", "event": "messages.upsert", "timestamp": 1751370000000, "data": { "type": "notify", "messages": [ { "key": { "remoteJid": "5511999999999@s.whatsapp.net", "fromMe": false, "id": "3EB0C767D26A1D8F" }, "messageTimestamp": 1751370000, "pushName": "João", "message": { "conversation": "Olá, tudo bem?" } } ] } } ``` `message` segue o formato `proto.IMessage` do Baileys e varia por tipo (`conversation` pra texto puro, `imageMessage`, `videoMessage`, `audioMessage`, `documentMessage`, `locationMessage`, etc). `remoteJid` (e qualquer campo `participant`, em mensagens de grupo) está sempre em formato de número de telefone. **Uma mensagem que @menciona alguém traz `mentions` no nível raiz** — array de números de telefone de quem foi marcado, nunca `@lid`/id cru (mesma garantia de formato de JID de todo o resto da API, ver "Garantia de formato do JID" abaixo). O mesmo array já vem reescrito dentro de `message.extendedTextMessage.contextInfo.mentionedJid` (ou do campo `contextInfo` equivalente pros outros tipos de mensagem), caso você leia o proto bruto em vez do campo de conveniência. **Uma mensagem encaminhada traz `isForwarded: true` no nível raiz**, e `forwardingScore` com quantas vezes já foi encaminhada ao longo da cadeia. O WhatsApp mostra "Encaminhada" a partir de 1 encaminhamento e "Encaminhada muitas vezes" a partir de ~5, mas essa API não aplica essa distinção — devolve o número cru de `contextInfo.forwardingScore`, pra você decidir o próprio limiar. Os dois campos ficam ausentes (não `false`/`0`) quando a mensagem não foi encaminhada. **Mensagens de mídia sempre trazem um `mediaUrl` pronto pra usar.** Ignore os campos criptografados dentro de `message.imageMessage`/`videoMessage`/`audioMessage`/`documentMessage` (`url`, `mediaKey`, `fileEncSha256`, etc) — esses apontam pro CDN do WhatsApp e exigem descriptografar com a chave da mensagem, o que você não precisa fazer. Em vez disso, toda mensagem em `messages[]` que tem conteúdo de mídia também tem um campo `mediaUrl` no nível raiz: a API já baixou e descriptografou o arquivo e salvou localmente, então `mediaUrl` é uma URL HTTP(S) simples sob `/media/*` que você pode acessar com `GET` direto, sem auth, sem descriptografia. Isso vale independente da direção — recebida, enviada por esta API, ou enviada de outro aparelho conectado: ```json { "key": { "remoteJid": "5511999999999@s.whatsapp.net", "fromMe": false, "id": "3EB0C767D26A1D8G" }, "messageTimestamp": 1751370000, "message": { "imageMessage": { "caption": "look at this", "mimetype": "image/jpeg" } }, "mediaUrl": "https://api-wpp.example.com/media/1751370000000-9b1e2c-c3.jpg" } ``` Se o arquivo não pôde ser baixado de primeira (ex. CDN expirada, dispositivo do remetente offline), `mediaUrl` simplesmente não aparece nessa mensagem — sempre confira se existe, não assuma que ele está lá só porque o tipo da mensagem é um tipo de mídia. Nesse caso a API continua tentando em segundo plano (com backoff, por até várias horas); se conseguir, dispara o evento `media.updated` (veja abaixo) avisando que a mídia ficou disponível. ### Evento: messaging-history.set Disparado por bloco durante uma sincronização de histórico — a sincronização completa inicial no pareamento (só significativa quando a instância tem `syncFullHistory: true`) ou uma sincronização de grupo sob demanda (`POST /api/instances/{id}/groups/{groupJid}/sync`). O payload traz **contagens, não as mensagens** (um bloco pode ter milhares de mensagens); as mensagens em si são persistidas e consultáveis via `GET /api/instances/{id}/chats/{chatJid}/messages`. ```json { "instance_id": "clx1a2b3c...", "event": "messaging-history.set", "timestamp": 1751370000000, "data": { "messages": 120, "persisted": 118, "progress": 45, "syncType": "ON_DEMAND", "isLatest": false } } ``` ### Evento: media.updated Disparado quando uma mídia recebida que falhou no download original (veja a nota sobre `mediaUrl` acima) finalmente foi baixada, por uma retentativa automática em segundo plano. `data.key` identifica a mesma mensagem do `messages.upsert` original. ```json { "instance_id": "clx1a2b3c...", "event": "media.updated", "timestamp": 1751370000000, "data": { "key": { "remoteJid": "5511999999999@s.whatsapp.net", "id": "3EB0C767D26A1D8G", "fromMe": false }, "mediaUrl": "https://api-wpp.example.com/media/1751370000000-9b1e2c-c3.jpg" } } ``` ### Evento: messages.update Disparado pra mudanças de status em mensagens existentes (confirmação de entrega/leitura, edições, etc). ```json { "instance_id": "clx1a2b3c...", "event": "messages.update", "timestamp": 1751370000000, "data": { "key": { "remoteJid": "5511999999999@s.whatsapp.net", "id": "3EB0C767D26A1D8F", "fromMe": true }, "update": { "status": 3 } } } ``` `update.status` segue o enum numérico `WAMessageStatus` do Baileys (0 erro, 1 pendente, 2 confirmado pelo servidor, 3 confirmado na entrega, 4 lida, 5 reproduzida). ### Evento: messages.delete Disparado quando uma mensagem é apagada (por esta API, ou remotamente pra todos). ```json { "instance_id": "clx1a2b3c...", "event": "messages.delete", "timestamp": 1751370000000, "data": { "keys": [ { "remoteJid": "5511999999999@s.whatsapp.net", "id": "3EB0C767D26A1D8F", "fromMe": true } ] } } ``` ### Evento: presence.update Disparado quando a presença de um contato assinado muda (veja `POST /api/instances/{id}/presence/subscribe`). ```json { "instance_id": "clx1a2b3c...", "event": "presence.update", "timestamp": 1751370000000, "data": { "id": "5511999999999@s.whatsapp.net", "presences": { "5511999999999@s.whatsapp.net": { "lastKnownPresence": "composing" } } } } ``` ### Evento: group.update Disparado quando os metadados do grupo mudam (nome, descrição, participantes, configurações). ```json { "instance_id": "clx1a2b3c...", "event": "group.update", "timestamp": 1751370000000, "data": { "id": "123456789-1600000000@g.us", "subject": "Nome novo" } } ``` Só os campos alterados são incluídos (`GroupMetadata` parcial); os JIDs dentro estão em formato de número de telefone. ## Entrega de webhook: retentativas e backoff Até 3 tentativas de entrega por evento, por webhook: imediata, depois +2s, depois +4s (backoff exponencial, base 2s). Uma entrega "tem sucesso" em qualquer resposta HTTP 2xx; qualquer outra coisa (status não-2xx, timeout, erro de conexão) conta como tentativa falha. O timeout por tentativa é configurável no servidor (`WEBHOOK_REQUEST_TIMEOUT_MS`, padrão 10000ms). Se as 3 tentativas falharem, o evento não é retentado mais automaticamente — mas toda tentativa (sucesso ou falha) fica registrada no log de entrega, recuperável via `GET /api/instances/{id}/webhooks/{webhookId}/logs`, e qualquer tentativa registrada pode ser reenviada manualmente via `POST /api/instances/{id}/webhooks/logs/{logId}/resend`. ## Verificação de assinatura HMAC de webhook Se um webhook tem um `secret` configurado, toda entrega inclui este header: ``` X-Webhook-Signature: sha256= ``` Verifique recalculando o HMAC sobre os **bytes exatos e crus do corpo da requisição** (não uma versão reserializada/reparseada — espaçamento e ordem das chaves precisam bater exatamente), usando o mesmo secret, e comparando em tempo constante. Exemplo em Node.js: ```js import { createHmac, timingSafeEqual } from 'node:crypto'; import express from 'express'; const app = express(); app.use(express.raw({ type: 'application/json' })); // mantém o corpo cru, sem parse automático app.post('/webhook', (req, res) => { const signatureHeader = req.headers['x-webhook-signature']; // "sha256=" const secret = process.env.WEBHOOK_SECRET; const expected = 'sha256=' + createHmac('sha256', secret).update(req.body).digest('hex'); const sigBuf = Buffer.from(signatureHeader || ''); const expBuf = Buffer.from(expected); const valid = sigBuf.length === expBuf.length && timingSafeEqual(sigBuf, expBuf); if (!valid) return res.status(401).send('assinatura inválida'); const event = JSON.parse(req.body); // ... trate event.event / event.data res.status(200).send('ok'); }); ``` Se nenhum `secret` foi configurado pro webhook, nenhum header de assinatura é enviado — não exija um nesse caso. ## Garantia de formato do JID (leia isto antes de escrever qualquer código que guarde ou compare JIDs) **Todo JID que esta API aceita, retorna, ou envia num webhook está no formato de número de telefone: `5511999999999@s.whatsapp.net` (ou `-@g.us` pra grupos). Esta API nunca entrega um JID em formato LID (`@lid`), em lugar nenhum — nem em respostas HTTP, nem em payloads de webhook.** Contexto: o protocolo do WhatsApp (via Baileys v7) está migrando identificadores de números de telefone pra valores opacos de "LID" (linked ID) (`123456789@lid`) em vários campos internos (dono/autor de grupo, IDs de participante, chaves de mensagem em alguns casos). Esta API normaliza tudo isso de volta pro formato de número de telefone antes do dado sair pela fronteira da API ou do webhook — usando os próprios campos complementares `*Pn` do Baileys quando disponíveis (sem round-trip de rede extra), com fallback pra uma consulta ao armazenamento de mapeamento de LID quando não. Respostas de lista/detalhe de grupo e de atualização de participante passam por essa normalização explicitamente; nada que você recebe desta API deveria terminar em `@lid`. Isso também vale no sentido contrário: **enviar pra um JID em formato `@lid` é rejeitado com `400 BAD_REQUEST`** (mensagem: `"" is a LID-format JID, which this API does not send to. Use the phone number instead...`). Se você tem um LID vindo de outra fonte, resolva-o pra um número de telefone antes de chamar esta API — esta API não faz essa resolução na entrada, só na saída. Implicação prática: ao montar `to` pra um endpoint de envio, ou guardar/comparar JIDs de payloads de webhook, sempre assuma `@s.whatsapp.net` sem nenhum outro prefixo. Você pode extrair o número de telefone com segurança usando `jid.split('@')[0]` em qualquer JID que esta API te dá. Você não precisa (e não deve) implementar sua própria resolução de LID. Formatos de entrada aceitos em qualquer campo que recebe um JID/número (`to`, `jid`, `participants[]`, parâmetro de rota `groupJid`): um número de telefone puro (`"5511999999999"`, caracteres não numéricos são removidos, então `"+55 11 99999-9999"` também funciona), ou um JID já formado (`"5511999999999@s.whatsapp.net"`). Nunca um valor `@lid`. ## Formato de erro Todo erro é um JSON com este formato, e o código de status HTTP bate com o erro: ```json { "error": { "code": "INSTANCE_NOT_CONNECTED", "message": "Instance is not connected. Call /connect first.", "details": {} } } ``` `details` só aparece em erros de validação (veja abaixo) e normalmente está ausente. Valores de `code` conhecidos e seus status: | status | code | quando | |---|---|---| | 400 | `BAD_REQUEST` | entrada inválida genérica | | 400 | `VALIDATION_ERROR` | corpo da requisição falhou na validação do schema (Zod); `details` traz os erros por campo (formato do `zod.flatten()`: `{ formErrors: string[], fieldErrors: { [campo]: string[] } }`) | | 401 | `UNAUTHORIZED` | API key ausente/inválida, ou instance key usada contra o ID errado | | 404 | `NOT_FOUND` | instância/webhook/rota não encontrada | | 409 | `INSTANCE_NOT_CONNECTED` | tentou enviar/agir numa instância que não está conectada — chame `/connect` primeiro | | 409 | `DELETE_WINDOW_EXPIRED` | tentou apagar-para-todos uma mensagem além da janela de ~2,5 dias do WhatsApp | | 409 | `EDIT_WINDOW_EXPIRED` | tentou editar uma mensagem além da janela de ~15 minutos do WhatsApp | | 429 | `RATE_LIMITED` | requisições demais pra esta instância na janela atual | | 500 | `FFMPEG_UNAVAILABLE` | servidor sem ffmpeg pra conversão de áudio | | 500 | `AUDIO_CONVERSION_FAILED` | áudio enviado não é decodificável | | 500 | `INTERNAL_ERROR` | erro não tratado no servidor | | 502 | `INVITE_CODE_UNAVAILABLE` | não é admin do grupo | | 502 | `GROUP_JOIN_FAILED` | código de convite inválido/expirado/revogado | | 502 | `PRODUCT_DELETE_FAILED` | WhatsApp não confirmou nenhuma exclusão de produto | | 503 | `ENCRYPTION_NOT_CONFIGURED` | `API_KEY_ENCRYPTION_SECRET` não configurado no servidor | Rotas desconhecidas sob `/api/*` retornam `404` com o mesmo formato de erro JSON (`code: "NOT_FOUND"`). ## Limite de requisições Endpoints de envio/ação (envio de mensagem, adicionar/remover participante de grupo, criar grupo) são limitados **por instância**, não por API key ou por IP de quem chama. Padrão: `RATE_LIMIT_POINTS` requisições por `RATE_LIMIT_DURATION_SECONDS` de janela (configurado no servidor; comumente 20 requisições / 1 segundo — um teto deliberadamente generoso pra pegar rajadas/bugs, não pra travar o uso normal — mas trate como configurável por deployment, não uma constante documentada). Ultrapassar isso retorna `429 RATE_LIMITED`. O limite é baseado em Redis (compartilhado entre requisições do processo) com fallback automático em memória se o Redis estiver indisponível (o limite continua valendo, mas só dentro de um processo, não entre vários — irrelevante pra este deployment já que ele sempre roda como um único processo). ## Proteção contra SSRF em URLs de webhook `POST` e `PUT` em `/api/instances/{id}/webhooks[/...]` resolvem o hostname da URL via DNS e rejeitam (`400 BAD_REQUEST`) se ele ou qualquer endereço resolvido cair numa faixa privada/interna/reservada, mesmo que o hostname em si pareça público (protegido contra rebinding — checa o IP resolvido, não só a string). Também rejeita `localhost` de cara e qualquer esquema que não seja `http(s)`. Faixas IPv4 bloqueadas: ``` 127.0.0.0/8 loopback 10.0.0.0/8 privada 172.16.0.0/12 privada 192.168.0.0/16 privada 169.254.0.0/16 link-local (inclui endpoints de metadados de nuvem, ex. 169.254.169.254) 0.0.0.0/8 100.64.0.0/10 carrier-grade NAT 224.0.0.0/4 multicast 240.0.0.0/4 reservada ``` IPv6 bloqueados: `::1` (loopback), `::` (não especificado), `fe80::/10` (link-local), `fc00::/7` (unique local), e qualquer endereço IPv6 mapeado de IPv4 (`::ffff:a.b.c.d`) cujo IPv4 embutido caia nas faixas acima. Essa checagem roda uma vez, na criação/atualização do webhook (resolução de DNS naquele momento) — não é refeita a cada entrega. Uma URL de webhook que depois passa a resolver pra um IP privado (DNS rebinding depois de criada) não fica retroativamente bloqueada só por essa checagem. ## Health check `GET /health` — sem auth, não fica sob `/api`. Verifica o MySQL (obrigatório) e o Redis (best-effort). ```json { "status": "ok", "uptimeSeconds": 12345, "mysql": "ok", "redis": "ok" } ``` Retorna `503` com `"status": "degraded"` só se o MySQL estiver inacessível; o Redis fora do ar é reportado (`"redis": "down (system still functional, degraded performance)"`) mas não afeta o status HTTP — a API continua funcionando, só mais devagar, sem o Redis. ## Mídia Toda mensagem de mídia (imagem/áudio/vídeo/documento) que aparece num evento `messages.upsert` — recebida de alguém, enviada por esta API, ou enviada de outro aparelho conectado — é baixada, descriptografada, e guardada temporariamente no servidor, exposta em `/media/` (não sob `/api`) como o campo `mediaUrl` da mensagem (veja o evento `messages.upsert` acima). As mesmas URLs também aparecem nas linhas de histórico de `GET /api/instances/{id}/chats/{chatJid}/messages`. Fotos de perfil (`GET /api/instances/{id}/profile-picture/{jid}`) são guardadas do mesmo jeito — baixadas uma vez e expostas como URL `/media/*`, nunca devolvidas como um link cru do CDN do WhatsApp. Esse armazenamento local é limpo automaticamente depois de uma janela de retenção configurada no servidor (padrão: 72 horas); não trate URLs `/media/*` como permanentes.