Mensagens

7. Mensagens#

Todas as operações assincronas desta seção (send, forward, delete, edit) são processadas em fila. A API retorna 202 Accepted com o message_id do Catcher (UUID pre-gerado na aceitacao da request), idempotency_key e task_id para rastreamento e deduplicacao segura de retries do cliente.

Header obrigatório: Idempotency-Key#

Todos os endpoints assincronos de mensagens exigem o header HTTP Idempotency-Key.

  • Use um valor único por operação lógica.
  • Repetir a mesma combinação instanceId + tipo de operação + Idempotency-Key não cria uma nova task.
  • O retry recebe o estado atual do mesmo request (queued, sent ou failed) com o mesmo message_id e o mesmo task_id.
  • Clientes browser podem enviar esse header diretamente: ele faz parte dos headers permitidos no preflight CORS junto com Authorization, Content-Type, X-API-Key, X-CSRF-Token, X-Force-Send e Last-Event-ID.

Formato de destinatário#

O campo to aceita:

  • Número de telefone: 554137984905 (será convertido para JID automaticamente)
  • JID individual: 554137984905@s.whatsapp.net
  • JID de grupo: 120363012345678901@g.us

Resposta padrão (202)#

Todas as operações que criam uma nova mensagem retornam:

json
{
  "status": "queued",
  "message_id": "550e8400-e29b-41d4-a716-446655440000",
  "task_id": "asynq:task:xxxx-xxxx",
  "idempotency_key": "msg-2026-03-21-0001"
}
  • message_id — UUID do Catcher, pre-gerado no momento da aceitacao. E o mesmo valor que aparece como elemento de message_ids nos webhooks message.sent, message.delivered, message.read, message.played, message.deleted, message.edited. Use este campo para correlacionar a resposta sincrona com os eventos assincronos (no consumer, message_ids[0] = message_id da resposta 202).
  • idempotency_key — eco do header enviado pelo cliente. Também aparece no webhook message.sent (campo idempotency_key) quando a mensagem foi originada via API.
  • task_id — identificador interno do Asynq. Util para debug/NOC; não deve ser usado como chave de correlação primaria (use message_id ou idempotency_key).

Operações que não criam um novo Message (delete, edit) retornam apenas status, task_id e idempotency_key; o cliente já possui o message_id alvo da operação.

Repetir a mesma chave idempotente devolve o mesmo message_id, task_id e o estado atual (queued, sent ou failed) sem enfileirar duplicidade. Este e o caminho oficial para um cliente recuperar o message_id caso tenha perdido a resposta original ou precise verificar o estado antes do primeiro webhook.

Confirmando entrega de verdade (o 202 não é entrega)#

O 202 significa apenas "aceito na fila". NÃO significa que a mensagem saiu, chegou, ou renderizou no destinatário. Para confirmar de verdade, acompanhe a escada de eventos (webhook/SSE/WS) correlacionando por message_id:

Evento Prova NÃO prova
202 queued (resposta HTTP) a task entrou na fila que saiu / chegou
message.sent o worker despachou pro WhatsApp; a Meta aceitou o proto que chegou no aparelho
message.delivered chegou no aparelho do destinatário que renderizou / tocou
message.read o destinatário abriu e o chat renderizou (texto, imagem, documento, sticker, localização, contato, link-preview)
message.played o destinatário tocou a mídia (áudio/PTT, vídeo, PTV, view-once)

O salto crítico é delivered → read/played: mídia pode entregar e falhar em renderizar/tocar no cliente (proto incompleto, container errado). Se um envio chega em delivered mas nunca dá read/played, trate como não consumido — não como entregue. Regra prática: "sem read/played, não chegou de verdade."

Pré-condição importante: o read/played só dispara se o destinatário tiver recibos de leitura ligados E efetivamente abrir/tocar a mensagem. Se o destinatário não produz recibo, o piso de verificação é message.delivered (chegou no aparelho) + inspecionar o message.received do lado dele e conferir que o conteúdo bate (==) com o enviado: mesma caption/content, mesmo message_type, media_id presente, phone correto. Isso prova que chegou correto, mesmo sem o sinal de leitura.

Status / Stories têm semântica de recepção diferente: o evento status.viewed (alguém viu MEU status) só vem de contatos no público do status (contatos salvos filtrados pela privacidade de status), não de qualquer número com quem você troca DM. Ver Status / Stories.

Campo quoted_id (reply)#

Todos os endpoints de envio que listam quoted_id aceitam:

  • UUID Catcher (formato xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx): e o mesmo UUID retornado em message_ids nos webhooks (message.received, message.sent, etc). A API resolve internamente para o ID hexadecimal do WhatsApp antes de enfileirar.
  • ID hexadecimal do WhatsApp (ex.: 3EB0ABC123...): passa direto, sem lookup.

Se o quoted_id for um UUID e não existir no banco (mensagem removida, pertence a outra instância, ou nunca foi recebida por está instância), a API responde 404 Not Found com error_code=MESSAGE_NOT_FOUND. Não ha enfileiramento parcial: a mensagem so e enviada com reply válido ou não e enviada.

