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.
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
Referência da API em HTML, para leitura humana (esta página). Sem auth.
GET /llms.txt
A mesma referência em Markdown puro, servida 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 spec OpenAPI abaixo — navegue pelo schema completo de request/response de cada endpoint numa única página com busca. Sem auth.
GET /openapi.yaml
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>
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
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
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
Resposta: { "owners": [{ "id", "name", "email" }, ...] }. Mesma paginação opcional, ordenado alfabeticamente por nome.
Consultar instância
Retorna o objeto Instance (sem apiKey). 404 NOT_FOUND se não existir.
Excluir instância
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
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
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
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
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
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
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
Gera uma nova API key pra instância; a antiga para de funcionar na hora. Resposta: { "apiKey": "nova-chave" }.
Ver API key atual
Retorna a API key atual da instância — não gera uma nova.
{ "apiKey": "wa_..." }
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)
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
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
{ "name": "Acme Support" }
name obrigatório, não vazio. Resposta: { "updated": true }.
Definir o próprio status/recado
{ "status": "Atendimento 9h-18h" }
status obrigatório, mas "" é válido e limpa o campo. Resposta: { "updated": true }.
Mensagens
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.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.
quotedMessageId opcional — o id da mensagem do WhatsApp de uma mensagem anterior na mesma conversa.
{ "to": "5511999999999", "text": "Chega em 10 min", "quotedMessageId": "3EB0..." }
@g.us, veja Grupos) em to no lugar de um número de telefone:
{ "to": "123456789-1600000000@g.us", "text": "Olá, grupo!" }
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
{ "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
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:
fileouurl,captionopcional. - video:
fileouurl,captionopcional,ptvopcional (nota em vídeo). - audio:
fileouurl. Sempre enviado como nota de voz — qualquer formato de entrada é aceito (mp3, wav, m4a, ogg...), convertido pra OGG/Opus. Falha com500 FFMPEG_UNAVAILABLEse o servidor não tiver ffmpeg, ou500 AUDIO_CONVERSION_FAILEDse o upload não for decodificável como áudio. - document:
fileouurl, obrigatóriofileName(a menos que a partefilejá traga um),mimetypeopcional,captionopcional.
# 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
{ "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
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
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
{ "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.
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)
Exige o parâmetro de query remoteJid. Parâmetro fromMe opcional ("true"/"false", padrão "true").
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
{ "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
{ "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
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
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
{ "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
{ "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
{ "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
Todos os grupos em que esta instância participa. Cache de ~45s no Redis.
{ "groups": [ /* GroupMetadata, ... */ ] }
Listar grupos com mensagens
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
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
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
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
{ "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
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 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
{ "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
{ "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
Cache de ~45s no Redis. Resposta: { "contacts": [ { "jid", "name", "syncedAt", ... } ] }. Paginação opcional (limit, máx. 2000, + before).
Listar conversas
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
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" }
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
Query: jid (opcional, usa o catálogo da própria instância por padrão), limit (1-100), cursor.
{ "products": [ "..." ], "nextCursor": null }
Criar produto
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
Mesmos campos do create (sem originCountryCode, imutável). As imagens são totalmente substituídas. Resposta: o produto atualizado.
Excluir produtos
{ "productIds": ["123456789"] }
Resposta: { "deleted": 1 }. 502 PRODUCT_DELETE_FAILED se o WhatsApp não confirmar nenhuma exclusão.
Webhooks
Criar webhook
{
"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
Resposta: { "webhooks": [Webhook, ...] }.
Atualizar webhook
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
Resposta: { "removed": true }. 404 se não encontrado.
Logs de entrega
Ú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
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": { } }
instance_id— a instância que gerou o evento.event— um dos 9 nomes de evento abaixo.timestamp— milissegundos desde o epoch.data— payload específico do evento.
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?" }
}
]
}
}
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.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.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
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.
| Status | Código | Quando |
|---|---|---|
| 400 | BAD_REQUEST | entrada inválida genérica |
| 400 | VALIDATION_ERROR | corpo da requisição falhou na validação do schema (Zod) |
| 401 | UNAUTHORIZED | API key ausente/inválida, ou instância errada |
| 404 | NOT_FOUND | instância/webhook/rota não encontrada |
| 409 | INSTANCE_NOT_CONNECTED | ação exige uma instância conectada |
| 409 | DELETE_WINDOW_EXPIRED | apagar-para-todos além da janela de ~2,5 dias do WhatsApp |
| 409 | EDIT_WINDOW_EXPIRED | edição além da janela de ~15 minutos do WhatsApp |
| 429 | RATE_LIMITED | requisições demais pra esta instância |
| 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.
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.
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
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.
/media/* como permanentes. Baixe/guarde o que precisar manter a longo prazo logo depois de receber.