Lobato API — Documentação

Lobato API

API REST multi-instância para WhatsApp, construída sobre o Baileys v7. Referência completa de todos os endpoints, do sistema de webhooks e da garantia de normalização de JID.

Prefere texto puro? Veja /llms.txt — mesmo conteúdo, Markdown denso, sem HTML.

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. 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 / — separado da API.

Endpoints de documentação

Estas cinco rotas servem a documentação em si. Nenhuma exige autenticação, e nenhuma fica sob /api.

GET /docs

GET /docs sem auth

Referência da API em HTML, para leitura humana (esta página). Sem auth.

GET /llms.txt

GET /llms.txt sem auth

A mesma referência em Markdown puro, servida como text/plain. Sem auth.

GET /docs.md

GET /docs.md sem auth

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

GET /docs/explorer sem auth

Explorador de API interativo (Redoc), renderizado no navegador a partir do spec OpenAPI abaixo — navegue pelo schema completo de request/response de cada endpoint numa única página com busca. Sem auth.

GET /openapi.yaml

GET /openapi.yaml sem auth

O spec OpenAPI 3 puro que descreve esta API — a 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 <key>
apikey: <key>
Admin key Ciclo de vida de instâncias: criar / listar / excluir instância, gerar nova chave, listar donos. Também vale em qualquer endpoint de instância específica.
Instance key Retornada na criação e em toda regeneração (campo apiKey) — guardada no servidor como hash irreversível, então essa resposta normalmente é o único lugar onde você vê a chave em texto puro, a menos que API_KEY_ENCRYPTION_SECRET esteja configurado no servidor, caso em que GET /instances/{id}/api-key também pode retorná-la depois. Exigida em todo endpoint que opera uma instância específica. A admin key também funciona aqui.

Um 401 UNAUTHORIZED é retornado pra chave ausente/malformada/errada, e quando uma instance key é usada contra o ID de outra instância (cada chave é vinculada a uma única instância).

Ciclo de vida da instância

Criar uma instância

POST /api/instances admin

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, então não conte com isso.