Isto vale para SendText, SendImage, SendVideo, SendAudio, SendDocument, SendSticker, SendLocation, SendContact e para os equivalentes de envio agendado via POST /v1/instances/{instanceId}/messages/scheduled.


Modo passivo (409 PASSIVE_MODE_ENABLED)#

Quando a instância está configurada em modo passivo (passive_mode_enabled=true em GET/PATCH /v1/instances/{id}/settings), todos os endpoints de envio (text, image, video, audio, document, sticker, location, contact, reaction, poll, template, forward, delete, edit) são bloqueados antes do enfileiramento:

json
{
  "error_code": "PASSIVE_MODE_ENABLED",
  "message": "instance is in passive mode — set passive_mode_enabled=false in /v1/instances/{id}/settings or pass X-Force-Send: true to override (audit-logged)",
  "trace_id": "..."
}
  • HTTP 409 Conflict.
  • Bypass: header X-Force-Send: true (registra audit log audit_action=passive_mode.force_send).
  • Modo passivo falha fechado: um erro transitorio no banco do tenant retorna 503/500 em vez de liberar o envio (proposito: não vazar outbound de uma instância sob hold legal/monitoramento).

Guard anti-spam (409 BLOCKED_*)#

Endpoints de envio passam por um guard anti-spam antes de enfileirar. Qualquer regra que dispare devolve 409 Conflict:

error_code Regra Campos extras no payload
BLOCKED_DUPLICATE_CONTENT Conteúdo identico já enviado nas últimas 24h remote_jid, content_hash, first_sent_at
BLOCKED_NO_RECIPROCITY Envios consecutivos sem nenhum inbound do contato — limite fixo remote_jid, count, threshold
BLOCKED_TEMPERATURE_LIMIT max_consecutive da curva de temperatura do contato excedido remote_jid, score, tier, max_consecutive, outbound_consecutive
  • A regra de conteúdo duplicado é controlada por anti_spam_guard_enabled (default true).
  • O limite consecutivo sem resposta é controlado pela combinação anti_spam_guard_enabled + anti_spam_temperature_enabled. Quando ambas estão ativas (default), a curva de temperatura por contato substitui o limite fixo — apenas BLOCKED_TEMPERATURE_LIMIT pode disparar. Quando a temperatura está desativada, apenas BLOCKED_NO_RECIPROCITY pode disparar. As duas nunca rodam ao mesmo tempo.
  • delete, edit, reaction e forward são isentos da regra de conteúdo duplicado (não ha shape de conteúdo comparavel), mas continuam contando para o limite consecutivo.
  • Bypass: header X-Force-Send: true (registra audit log audit_action=anti_spam.force_send).

POST/v1/instances/{instanceId}/messages/text#

Envia mensagem de texto.

Auth: Todos autenticados

Request:

json
{
  "to": "554137984905",
  "text": "Ola! Como posso ajudar?",
  "quoted_id": "3EB0ABC123...",
  "mentions": ["5541988888888@s.whatsapp.net"]
}
Campo Tipo Obrigatório Descricao
to string sim Destinatário (telefone ou JID)
text string sim Conteúdo da mensagem
quoted_id string não ID da mensagem respondida (UUID Catcher ou hex WhatsApp; ver nota geral de quoted_id acima)
mentions []string não JIDs de usuários mencionados

Regras do humanize:

  • Controlado por instância via GET/PATCH /v1/instances/{instanceId}/settings (humanize_enabled, default true).
  • Quando ativo, o processor envia composing imediatamente antes de SendText e espera numero_de_caracteres(texto) * multiplier segundos, com multiplier aleatorio entre 0.1 e 0.5.
  • A aplicação ocorre apenas em mensagens de texto para chats 1:1; grupos (@g.us) e mensagens de mídia seguem o fluxo legado.
  • Se a leitura da configuração falhar, o envio segue imediatamente para não bloquear a fila.

Envio de mídia: media_url, media_id ou arquivo direto#

Os endpoints POST /v1/instances/{instanceId}/messages/{image|video|audio|document|sticker} aceitam as tres formas de origem no mesmo endpoint:

  1. application/json com media_url: o servidor baixa a URL sincronamente durante o request (~500ms-2s para arquivos pequenos), aplica validação SSRF/MIME/64 MB, armazena no R2/S3, gera um media_id interno e enfileira o envio usando esse ID.
  2. application/json com media_id: reutiliza uma mídia já armazenada pelo endpoint POST /v1/instances/{instanceId}/media ou por mídia recebida.
  3. multipart/form-data com campo file: envia o arquivo binario direto no próprio endpoint; a API armazena em R2, gera um media_id e enfileira o envio usando esse ID.

Arquitetura de mídia: API resolve, worker só envia#

Independente da forma de origem (URL, media_id existente ou multipart), a API sempre garante que os bytes estejam armazenados no R2 antes de retornar 202. O worker só recebe media_id na fila Asynq — nunca uma URL externa para baixar. Isso elimina classe inteira de falha silenciosa em que a CDN externa bloqueia download depois do 202 e a mensagem nunca chega.

