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.

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 de 3 regras 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 remote_jid, count, threshold
BLOCKED_TEMPERATURE_LIMIT max_consecutive do tier de temperatura do contato excedido remote_jid, score, tier, max_consecutive, outbound_consecutive
  • As regras 1 e 2 são controladas por anti_spam_guard_enabled (default true); a regra 3 por anti_spam_temperature_enabled + flag global de plataforma.
  • 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 reciprocidade.
  • 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": "3EB0ABC123DEF456",
  "emoji": "👍"
}
Campo Tipo Obrigatório Descricao
to string sim JID do chat onde está a mensagem
message_id string sim ID da mensagem no WhatsApp
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/forward#

Encaminha uma mensagem existente para outro chat.

Auth: Todos autenticados

Request:

json
{
  "to": "5541988888888",
  "forward_chat_id": "554137984905@s.whatsapp.net",
  "message_id": "3EB0ABC123DEF456"
}
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 ID da mensagem a encaminhar

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": "3EB0ABC123DEF456"
}
Campo Tipo Obrigatório Descricao
to string sim JID do chat
message_id string sim ID da mensagem no WhatsApp

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

O messageId na URL e o ID do WhatsApp (whats_app_id), não o ID interno. Após a edicao, o webhook message.edited e emitido com is_from_me: true.