{
  "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 já existir um dono com esse name exato, ele é reaproveitado; senão, é criado. syncFullHistory opcional (padrão false) — veja Atualizar configurações.

Resposta 201:

{
  "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"
}
curl -X POST "$BASE/api/instances" \
  -H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{"name":"vendas-01","owner":{"name":"Cliente Acme"}}'

Listar instâncias

GET /api/instances admin

Resposta: { "instances": [Instance, ...] } (mesmo formato do create, sem o apiKey).

Paginação é opcional: omita limit e todas as instâncias voltam, mais recente primeiro. Passe limit (máx. 2000) e before (do nextCursor da página anterior) pra paginar.

Listar donos

GET /api/owners admin

Resposta: { "owners": [{ "id", "name", "email" }, ...] }. Mesma paginação opcional, ordenado alfabeticamente por nome.

Consultar instância

GET /api/instances/{id} instância ou admin

Retorna o objeto Instance (sem apiKey). 404 NOT_FOUND se não existir.

Excluir instância

DELETE /api/instances/{id} admin

Remove a instância por completo: sessão + todas as linhas no banco (mensagens, contatos, grupos, webhooks). Irreversível. Resposta: { "removed": true }.

Conectar / obter QR code

GET /api/instances/{id}/connect instância

Inicia a conexão (ou reconecta com uma sessão ainda válida). Espera até ~15s no servidor por um QR ou pela conexão completa antes de responder — chame de novo se ainda estiver connecting.

{ "status": "connected", "phoneNumber": "5511999999999" }
{ "status": "qr_pending", "qr": "data:image/png;base64,..." }
{ "status": "connecting" }
curl "$BASE/api/instances/$INSTANCE_ID/connect" -H "Authorization: Bearer $INSTANCE_KEY"

Reconectar

POST /api/instances/{id}/reconnect instância

Destrava uma instância (ex.: presa em connecting) derrubando o socket/timer atual e começando uma tentativa nova, sem excluir a instância. Mesmos formatos de resposta e tempo de espera do Conectar acima.

{ "wipeSession": false }

wipeSession: true também apaga a sessão salva, forçando um pareamento por QR totalmente novo em vez de retomar o existente — use quando suspeitar que a própria sessão está corrompida.

curl -X POST "$BASE/api/instances/$INSTANCE_ID/reconnect" \
  -H "Authorization: Bearer $INSTANCE_KEY" -H "Content-Type: application/json" \
  -d '{"wipeSession": true}'

Conectar com código de pareamento

POST /api/instances/{id}/connect/pairing-code instância

Alternativa ao pareamento por QR: conecta pelo código de 8 caracteres mostrado no app em Aparelhos conectados → Conectar com número de telefone. Sempre apaga qualquer sessão existente antes (só faz sentido pra um aparelho ainda não registrado). Espera até ~20s.

{ "phoneNumber": "5511999998888" }

phoneNumber: só dígitos, 8 a 15, com código do país incluído, sem +, espaços ou pontuação.

{ "status": "qr_pending", "pairingCode": "ABCD1234", "phoneNumber": "5511999998888" }
curl -X POST "$BASE/api/instances/$INSTANCE_ID/connect/pairing-code" \
  -H "Authorization: Bearer $INSTANCE_KEY" -H "Content-Type: application/json" \
  -d '{"phoneNumber":"5511999998888"}'

Status da conexão

GET /api/instances/{id}/status instância

Lido do Redis, com fallback pro MySQL se o Redis estiver fora.

{ "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.

Atualizar configurações

PUT /api/instances/{id}/settings instância

Todo campo é opcional — envie só o que você está mudando.

{
  "syncFullHistory": true,
  "autoMarkRead": true,
  "metaAdsEnabled": true,
  "metaAdsToken": "EAAG..."
}

syncFullHistory: se um novo pareamento pede o histórico completo de mensagens em vez de só as conversas recentes. Vale a partir do próximo pareamento por QR/código.

autoMarkRead: marca toda mensagem recebida como lida (tique azul) assim que chega. Vale imediatamente.

metaAdsEnabled / metaAdsToken: enriquece mensagens de anúncio Click-to-WhatsApp via Marketing API da Meta. metaAdsToken é write-only (nunca é retornado — a resposta só informa metaAdsTokenConfigured); envie "" pra limpar. Exige API_KEY_ENCRYPTION_SECRET configurado no servidor, senão 503 ENCRYPTION_NOT_CONFIGURED.

Resposta: o objeto da instância atualizado.

Logout

POST /api/instances/{id}/logout instância

Desconecta a sessão mas mantém o registro da instância (e a API key) — chame /connect de novo depois pra parear novamente. Resposta: { "status": "disconnected" }.

Gerar nova API key

POST /api/instances/{id}/regenerate-key admin

Gera uma nova API key pra instância; a antiga para de funcionar na hora. Resposta: { "apiKey": "nova-chave" }.

Ver API key atual

GET /api/instances/{id}/api-key admin

Retorna a API key atual da instância — não gera uma nova.

{ "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.

Verificar número(s)

POST /api/instances/{id}/check-number instância

Verifica se números de telefone estão registrados no WhatsApp antes de enviar mensagem.

{ "numbers": ["5511999999999", "5511888888888"] }
{ "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. jid é null quando exists é false.

curl -X POST "$BASE/api/instances/$INSTANCE_ID/check-number" \
  -H "Authorization: Bearer $INSTANCE_KEY" -H "Content-Type: application/json" \
  -d '{"numbers":["5511999999999"]}'

Definir / remover a própria foto de perfil

PUT /api/instances/{id}/profile-picture instância
DELETE /api/instances/{id}/profile-picture instância

Define ou remove a foto de perfil do WhatsApp desta instância. PUT aceita o 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.

Resposta do PUT: { "updated": true }. Resposta do DELETE: { "removed": true }. Ambos retornam 409 INSTANCE_NOT_CONNECTED se não estiver conectada.

curl -X PUT "$BASE/api/instances/$INSTANCE_ID/profile-picture" \
  -H "Authorization: Bearer $INSTANCE_KEY" \
  -F "file=@/path/avatar.jpg"

Definir o próprio nome de exibição

PUT /api/instances/{id}/profile-name instância
{ "name": "Acme Support" }

name obrigatório, não vazio. Resposta: { "updated": true }.

Definir o próprio status/recado

PUT /api/instances/{id}/profile-status instância
{ "status": "Atendimento 9h-18h" }

status obrigatório, mas "" é válido e limpa o campo. Resposta: { "updated": true }.

Mensagens

Todo endpoint de envio exige que a instância esteja connected, senão 409 INSTANCE_NOT_CONNECTED. Limitado por instância (veja Limite de requisições). to aceita um número de telefone puro ou um JID completo — nunca @lid.
Fila de retentativa automática: os endpoints de envio nunca simplesmente falham uma mensagem por um erro passageiro — ela é enfileirada no Redis e reenviada com backoff exponencial, respondendo 202 em vez de erro:
{ "status": "queued", "messageId": "3EB0...", "reason": "Send failed (...); queued for automatic retry." }
Consulte o histórico de conversa por esse messageId pra ver quando ela chega.
Respondendo (citando) uma mensagem: texto, imagem, vídeo, áudio, documento e localização aceitam um quotedMessageId opcional — o id da mensagem do WhatsApp de uma mensagem anterior na mesma conversa.
{ "to": "5511999999999", "text": "Chega em 10 min", "quotedMessageId": "3EB0..." }
Enviar pra um grupo usa exatamente os mesmos endpoints. Coloque o JID do grupo (termina em @g.us, veja Grupos) em to no lugar de um número de telefone:
{ "to": "123456789-1600000000@g.us", "text": "Olá, grupo!" }
Marcar (@mencionar) alguém num grupo: campo mentions no envio de texto — um array de números de telefone (nunca @lid/jid cru, a API resolve isso internamente). Escreva @<número> no próprio text pra cada entrada de mentions, alinhando visualmente:
{ "to": "123456789-1600000000@g.us", "text": "Bom dia @5511999999999!", "mentions": ["5511999999999"] }
Só vale quando to é um grupo (400 se não for), e todo número em mentions precisa ser participante do grupo (400 se não for). Mensagens recebidas que te marcam também trazem mentions com número de telefone, nunca id/jid cru — ver Webhooks.

Enviar texto

POST /api/instances/{id}/messages/text instância
{ "to": "5511999999999", "text": "Olá!" }

to/text são obrigatórios; mentions é opcional (ver callout acima). Resposta 200:

{ "messageId": "3EB0...", "key": { "remoteJid": "5511999999999@s.whatsapp.net", "id": "3EB0...", "fromMe": true } }
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á!"}'

Enviar mídia: imagem / vídeo / áudio / documento

POST /api/instances/{id}/messages/image
POST /api/instances/{id}/messages/video
POST /api/instances/{id}/messages/audio
POST /api/instances/{id}/messages/document instância, os 4

Os quatro aceitam ou multipart/form-data (parte file + campos) ou um corpo JSON com um campo url. Mesmo formato de resposta do texto.

  • image: file ou url, caption opcional.
  • video: file ou url, caption opcional, ptv opcional (nota em vídeo).
  • audio: file ou url. Sempre enviado como nota de voz — qualquer formato de entrada é aceito (mp3, wav, m4a, ogg...), convertido pra OGG/Opus. Falha com 500 FFMPEG_UNAVAILABLE se o servidor não tiver ffmpeg, ou 500 AUDIO_CONVERSION_FAILED se o upload não for decodificável como áudio.
  • document: file ou url, obrigatório fileName (a menos que a parte file já traga um), mimetype opcional, caption opcional.
# 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 -- qualquer formato funciona; 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"}'

Enviar mais de uma parte file manda cada uma como mensagem separada (espaçadas ~1,2s). A resposta vira { "results": [...] }, uma entrada por arquivo.

Enviar localização

POST /api/instances/{id}/messages/location instância
{ "to": "5511999999999", "latitude": -23.55, "longitude": -46.63, "name": "São Paulo" }

to, latitude, longitude obrigatórios; name e quotedMessageId opcionais. Resposta: { "messageId", "key" }.

Enviar botões

POST /api/instances/{id}/messages/buttons instância

Envia uma mensagem interativa com 1 a 3 botões.

{
  "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" },
    { "type": "copy", "displayText": "Copiar cupom", "copyCode": "PROMO10" }
  ]
}

O type de cada botão 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. Máximo de 3 botões. Não aceita quotedMessageId. Resposta: { "messageId", "key" }.

Enviar produto do catálogo

POST /api/instances/{id}/messages/product instância

Envia um "cartão" de produto numa conversa — diferente de /catalog/products (que cria/edita/remove produtos no catálogo em si). A imagem pode vir como upload multipart ou pelo campo imageUrl (JSON); só aceita uma imagem.

{
  "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, propositalmente um campo separado pra nunca se confundir com url. productId (id do produto no catálogo do WhatsApp) e retailerId (id próprio do lojista) são opcionais e independentes — nenhum dos dois é obrigatório pra mandar o cartão. businessOwnerJid é opcional, usa o jid da própria instância por padrão. body/footer aparecem como texto abaixo do cartão. Resposta: { "messageId", "key" }.

Editar uma mensagem enviada

PUT /api/instances/{id}/messages/{messageId} instância
{ "remoteJid": "5511999999999@s.whatsapp.net", "text": "preço correto: R$ 79,90" }

remoteJid e text obrigatórios. Só funciona numa mensagem que esta instância mesma enviou e ainda tem registrada — senão, 404.

Bloqueado com 409 EDIT_WINDOW_EXPIRED se for mais antiga que a janela de edição do WhatsApp (~15 minutos).

Resposta: { "edited": true }.

Apagar mensagem (para todos)

DELETE /api/instances/{id}/messages/{messageId} instância

Exige o parâmetro de query remoteJid. Parâmetro fromMe opcional ("true"/"false", padrão "true").

Bloqueado com 409 DELETE_WINDOW_EXPIRED se for mais antiga que a janela de apagar-para-todos do WhatsApp (~2,5 dias).

Resposta: { "deleted": true }.

curl -X DELETE "$BASE/api/instances/$INSTANCE_ID/messages/$MSG_ID?remoteJid=5511999999999@s.whatsapp.net" \
  -H "Authorization: Bearer $INSTANCE_KEY"

Reagir a uma mensagem

POST /api/instances/{id}/messages/{messageId}/react instância
{ "remoteJid": "5511999999999@s.whatsapp.net", "emoji": "👍", "fromMe": false }

remoteJid e emoji obrigatórios. emoji: "" remove uma reação existente. fromMe opcional, padrão false. Resposta: { "messageId", "key" } (a key da própria reação).

Marcar mensagens como lidas

POST /api/instances/{id}/messages/read instância
{ "keys": [ { "remoteJid": "5511999999999@s.whatsapp.net", "id": "3EB0...", "fromMe": false } ] }

keys obrigatório, mínimo 1. Resposta: { "marked": 1 } (quantidade enviada).

Histórico de mensagens da conversa

GET /api/instances/{id}/chats/{chatJid}/messages instância

Histórico paginado do próprio log desta API (não é uma busca ao vivo no WhatsApp). chatJid aceita um número de telefone puro, um JID de número, ou um JID de grupo.

Query: limit (padrão 50, máx. 100), before (data/hora ISO 8601).

{ "messages": [ /* mais recentes primeiro */ ], "nextCursor": "2026-06-30T09:00:00.000Z" }

nextCursor é null quando não há mais histórico, senão é o createdAt da linha mais antiga — passe como before pra pegar a próxima página.

Importar por telefone

GET /api/instances/{id}/messages/import/{phone} instância

Exportação em massa do histórico completo de um número, do mais antigo pro mais recente. phone aceita um número puro, um JID de número, ou um JID de grupo.

Query: limit (padrão 2000, máx. 5000), since (data/hora ISO 8601).

{ "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 pegar a próxima página.

Presença

Definir a própria presença

POST /api/instances/{id}/presence instância
{ "presence": "composing", "to": "5511999999999" }

presence: um de composing, recording, available, unavailable. to obrigatório para composing/recording. Resposta: { "ok": true }.

Assinar presença de um contato

POST /api/instances/{id}/presence/subscribe instância
{ "jid": "5511999999999" }

As atualizações de presença desse contato passam a chegar pelo evento de webhook presence.update. Resposta: { "subscribed": true }.

Grupos

Criar grupo

POST /api/instances/{id}/groups instância
{ "name": "Time de Vendas", "participants": ["5511999999999", "5511888888888"] }

name obrigatório (1–150 caracteres). participants obrigatório, mínimo 1. Resposta 201: GroupMetadata completo.

Listar grupos

GET /api/instances/{id}/groups instância

Todos os grupos em que esta instância participa. Cache de ~45s no Redis.

{ "groups": [ /* GroupMetadata, ... */ ] }

Listar grupos com mensagens

GET /api/instances/{id}/groups/messages instância

Lista os grupos sincronizados no MySQL e anexa as mensagens persistidas de cada um. Não é uma busca ao vivo no WhatsApp, não exige o socket conectado.

Query: limit (padrão 1000, mín. 1, máx. 1000, mensagens por grupo; messageLimit aceito como apelido), before (ISO 8601). groupLimit/groupBefore limitam separadamente quantos grupos são retornados (opcional, sem padrão).

{
  "groups": [
    {
      "id": "1",
      "groupJid": "123456789-1600000000@g.us",
      "name": "Time de Vendas",
      "syncedAt": "2026-07-01T12:00:00.000Z",
      "messages": [ /* mais recentes primeiro */ ],
      "nextCursor": "2026-06-30T09:00:00.000Z"
    }
  ]
}
curl "$BASE/api/instances/$INSTANCE_ID/groups/messages?groupLimit=50" \
  -H "Authorization: Bearer $INSTANCE_KEY"

Obter mensagens de um grupo

GET /api/instances/{id}/groups/{groupJid}/messages instância

Mesma fonte/semântica do endpoint acima, pra um único grupo. 404 se o grupo não estiver sincronizado. Cada mensagem inclui senderName, senderNumber, senderJid, e mediaUrl quando disponível.

Query: limit (padrão 1000, mín. 1, máx. 1000), before.

{
  "id": "1",
  "groupJid": "123456789-1600000000@g.us",
  "name": "Time de Vendas",
  "messages": [
    {
      "waMessageId": "3EB0...",
      "senderJid": "5511999999999@s.whatsapp.net",
      "senderNumber": "5511999999999",
      "senderName": "Maria Sender",
      "mediaUrl": "https://api.example.com/media/2026/07/file.jpg"
    }
  ],
  "nextCursor": null
}

Sincronização de histórico do grupo sob demanda

POST /api/instances/{id}/groups/{groupJid}/sync instância

Pede ao WhatsApp mensagens mais antigas deste grupo, ancoradas na mensagem mais antiga já persistida. Sem corpo na requisição. 404 se o grupo ainda não estiver sincronizado; 400 se não houver nenhuma mensagem já persistida pra ancorar.

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 — este endpoint só dispara o pedido.

{ "status": "requested", "anchor": { "waMessageId": "3EB0...", "createdAt": "2026-06-30T09:00:00.000Z" } }

Consultar metadados do grupo

GET /api/instances/{id}/groups/{groupJid} instância

Cache de ~60s no Redis. 409 INSTANCE_NOT_CONNECTED se não estiver conectada.

{
  "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 }
  ]
}

Todo campo identificador é um JID em formato de número de telefone, nunca @lid.

Atualizar grupo

PUT /api/instances/{id}/groups/{groupJid} instância
{ "subject": "Nome novo", "description": "Descrição nova", "announcement": true, "locked": false, "ephemeralExpiration": 604800 }

Pelo menos um campo obrigatório. announcement: true = só admins podem enviar mensagens. locked: true = só admins podem editar as informações do grupo. ephemeralExpiration: segundos até a mensagem desaparecer, 0 desativa. Resposta: { "updated": true }.

Definir / remover foto do grupo

PUT /api/instances/{id}/groups/{groupJid}/picture instância
DELETE /api/instances/{id}/groups/{groupJid}/picture instância

Mesmo formato de entrada da foto de perfil da própria instância. Exige que esta instância seja admin do grupo. PUT: { "updated": true }. DELETE: { "removed": true }.

Obter / revogar código de convite

GET /api/instances/{id}/groups/{groupJid}/invite-code instância
POST /api/instances/{id}/groups/{groupJid}/invite-code/revoke instância

GET obtém (ou gera) o código de convite atual. POST .../revoke revoga e gera um novo. Os dois exigem que esta instância seja admin do grupo, senão 502 INVITE_CODE_UNAVAILABLE.

{ "code": "AbCdEfGhIjK", "inviteUrl": "https://chat.whatsapp.com/AbCdEfGhIjK" }

Entrar em um grupo

POST /api/instances/{id}/groups/join instância
{ "code": "https://chat.whatsapp.com/AbCdEfGhIjK" }

code aceita tanto o código puro quanto a URL de convite completa. Resposta: { "groupJid": "..." }. 502 GROUP_JOIN_FAILED se inválido/expirado/revogado.

Adicionar / remover participantes

POST /api/instances/{id}/groups/{groupJid}/participants instância
DELETE /api/instances/{id}/groups/{groupJid}/participants instância
{ "participants": ["5511999999999"] }
{ "result": [ { "jid": "5511999999999@s.whatsapp.net", "status": "200" } ] }

status é o resultado do WhatsApp por participante: "200" ok, "403" não permitido, "408" não está no WhatsApp/timeout, "409" já é participante.

Contatos e conversas

Listar contatos

GET /api/instances/{id}/contacts instância

Cache de ~45s no Redis. Resposta: { "contacts": [ { "jid", "name", "syncedAt", ... } ] }. Paginação opcional (limit, máx. 2000, + before).

Listar conversas

GET /api/instances/{id}/chats instância

Derivado do próprio log de mensagens desta API — uma linha por conversa distinta com mensagem registrada.

{ "chats": [ { "chatJid": "5511999999999@s.whatsapp.net", "name": "Some Contact", "lastMessageAt": "2026-07-01T12:00:00.000Z" } ] }

Obter foto de perfil

GET /api/instances/{id}/profile-picture/{jid} instância

Funciona pra JIDs de usuário e de grupo. Opcional ?type=preview (padrão, miniatura) ou ?type=image (resolução completa). Baixada e espelhada em armazenamento local, então url é sempre um link estável em /media/*.

{ "jid": "5511999999999@s.whatsapp.net", "url": "https://api-wpp.example.com/media/1751370000000-9b1e2c-c3.jpg" }
Sem foto definida: 200 com "url": null, sem reason. Falha na busca (privacidade, timeout, etc): 200 com "url": null e uma string em reason. Número não está no WhatsApp: "url": null, "reason": "This number is not registered on WhatsApp".

Catálogo

Catálogo de produtos do WhatsApp Business. Exige que a conta por trás seja uma conta Business com catálogo habilitado.

Listar produtos

GET /api/instances/{id}/catalog instância

Query: jid (opcional, usa o catálogo da própria instância por padrão), limit (1-100), cursor.

{ "products": [ "..." ], "nextCursor": null }

Criar produto

POST /api/instances/{id}/catalog/products instância

multipart/form-data (uma ou mais partes file) ou JSON (array images de URLs).

Campos: name, description, price (inteiro, menor unidade da moeda), currency (3 letras, ex. "BRL"), retailerId?, url?, isHidden?, originCountryCode? (2 letras).

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 produto criado.

Atualizar produto

PUT /api/instances/{id}/catalog/products/{productId} instância

Mesmos campos do create (sem originCountryCode, imutável). As imagens são totalmente substituídas. Resposta: o produto atualizado.

Excluir produtos

DELETE /api/instances/{id}/catalog/products instância
{ "productIds": ["123456789"] }

Resposta: { "deleted": 1 }. 502 PRODUCT_DELETE_FAILED se o WhatsApp não confirmar nenhuma exclusão.

Webhooks

Criar webhook

POST /api/instances/{id}/webhooks instância
{
  "url": "https://yoursite.com/webhook",
  "events": ["messages.upsert", "connection.update"],
  "secret": "optional-hmac-secret",
  "active": true
}

url obrigatório, http(s):// válido, precisa resolver pra um IP público (veja proteção contra SSRF). events obrigatório, mínimo 1, dentre os 9 nomes abaixo. secret opcional — ativa assinatura HMAC. active opcional, padrão true.

Resposta 201: a linha do webhook criado.

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"}'

Listar webhooks

GET /api/instances/{id}/webhooks instância

Resposta: { "webhooks": [Webhook, ...] }.

Atualizar webhook

PUT /api/instances/{id}/webhooks/{webhookId} instância

Atualização parcial — qualquer subconjunto de url, events, secret, active. Um url novo dispara a checagem de SSRF de novo. 404 se não encontrado.

Excluir webhook

DELETE /api/instances/{id}/webhooks/{webhookId} instância

Resposta: { "removed": true }. 404 se não encontrado.

Logs de entrega

GET /api/instances/{id}/webhooks/{webhookId}/logs instância

Últimas 100 tentativas de entrega, mais recente primeiro.

{ "logs": [ { "id": "123", "webhookId": "wh_...", "eventType": "messages.upsert", "payload": {}, "statusCode": 200, "attempt": 1, "success": true, "error": null, "createdAt": "..." } ] }

id é um BigInt, serializado como string JSON.

Reenviar uma entrega registrada

POST /api/instances/{id}/webhooks/logs/{logId}/resend instância

Reenvia um evento já registrado. Resposta: { "resent": true }.

Envelope do payload do webhook

Toda entrega é um POST HTTP pra url configurada, Content-Type: application/json:

{ "instance_id": "clx1a2b3c...", "event": "messages.upsert", "timestamp": 1751370000000, "data": { } }
Todo JID dentro de data está em formato de número de telefone, nunca @lid (veja Garantia de formato do JID).

Os 9 eventos de webhook

qr.updated

Um novo QR code está disponível pra pareamento.

{ "event": "qr.updated", "data": { "qr": "data:image/png;base64,iVBORw0KGgo..." } }

pairing_code.updated

Um novo código de pareamento está disponível (o fluxo de conexão por código).

{ "event": "pairing_code.updated", "data": { "code": "ABCD1234", "phoneNumber": "5511999998888" } }

connection.update

Transições de estado da conexão (connecting, open, close, reconnecting).

{ "event": "connection.update", "data": { "status": "connected", "phoneNumber": "5511999999999", "connectedAt": "2026-07-01T12:00:00.000Z" } }

messages.upsert

Mensagens novas/recebidas e mensagens que esta API envia ou que chegam de outro aparelho conectado.

{
  "event": "messages.upsert",
  "data": {
    "type": "notify",
    "messages": [
      {
        "key": { "remoteJid": "5511999999999@s.whatsapp.net", "fromMe": false, "id": "3EB0C767D26A1D8F" },
        "messageTimestamp": 1751370000,
        "pushName": "João",
        "message": { "conversation": "Olá, tudo bem?" }
      }
    ]
  }
}
Mensagens de mídia trazem um mediaUrl pronto pra usar, no nível raiz — a API já baixou/descriptografou o arquivo. Ignore os campos criptografados dentro de message.imageMessage/etc; se o download falhou, mediaUrl simplesmente não aparece.
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 do JID de todo o resto da API). O mesmo vale dentro de message.extendedTextMessage.contextInfo.mentionedJid, se você ler o proto bruto.
Uma mensagem encaminhada traz isForwarded: true no nível raiz, e forwardingScore com quantas vezes já foi encaminhada (o WhatsApp mostra "Encaminhada" a partir de 1 e "Encaminhada muitas vezes" a partir de ~5, mas essa distinção não é aplicada aqui — o número cru vem como está). Ambos ausentes quando a mensagem não foi encaminhada.

messages.update

Mudanças de status em mensagens existentes (confirmação de entrega/leitura, edições).

{ "event": "messages.update", "data": { "key": { "remoteJid": "5511999999999@s.whatsapp.net", "id": "3EB0C767D26A1D8F", "fromMe": true }, "update": { "status": 3 } } }

update.status: enum numérico do Baileys (0 erro, 1 pendente, 2 confirmado pelo servidor, 3 confirmado na entrega, 4 lida, 5 reproduzida).

messages.delete

{ "event": "messages.delete", "data": { "keys": [ { "remoteJid": "5511999999999@s.whatsapp.net", "id": "3EB0C767D26A1D8F", "fromMe": true } ] } }

presence.update

A presença de um contato assinado mudou (veja presence/subscribe).

{ "event": "presence.update", "data": { "id": "5511999999999@s.whatsapp.net", "presences": { "5511999999999@s.whatsapp.net": { "lastKnownPresence": "composing" } } } }

group.update

Os metadados do grupo mudaram. Só os campos alterados são incluídos.

{ "event": "group.update", "data": { "id": "123456789-1600000000@g.us", "subject": "Nome novo" } }

messaging-history.set

Disparado por bloco durante uma sincronização de histórico — a sincronização completa inicial no pareamento (syncFullHistory) ou uma sincronização de grupo sob demanda. Só contagens, não as mensagens completas — consulte as mensagens em si pelo histórico de conversa depois de persistidas.

{ "event": "messaging-history.set", "data": { "messages": 120, "persisted": 118, "progress": 45, "syncType": "ON_DEMAND", "isLatest": false } }

media.updated

Mídia recebida que não pôde ser baixada a tempo do webhook original de messages.upsert (CDN expirada, dispositivo do remetente offline, etc.) ficou disponível depois de uma retentativa em segundo plano. Nesse caso, o messages.upsert original sai sem o campo url na mídia — este evento avisa quando ela finalmente foi baixada, pra você atualizar o que já tinha recebido em vez de precisar consultar de novo.

{ "event": "media.updated", "data": { "key": { "remoteJid": "5511999999999@s.whatsapp.net", "id": "3EB0C767D26A1D8F", "fromMe": false }, "mediaUrl": "https://.../media/xxx/167...-uuid.jpg" } }

Retentativa e backoff

Até 3 tentativas de entrega por evento, por webhook: imediata, depois +2s, depois +4s. Uma resposta 2xx conta como sucesso; qualquer outra coisa conta como tentativa falha. Timeout por tentativa: WEBHOOK_REQUEST_TIMEOUT_MS (padrão 10000ms).

Se as 3 falharem, o evento não é retentado mais automaticamente, mas toda tentativa fica registrada no log de entrega e pode ser reenviada manualmente via resend.

Verificação de assinatura HMAC

Se um webhook tiver um secret configurado, toda entrega inclui:

X-Webhook-Signature: sha256=<HMAC-SHA256 em hex do corpo JSON cru>

Verifique recalculando o HMAC sobre os bytes exatos e crus do corpo da requisição, usando o mesmo secret, e comparando em tempo constante.

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=<hex>"
  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);
  res.status(200).send('ok');
});

Se nenhum secret foi configurado pro webhook, nenhum header de assinatura é enviado.

Garantia de formato do JID

Todo JID que esta API aceita, retorna, ou envia num webhook está no formato de número de telefone: 5511999999999@s.whatsapp.net (ou <id>-<ts>@g.us pra grupos). Esta API nunca entrega um JID em formato LID (@lid), em nenhuma resposta ou webhook.

O protocolo do WhatsApp está migrando identificadores para valores opacos de "LID" (via Baileys v7) 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 API ou pelo webhook.

Isso vale nos dois sentidos: enviar para um JID em formato @lid é rejeitado com 400 BAD_REQUEST. 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.

Na prática: você pode extrair o número de telefone com segurança de qualquer JID que esta API te dá com jid.split('@')[0]. Você não precisa (e não deve) implementar sua própria resolução de LID.

Formatos de entrada aceitos em qualquer campo de JID/número (to, jid, participants[], parâmetro de rota groupJid): um número de telefone puro (não-dígitos 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, o status HTTP bate com o erro:

{ "error": { "code": "INSTANCE_NOT_CONNECTED", "message": "Instance is not connected. Call /connect first.", "details": {} } }

details só aparece em erros de validação, normalmente ausente.

StatusCódigoQuando
400BAD_REQUESTentrada inválida genérica
400VALIDATION_ERRORcorpo da requisição falhou na validação do schema (Zod)
401UNAUTHORIZEDAPI key ausente/inválida, ou instância errada
404NOT_FOUNDinstância/webhook/rota não encontrada
409INSTANCE_NOT_CONNECTEDação exige uma instância conectada
409DELETE_WINDOW_EXPIREDapagar-para-todos além da janela de ~2,5 dias do WhatsApp
409EDIT_WINDOW_EXPIREDedição além da janela de ~15 minutos do WhatsApp
429RATE_LIMITEDrequisições demais pra esta instância
500FFMPEG_UNAVAILABLEservidor sem ffmpeg pra conversão de áudio
500AUDIO_CONVERSION_FAILEDáudio enviado não é decodificável
500INTERNAL_ERRORerro não tratado no servidor
502INVITE_CODE_UNAVAILABLEnão é admin do grupo
502GROUP_JOIN_FAILEDcódigo de convite inválido/expirado/revogado
502PRODUCT_DELETE_FAILEDWhatsApp não confirmou nenhuma exclusão de produto
503ENCRYPTION_NOT_CONFIGUREDAPI_KEY_ENCRYPTION_SECRET não configurado no servidor

Rotas desconhecidas sob /api/* retornam 404 com o mesmo formato de erro JSON.

Limite de requisições

Endpoints de envio/ação são limitados por instância, não por API key ou IP do chamador. Padrão: 20 requisições / 1 segundo (configurável no servidor — pensado como um teto generoso pra pegar rajadas/bugs, não pra travar o uso normal). Ultrapassar isso retorna 429 RATE_LIMITED.

Baseado em Redis (compartilhado entre requisições), com fallback automático em memória se o Redis estiver indisponível.

Faixas de IP bloqueadas em URLs de webhook (SSRF)

URLs de webhook são resolvidas por DNS e rejeitadas (400 BAD_REQUEST) se qualquer endereço resolvido cair numa faixa privada/interna/reservada — mesmo que o hostname pareça público (protegido contra rebinding: checa o IP resolvido, não só a string). Também rejeita localhost 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. Um url que passe a resolver pra um IP privado depois de criado (DNS rebinding) não fica retroativamente bloqueado só por essa checagem.

Verificação de saúde

GET /health sem auth

Não fica sob /api. Verifica o MySQL (obrigatório) e o Redis (best-effort).

{ "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.

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/<path> como o mediaUrl da mensagem (veja o evento messages.upsert acima). As mesmas URLs também aparecem nas linhas 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. Baixe/guarde o que precisar manter a longo prazo logo depois de receber.