Prático para você (consumidor da API):

  • Falha de download é sincrona: se a URL retorna 4xx, 5xx, timeout, ou MIME bloqueado, você recebe 502 MEDIA_FETCH_FAILED no momento da chamada. NÃO vai pra fila, não tem retry silencioso. Retry o request com URL válida ou faca upload prévio.
  • Custo de latência: requests com media_url agora levam ~500ms-2s a mais que com media_id (tempo do download + upload R2). Para arquivos grandes (>10MB), considere fazer upload prévio via POST /v1/instances/{id}/media e reutilizar o media_id retornado.
  • Cache automático: cada media_url baixado vira uma linha no R2 com source="url-cache" e TTL de 7 dias. Reaproveitamento da mesma URL durante esse período é livre.
  • Disciplina de proxy: o download da URL passa pelo cliente HTTP padrão da API (resolver DNS-over-TLS, validador SSRF). Bright Data residential proxy é reservado APENAS para o upload worker → Meta CDN na hora do envio. Isso é deliberado e estrutural — ver .claude/rules/whatsapp-proxy-discipline.md.

Campos textuais do envio (to, caption, file_name, ptt, quoted_id, mentions) podem ir no multipart como campos de formulario. mentions aceita valores repetidos, CSV ou JSON array.

O header Idempotency-Key e obrigatório em todas as tres formas de envio de mídia (igual aos demais endpoints de mensagem).

Exemplo com arquivo direto:

bash
curl -X POST "https://api.catcher.one/v1/instances/$INSTANCE/messages/image" \
  -H "X-API-Key: $TOKEN" \
  -H "Idempotency-Key: req_01HX9Y..." \
  -F "to=554137984905" \
  -F "caption=Imagem enviada pela API" \
  -F "file=@/caminho/foto.png"

Exemplo com URL externa:

bash
curl -X POST "https://api.catcher.one/v1/instances/$INSTANCE/messages/video" \
  -H "X-API-Key: $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: req_01HX9Y..." \
  -d '{
    "to": "554137984905",
    "media_url": "https://meucdn.com/video.mp4",
    "caption": "Olha esse vídeo"
  }'
# → 202 só sai depois que os bytes estão no R2.
# → 502 MEDIA_FETCH_FAILED se o CDN externo recusar.

Exemplo com upload prévio (recomendado para arquivos grandes ou reutilizados):

bash
# 1. Upload uma vez:
MEDIA_ID=$(curl -sf -X POST "https://api.catcher.one/v1/instances/$INSTANCE/media" \
  -H "X-API-Key: $TOKEN" \
  -H "Idempotency-Key: upload-$(date +%s)" \
  -F "file=@/caminho/video-grande.mp4" | jq -r .media_id)

# 2. Reutilize em N envios sem rebaixar:
for PHONE in 554137984905 554196332719 554163475715; do
  curl -X POST "https://api.catcher.one/v1/instances/$INSTANCE/messages/video" \
    -H "X-API-Key: $TOKEN" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: bulk-$PHONE-$(date +%s)" \
    -d "{\"to\":\"$PHONE\",\"media_id\":\"$MEDIA_ID\",\"caption\":\"Veja como ficou\"}"
done

Em JSON, envie media_url OU media_id. Em multipart, envie file. Um desses tres e obrigatório.


POST/v1/instances/{instanceId}/messages/image#

Envia imagem.

Auth: Todos autenticados

Request:

json
{
  "to": "554137984905",
  "media_url": "https://exemplo.com/foto.png",
  "caption": "Confira esta imagem!",
  "quoted_id": "",
  "mentions": []
}
Campo Tipo Obrigatório Descricao
to string sim Destinatário
media_url string sim* URL pública da imagem
media_id string sim* ID de média pre-enviado via upload
file file sim* Arquivo direto via multipart/form-data
caption string não Legenda da imagem
quoted_id string não ID da mensagem respondida (UUID Catcher ou hex WhatsApp; ver nota geral de quoted_id acima)
mentions []string não JIDs mencionados

*Enviar media_url, media_id OU file (um dos tres e obrigatório).


POST/v1/instances/{instanceId}/messages/video#

Envia video.

Auth: Todos autenticados

Request:

json
{
  "to": "554137984905",
  "media_url": "https://exemplo.com/video.mp4",
  "caption": "Assista ao video"
}
Campo Tipo Obrigatório Descricao
to string sim Destinatário
media_url string sim* URL pública do video
media_id string sim* ID de média pre-enviado
file file sim* Arquivo direto via multipart/form-data
caption string não Legenda
quoted_id string não ID da mensagem respondida (UUID Catcher ou hex WhatsApp; ver nota geral de quoted_id acima)
mentions []string não JIDs mencionados

*Enviar media_url, media_id OU file (um dos tres e obrigatório).


POST/v1/instances/{instanceId}/messages/audio#

Envia audio. Por padrão, todos os audios são enviados como nota de voz (PTT). Formatos não-OGG (MP3, M4A, WAV, WebM, etc) são convertidos automaticamente para OGG Opus (mono, 16 kHz, 32 kbps) via ffmpeg — formato exigido pelo WhatsApp mobile para PTT. Uma waveform de 64 bytes e gerada automaticamente para exibição no app. Use ptt: false para enviar como audio regular sem conversao.

Auth: Todos autenticados

Request:

json
{
  "to": "554137984905",
  "media_url": "https://exemplo.com/audio.mp3"
}
Campo Tipo Obrigatório Descricao
to string sim Destinatário
media_url string sim* URL pública do audio (qualquer formato: MP3, OGG, M4A, WAV, etc)
media_id string sim* ID de média pre-enviado
file file sim* Arquivo direto via multipart/form-data
ptt bool não Padrão true (nota de voz). Converte automaticamente para OGG Opus se necessário. false = audio regular, sem conversao
quoted_id string não ID da mensagem respondida (UUID Catcher ou hex WhatsApp; ver nota geral de quoted_id acima)
mentions []string não JIDs mencionados

*Enviar media_url, media_id OU file (um dos tres e obrigatório).

No envio multipart, o campo ptt deve ser um booleano válido (true/false); valores inválidos retornam 400 BAD_REQUEST.


POST/v1/instances/{instanceId}/messages/document#

Envia documento (PDF, DOCX, etc).

Auth: Todos autenticados

Request:

json
{
  "to": "554137984905",
  "media_url": "https://exemplo.com/relatorio.pdf",
  "file_name": "relatorio-marco.pdf"
}
Campo Tipo Obrigatório Descricao
to string sim Destinatário
media_url string sim* URL pública do documento
media_id string sim* ID de média pre-enviado
file file sim* Arquivo direto via multipart/form-data
file_name string não Nome do arquivo exibido no WhatsApp
quoted_id string não ID da mensagem respondida (UUID Catcher ou hex WhatsApp; ver nota geral de quoted_id acima)
mentions []string não JIDs mencionados

*Enviar media_url, media_id OU file (um dos tres e obrigatório).


POST/v1/instances/{instanceId}/messages/sticker#

Envia sticker (figurinha). A imagem deve estar no formato WebP.

Auth: Todos autenticados

Request:

json
{
  "to": "554137984905",
  "media_url": "https://exemplo.com/sticker.webp"
}
Campo Tipo Obrigatório Descricao
to string sim Destinatário
media_url string sim* URL pública do sticker (WebP)
media_id string sim* ID de média pre-enviado
file file sim* Arquivo direto via multipart/form-data
quoted_id string não ID da mensagem respondida (UUID Catcher ou hex WhatsApp; ver nota geral de quoted_id acima)
mentions []string não JIDs para mencionar

*Enviar media_url, media_id OU file (um dos tres e obrigatório).


POST/v1/instances/{instanceId}/messages/location#

Envia localização.

Auth: Todos autenticados

Request:

json
{
  "to": "554137984905",
  "latitude": -25.4284,
  "longitude": -49.2733,
  "name": "Praca Tiradentes",
  "address": "Centro, Curitiba - PR"
}
Campo Tipo Obrigatório Descricao
to string sim Destinatário
latitude float sim Latitude
longitude float sim Longitude
name string não Nome do local
address string não Endereço
quoted_id string não ID da mensagem respondida (UUID Catcher ou hex WhatsApp; ver nota geral de quoted_id acima)
mentions []string não JIDs para mencionar

POST/v1/instances/{instanceId}/messages/contact#

Envia um ou mais cartoes de contato (vCard). Com 2+ contatos, o WhatsApp do destinatário recebe um único card agrupado (ContactsArrayMessage).

Auth: Todos autenticados

Request — contato único (nome + telefone):

json
{
  "to": "554137984905",
  "contact_name": "Suporte Catcher",
  "contact_phone": "+5541988887777"
}

Request — varios contatos:

json
{
  "to": "554137984905",
  "contacts": [
    { "contact_name": "Suporte Catcher", "contact_phone": "+5541988887777" },
    { "contact_name": "Comercial", "contact_phone": "+5541977776666" }
  ]
}

Request — vCard cru (fidelidade total: email, empresa, multiplos números):

json
{
  "to": "554137984905",
  "vcard": "BEGIN:VCARD\nVERSION:3.0\nFN:Suporte Catcher\nTEL;type=CELL;waid=5541988887777:+5541988887777\nEMAIL:suporte@catcher.one\nEND:VCARD"
}
Campo Tipo Obrigatório Descricao
to string sim Destinatário
contact_name string condicional Nome do contato. Obrigatório no modo contato-único quando vcard e contacts estão ausentes
contact_phone string condicional Telefone do contato em formato E.164 com + (ex: +5541988887777). Obrigatório junto de contact_name
vcard string não vCard 3.0 cru de um único contato (deve conter BEGIN:VCARD/END:VCARD, max 8 KB). Enviado verbatim — o conteúdo e responsabilidade do chamador. Tem precedencia sobre contact_name/contact_phone
contacts []object não Lista de contatos (max 50). Cada item: contact_name+contact_phone, ou vcard. Tem precedencia sobre os campos de nivel raiz. 2+ itens viram um ContactsArrayMessage
quoted_id string não ID da mensagem respondida (UUID Catcher ou hex WhatsApp; ver nota geral de quoted_id acima)
mentions []string não JIDs para mencionar

Precedencia entre as formas: contacts > vcard > contact_name/contact_phone. O webhook message.sent resultante carrega o array contacts (ver message.sent).

Erros de validação (400):

Mensagem Causa
too many contacts: at most 50 per send contacts[] excedeu o cap de 50 itens
vcard must be a vCard 3.0 string (BEGIN/END:VCARD) under 8 KB vcard no nivel raiz não tem BEGIN:VCARD/END:VCARD ou passou de 8 KB
contacts[N]: vcard must be a vCard 3.0 string (BEGIN/END:VCARD) under 8 KB Item N do array contacts com vcard inválido ou oversized
valid contact_name and contact_phone (or a vcard) are required Modo legacy: faltou contact_name, ou contact_phone não está em E.164 com +
contacts[N]: valid contact_name and contact_phone (or a vcard) are required Item N do array sem contact_name+contact_phone válidos nem vcard

POST/v1/instances/{instanceId}/messages/reaction#

Envia reacao (emoji) a uma mensagem.

Auth: Todos autenticados

Request:

json
{
  "to": "554137984905",
  "message_id": "198d2663-d6a7-4d62-a018-963065c80403",
  "emoji": "👍"
}
Campo Tipo Obrigatório Descricao
to string sim JID do chat onde está a mensagem
message_id string sim UUID Catcher (elemento de message_ids nos webhooks) OU hex WhatsApp da mensagem-alvo. A API resolve internamente; retorna 404 MESSAGE_NOT_FOUND se o UUID não existir nesta instância.
emoji string sim Emoji da reacao (string vazia para remover)

POST/v1/instances/{instanceId}/messages/poll#

Cria uma enquete.

Auth: Todos autenticados

Request:

json
{
  "to": "554137984905",
  "question": "Qual o melhor dia para a reuniao?",
  "options": ["Segunda", "Terca", "Quarta"],
  "max_selections": 1
}
Campo Tipo Obrigatório Descricao
to string sim Destinatário
question string sim Pergunta da enquete
options []string sim Opcoes (mínimo 2)
max_selections int não Máximo de selecoes por pessoa. Padrão: 1

POST/v1/instances/{instanceId}/messages/template#

Envia mensagem com botoes (template). Requer conta WhatsApp Business.

Auth: Todos autenticados

Request:

json
{
  "to": "554137984905",
  "body_text": "Escolha uma opcao abaixo:",
  "footer_text": "Powered by Catcher",
  "buttons": [
    {"text": "Comprar", "id": "btn_buy"},
    {"text": "Cancelar", "id": "btn_cancel"}
  ]
}
Campo Tipo Obrigatório Descricao
to string sim Destinatário
body_text string sim Texto principal
footer_text string não Texto do rodape
buttons []object sim 1 a 3 botoes
buttons[].text string sim Texto exibido no botao
buttons[].id string sim ID do botao (retornado no callback)

Nota: Se a instância não for uma conta Business, a mensagem será rejeitada sem retry.


POST/v1/instances/{instanceId}/messages/link-preview#

Envia uma mensagem de texto com card de preview de link controlado (você define título e descrição). Renderiza no destinatário (tipo nativo ExtendedTextMessage).

Auth: Todos autenticados · Header: Idempotency-Key obrigatório

Preview NÃO é o default. Um link num POST /messages/text comum vai como texto puro — o app do destinatário PODE gerar um preview básico do lado dele (fetch client-side), mas isso não é garantido nem controlado por você. Para um card com título/descrição garantidos, use este endpoint.

Request:

json
{
  "to": "554137984905",
  "text": "Conhece a Catcher? https://catcher.one",
  "url": "https://catcher.one",
  "title": "Catcher",
  "description": "WhatsApp API multi-tenant"
}
Campo Tipo Obrigatório Descrição
to string sim Destinatário
text string sim Corpo da mensagem (deve conter a URL)
url string sim URL do preview
title string não Título do card
description string não Descrição do card
quoted_id string não UUID Catcher OU hex WhatsApp da mensagem citada

Retorna 202 com message_id (UUID Catcher) + task_id + idempotency_key. Emite message.sent (message_type: text).


POST/v1/instances/{instanceId}/messages/event#

Envia uma mensagem de evento (calendário). Tipo nativo EventMessage — renderiza no destinatário.

Auth: Todos autenticados · Header: Idempotency-Key obrigatório

Request:

json
{
  "to": "554137984905",
  "name": "Reunião de planejamento",
  "description": "pauta trimestral",
  "start_at": "2026-05-30T15:00:00Z",
  "end_at": "2026-05-30T16:00:00Z",
  "location": "Online"
}
Campo Tipo Obrigatório Descrição
to string sim Destinatário (1:1 ou grupo @g.us)
name string sim Nome do evento
start_at string sim Início (RFC3339)
description string não Descrição
end_at string não Fim (RFC3339)
location string não Local
link string não Link de chamada/join

Retorna 202. Emite message.sent (message_type: event).


POST/v1/instances/{instanceId}/messages/ptv#

Envia um vídeo redondo (PTV / "video note"). Mesma origem de mídia dos outros sends (media_url / media_id / multipart). Tipo nativo PtvMessage.

POST/v1/instances/{instanceId}/messages/gif#

Envia um vídeo animado marcado como GIF (autoplay/loop no destinatário, gifPlayback=true). Mesma origem de mídia.

Ambos: 202 + Idempotency-Key obrigatório; emitem message.sent (message_type: ptv / gif).


view_once (flag de mídia)#

POST /messages/image, /video e /audio aceitam "view_once": true no corpo — a mídia vira visualização única (viewOnceMessage, auto-destrói após aberta). Default false.

json
{ "to": "554137984905", "media_url": "https://...", "view_once": true }

Status / Stories (§13.3)#

Posta um status (story). Nativo e render-safe: a whatsmeow envia para status@broadcast e resolve a audiência automaticamente das configurações de privacidade de status do número (quem vê), cifrando o fan-out. Sem to — status é um broadcast. Anti-spam guard não se aplica (broadcast, não 1:1); o throttle de pacing continua valendo.

Auth: Todos autenticados · Header: Idempotency-Key obrigatório nos POSTs.

POST /v1/instances/{instanceId}/messages/status/text#

json
{ "text": "Meu status ✨", "background_color": "#1FA75B", "font": 6 }
Campo Tipo Obrigatório Descrição
text string sim Texto do status
background_color string não Cor de fundo #RRGGBB
font int não Fonte (0=sistema, 1=system_text, 2=fb_script, 6=system_bold, 7=morningbreeze, 8=calistoga, 10=courierprime_bold)

POST /v1/instances/{instanceId}/messages/status/image#

POST /v1/instances/{instanceId}/messages/status/video#

json
{ "media_url": "https://...", "caption": "legenda opcional" }

Aceita media_url (resolvido sincronamente pra media_id) OU media_id (upload prévio em /media).

Todos retornam 202 com message_id (UUID Catcher); emitem message.sent (message_type: status_text / status_image / status_video).

GET /v1/instances/{instanceId}/status/privacy#

Auth: Owner/Admin

Retorna quem pode ver o status do número.

json
{ "status_privacy": [ { "type": "contacts", "list": [], "is_default": true } ] }

type: contacts | blacklist | whitelist. list: telefones (para black/whitelist).

GET /v1/instances/{instanceId}/status/{statusId}/views#

Auth: Owner/Admin

Contagem de visualizações + lista de quem viu UM status seu. statusId é o message_id retornado ao postar (UUID Catcher) ou o id bruto do WhatsApp. A contagem é de viewers únicos (um por contato, mesmo que reabra o status).

json
{
  "status_message_id": "c710cea9-fcc4-4a7c-9e30-1a19ebc07bfb",
  "count": 2,
  "viewers": [
    { "phone": "554196332719", "jid": "216251804708983@lid", "receipt_type": "read",   "viewed_at": "2026-05-23T18:48:26Z" },
    { "phone": "554163475715", "jid": "...@lid",             "receipt_type": "played", "viewed_at": "2026-05-23T18:49:01Z" }
  ]
}

receipt_type: read (status aberto) | played (vídeo assistido). Sem visualizações ainda → count: 0, viewers: [].

Webhooks de status — recepção (interações com seu status)#

A Catcher emite um evento dedicado para cada interação. Compartilhamento não é observável pelo autor (o WhatsApp não notifica o autor quando alguém compartilha).

Semântica de público (audience) — vale para TODOS os eventos de recepção abaixo. Status são entregues ao público do autor: contatos salvos na agenda do número, filtrados pela privacidade de status ("Meus contatos" / exceções). Você só recebe status.received de um contato cujo status alcança a sua instância, e só recebe status.viewed/status.reaction/status.reply de quem está no SEU público. Trocar DM com um número não o coloca no público de status de nenhum dos lados — é preciso estar salvo na agenda. Em testes entre instâncias de API que não se têm salvas, o POST .../status/* envia com sucesso (você vê message.sent no autor), mas a outra instância não emite status.received — isso é esperado, não é bug. Um aviso Server returned different participant list hash … no log do worker é a computação do público, não uma falha de envio.

status.received — um contato postou um status/story (antes era descartado):

json
{ "phone": "5511999999999", "from": "5511999999999@s.whatsapp.net",
  "status_message_id": "3EB0...", "type": "text|image|video|audio",
  "content": "...", "timestamp": 1779543210 }

Convenção de campos (igual a todos os eventos): phone = telefone do interagente (resolvido), from = JID dele (pode ser um LID em status@broadcast).

status.viewed — um contato VISUALIZOU um status SEU (recibo read/played):

json
{ "phone": "554196332719", "from": "216251804708983@lid",
  "status_message_id": "c710cea9-...", "whatsapp_status_id": "3EB0...",
  "status_type": "image", "receipt_type": "read", "timestamp": 1779565914 }

status.reaction — um contato REAGIU a um status SEU:

json
{ "phone": "554196332719", "from": "216251804708983@lid", "push_name": "Adriano",
  "status_message_id": "c710cea9-...", "whatsapp_status_id": "3EB0...",
  "status_type": "image", "emoji": "💚", "removed": false, "timestamp": 1779565792 }

removed: true (e emoji: "") quando a reação foi removida.

status.reply — um contato RESPONDEU/comentou um status SEU. A message.received do DM continua disparando normalmente; este evento traz o contexto do status:

json
{ "phone": "554196332719", "from": "216251804708983@lid", "push_name": "Adriano",
  "status_message_id": "c710cea9-...", "whatsapp_status_id": "3EB0...",
  "status_type": "image", "reply_message_id": "0a279...", "whatsapp_reply_id": "3A2D...",
  "reply_type": "text", "content": "😍", "timestamp": 1779566005 }

Mensagens interativas (botões / PIX / OTP / carrossel / lista) — DESATIVADAS#

Os endpoints POST /messages/button, /button-pix, /button-otp e /option-list existem no código mas estão desativados e retornam 403 INTERACTIVE_DISABLED.

Por quê: o spike de 2026-05-23 provou que os clientes WhatsApp do destinatário descartam silenciosamente mensagens interativas (InteractiveMessage/ListMessage) enviadas pelo protocolo multi-device não-oficial — não renderizam, nem como texto. O Baileys (referência da indústria) nunca shipou envio de botão pelo mesmo motivo. Enviar tipos não suportados é risco de ban sem ganho. Ficam prontos para reativar se a Meta passar a suportar. Detalhe em docs/COMPARATIVO-ZAPI-BIAZAP.md §15.9.

POST /v1/instances/{instanceId}/messages/button#

Retorna 403 INTERACTIVE_DISABLED enquanto mensagens interativas seguem desativadas.

POST /v1/instances/{instanceId}/messages/button-pix#

Retorna 403 INTERACTIVE_DISABLED enquanto mensagens interativas seguem desativadas.

POST /v1/instances/{instanceId}/messages/button-otp#

Retorna 403 INTERACTIVE_DISABLED enquanto mensagens interativas seguem desativadas.

POST /v1/instances/{instanceId}/messages/option-list#

Retorna 403 INTERACTIVE_DISABLED enquanto mensagens interativas seguem desativadas.


POST/v1/instances/{instanceId}/messages/forward#

Encaminha uma mensagem existente para outro chat.

Auth: Todos autenticados

Request:

json
{
  "to": "5541988888888",
  "forward_chat_id": "554137984905@s.whatsapp.net",
  "message_id": "198d2663-d6a7-4d62-a018-963065c80403"
}
Campo Tipo Obrigatório Descricao
to string sim Destinatário do encaminhamento
forward_chat_id string sim JID do chat onde a mensagem original está
message_id string sim UUID Catcher (elemento de message_ids nos webhooks) OU hex WhatsApp da mensagem a encaminhar. A API resolve internamente; retorna 404 MESSAGE_NOT_FOUND se o UUID não existir nesta instância.

Funciona com todos os tipos: texto, imagem, video, audio, documento.


DELETE/v1/instances/{instanceId}/messages/{messageId}#

Apaga uma mensagem enviada ("apagar para todos").

Auth: Todos autenticados

Request:

json
{
  "to": "554137984905",
  "message_id": "198d2663-d6a7-4d62-a018-963065c80403"
}
Campo Tipo Obrigatório Descricao
to string sim JID do chat
message_id string sim UUID Catcher (elemento de message_ids nos webhooks) OU hex WhatsApp da mensagem a apagar. A API resolve internamente; retorna 404 MESSAGE_NOT_FOUND se o UUID não existir nesta instância.

Resposta 202:

json
{
  "status": "queued",
  "task_id": "asynq:task:xxxx"
}

PUT/v1/instances/{instanceId}/messages/{messageId}/edit#

Edita o texto de uma mensagem enviada. Somente mensagens de texto outbound podem ser editadas. O próprio WhatsApp pode rejeitar edicoes de mensagens muito antigas (tipicamente após ~15 minutos) — a API não impoe esse limite, a rejeição ocorre de forma assincrona no worker.

Auth: Todos autenticados

Request:

json
{
  "content": "Texto atualizado da mensagem"
}
Campo Tipo Obrigatório Descricao
content string sim Novo conteúdo de texto da mensagem

Resposta 202:

json
{
  "status": "queued",
  "task_id": "asynq:task:xxxx"
}

Erros:

Código Descricao
400 content is required — conteúdo vazio
400 only outbound messages can be edited — mensagem não foi enviada pela instância
400 only text messages can be edited — tipo não e texto
400 cannot edit a deleted message — mensagem já foi deletada
404 message not foundmessageId não encontrado (UUID inexistente nesta instância OU hex desconhecido)

O messageId na URL aceita o UUID Catcher (elemento de message_ids nos webhooks) OU o hex WhatsApp (whats_app_id). A API resolve internamente; UUID inexistente retorna 404 MESSAGE_NOT_FOUND. Após a edicao, o webhook message.edited e emitido com is_from_me: true.


7.X Ações sobre mensagens (Wave 1 — Z-API parity)#

Três endpoints síncronos que operam sobre uma mensagem existente: fixar (pin), votar em enquete (poll-vote) e favoritar (star). Diferente dos sends de conteúdo, esses endpoints respondem 200 OK (não 202) porque atuam direto via RPC no worker — sem fila Asynq, sem Idempotency-Key obrigatório, sem anti-spam guard pesado.

Os 3 endpoints aceitam o message_id em qualquer formato (UUID Catcher OU hex WhatsApp). A API resolve internamente.


POST /v1/instances/{instanceId}/messages/pin#

Fixa ou desfixa uma mensagem no chat para todos os participantes. Em grupos, requer que a instância seja admin.

Request:

json
{
  "chat_jid": "554163475715@s.whatsapp.net",
  "message_id": "198d2663-d6a7-4d62-a018-963065c80403",
  "target_sender_jid": "",
  "target_from_me": true,
  "pin": true,
  "duration_seconds": 86400
}
Campo Tipo Obrigatório Descrição
chat_jid string sim Chat onde a mensagem está.
message_id string sim UUID Catcher OU hex WhatsApp da mensagem-alvo.
target_sender_jid string não JID de quem enviou a mensagem-alvo. Em DM, default = chat_jid. Obrigatório em grupos.
target_from_me bool não Se a mensagem foi enviada pela instância. Default false.
pin bool sim true = PIN_FOR_ALL, false = UNPIN_FOR_ALL.
duration_seconds int não 86400 (24h) / 604800 (7d) / 2592000 (30d). 0 = WhatsApp default (24h). Ignorado em unpin.

Response 200 OK:

json
{
  "instance_id": "48a1322e-f3b1-4a32-ac79-58f67036af79",
  "chat_jid": "554163475715@s.whatsapp.net",
  "target_message_id": "3EB01870039BE5362C6F0A",
  "action": "pinned",
  "envelope_message_id": "3EB0ABC123..."
}

envelope_message_id é o ID do WhatsApp da própria mensagem PIN (não da mensagem fixada).


POST /v1/instances/{instanceId}/messages/poll-vote#

Vota em uma enquete existente. A instância precisa ter recebido a enquete (whatsmeow precisa do segredo da poll pra re-encriptar).

Request:

json
{
  "chat_jid": "554163475715@s.whatsapp.net",
  "poll_message_id": "3EB0CAFEBABE123",
  "poll_sender_jid": "",
  "poll_from_me": false,
  "selected_options": ["sim", "muito legal"]
}
Campo Tipo Obrigatório Descrição
chat_jid string sim Chat onde a poll está.
poll_message_id string sim UUID Catcher OU hex WhatsApp da mensagem de criação da poll.
poll_sender_jid string não Quem criou a poll. Em DM, default = chat_jid.
poll_from_me bool não Se a instância criou a poll. Default false.
selected_options array de strings sim Nomes exatos das opções votadas. [] vazio limpa o voto.

Response 200 OK:

json
{
  "instance_id": "...",
  "chat_jid": "554163475715@s.whatsapp.net",
  "poll_message_id": "3EB0CAFEBABE123",
  "selected_options": ["sim", "muito legal"],
  "envelope_message_id": "3EB0VOTE..."
}

Voto é uma PollUpdateMessage cifrada com o segredo da poll original. Se a instância não tem o segredo (poll foi enviada antes da instância existir, ou após reset de sessão), o envio falha com 404.


POST /v1/instances/{instanceId}/messages/star#

Favorita ou desfavorita uma mensagem. Muda só no AppState da instância — propaga pros outros devices via sync (não é visível pro destinatário).

Request:

json
{
  "chat_jid": "554163475715@s.whatsapp.net",
  "message_id": "198d2663-d6a7-4d62-a018-963065c80403",
  "sender_jid": "",
  "from_me": true,
  "starred": true
}
Campo Tipo Obrigatório Descrição
chat_jid string sim Chat onde a mensagem está.
message_id string sim UUID Catcher OU hex WhatsApp.
sender_jid string não Quem enviou. Em DM, default = chat_jid. Obrigatório em grupos.
from_me bool não Se a instância enviou. Default false.
starred bool sim true = favoritar, false = desfavoritar.

Response 200 OK:

json
{
  "instance_id": "...",
  "chat_jid": "554163475715@s.whatsapp.net",
  "message_id": "3EB01870...0A",
  "action": "starred"
}

Mudança propaga pros outros devices da operadora via appstate sync. Webhook message.starred é emitido quando a propagação retorna pelo whatsmeow.