Enviar mensagens
Texto, mídia, template e interativo — idempotência e resultado do envio.
Envie por um número connected com messages.send(channelRef, input). O input carrega to (E.164 sem +), um content discriminado por kind, e uma idempotencyKey sua.
Escolher o número: id ou telefone
O 1º argumento (channelRef) aceita duas formas — o broker resolve as duas para o mesmo número vivo do tenant:
| channelRef | exemplo | quando usar |
|---|---|---|
| telefone (E.164 sem +) | 5511999999999 | o padrão recomendado — estável, sobrevive à recriação do número |
ch_… | ch_9f2c… | o handle estável do ciclo de vida; use no oficial ainda não sincronizado (sem number) ou quando quiser um ref que independe do número |
// Pelo NÚMERO do canal (E.164 sem '+') — o PADRÃO: resolve o canal vivo atual
await wa.messages.send('5511999999999', input)
// Por id do canal (handle estável; use no oficial ainda não sincronizado)
await wa.messages.send(channel.id, input) // channel.id === 'ch_…'Use o telefone como padrão
ch_… muda quando o número é recriado (apagar + reconectar por QR gera outro id); o telefone sobrevive — 1 telefone = 1 número vivo por tenant. É a mesma chave estável que os eventos trazem em channelNumber, então você amarra a conversa/ticket por telefone dos dois lados. Um telefone sem número vivo responde channel_not_found (404); se o mesmo telefone tiver dois canais vivos, o broker responde channel_ambiguous (409) pedindo o ch_…. Isso vale também para o oficial: assim que o número é sincronizado da plataforma oficial (a coluna channels.number é preenchida), ele resolve por telefone igual ao não-oficial. O ch_… é o handle sempre estável — use no oficial ainda não sincronizado (sem number) ou quando quiser um ref que independe do número.Idempotência
A idempotencyKey é obrigatória. O broker garante UNIQUE (tenant, idempotencyKey): reenviar a mesma key devolve o resultado do primeiro envio (com idempotent: true) em vez de duplicar a mensagem. Use uma key derivada da sua operação de negócio (ex.: pedido-1234-confirmacao).
metadata: dado seu, opaco, ecoado no message.sent
input.metadata?: Record<string, string> é um mapa opcional e de chaves livres — qualquer propriedade sua (nº de contrato, nome, documento) — que volta idêntico em message.sent.data.metadata, para você correlacionar sem um lookup próprio. O broker trata como totalmente opaco: nunca interpreta, indexa ou loga o conteúdo — só valida a forma (capado em ≤20 chaves, chave ≤64 caracteres, valor ≤512 caracteres, total serializado ≤4096 bytes) e repassa. Ausente → nenhum erro, e a chave metadata sai omitida do evento (nunca null/{}).
await wa.messages.send('5511999999999', {
to: '5511999998888',
content: { kind: 'text', text: 'Seu pedido saiu para entrega.' },
idempotencyKey: 'pedido-1234-saiu',
metadata: { contrato: 'CT-88213', nome: 'Maria Silva', documento: '123.456.789-00' },
})
// no seu webhook: event.data.metadata === { contrato: 'CT-88213', nome: 'Maria Silva', documento: '123.456.789-00' }O mesmo campo, via HTTP puro, está documentado na referência de Mensagens (HTTP) (/api-mensagens).
Dado pessoal em metadata? O erasure também o apaga
metadata costuma carregar dado de correlação (às vezes PII), compliance.erase({ phone }) (LGPD) apaga também o metadata já entregue/pendente do contato — fechando o direito ao apagamento para este campo.Texto
const result = await wa.messages.send('5511999999999', {
// 1º arg = o NÚMERO do seu canal (E.164 sem '+'); 'to' é o destinatário.
to: '5511999998888',
content: { kind: 'text', text: 'Seu pedido saiu para entrega.' },
idempotencyKey: 'pedido-1234-saiu',
})Resposta 202
{
"idempotencyKey": "pedido-1234-saiu",
"idempotent": false,
"messageId": "msg_7b3e1d9a2c4f6081",
"state": "queued",
"traceId": "tr_2f8b6d0a4c1e3597"
}Mídia
Informe o tipo (image / video / audio / document) e uma URL pública. caption e filename são opcionais. (Para baixar a mídia recebida de um contato, veja GET /v1/media/:channelId/:mediaId na referência de Mensagens (HTTP).)
await wa.messages.send('5511999999999', {
to: '5511999998888',
content: {
kind: 'media',
media: 'document',
url: 'https://cdn.example.com/nota-fiscal-1234.pdf',
filename: 'nota-fiscal-1234.pdf',
caption: 'Sua nota fiscal',
},
idempotencyKey: 'pedido-1234-nf',
})Template (comportamento por lane)
Templates são um recurso oficial (Meta): no lane oficial exigem aprovação e são o que permite iniciar conversa fora da janela de 24h (a janela em si só reabre quando o contato responde). No lane não-oficial (WAHA), o mesmo envio de template é renderizado como texto e entregue como mensagem comum — corpo com as variáveis preenchidas, cabeçalho de texto e rodapé, e botões de link viram links no texto (botões de resposta rápida/ligar são descartados). Basta o template existir no workspace — no não-oficial não é preciso aprovação da Meta. Informe name, language e os parâmetros. Veja a seção Templates para o ciclo de aprovação.
await wa.messages.send(channel.id, {
to: '5511999998888',
content: {
kind: 'template',
name: 'confirmacao_pedido',
language: 'pt_BR',
bodyParams: ['1234', 'amanhã'],
},
idempotencyKey: 'pedido-1234-tmpl',
})Mesmo payload, entrega diferente por lane
template_not_found.Janela de 24h
outside_24h_window. No não-oficial não há janela de 24h nem aprovação — o template é renderizado como texto.Interativo e reação
content.kind também aceita interactive (botões ou lista) e reaction (emoji sobre uma mensagem recebida):
// Botões
content: {
kind: 'interactive',
interactive: {
type: 'buttons',
body: 'Confirma o horário?',
buttons: [
{ id: 'sim', title: 'Sim' },
{ id: 'nao', title: 'Não' },
],
},
}
// Lista
content: {
kind: 'interactive',
interactive: {
type: 'list',
body: 'Escolha um horário disponível',
buttonLabel: 'Ver horários',
sections: [
{
title: 'Manhã',
rows: [
{ id: 'h9', title: '09:00' },
{ id: 'h10', title: '10:00', description: 'Último horário da manhã' },
],
},
],
},
}
// Reação — targetMessageId é o msg_… que veio em message.received; o broker
// traduz para o formato do provider internamente.
content: { kind: 'reaction', targetMessageId: receivedMessageId, emoji: '👍' }Interativo: mesmo payload, entrega por lane
1. 09:00, 2. 10:00 — …) para o contato responder o número. Assim o mesmo envio funciona nos dois — rico no oficial, texto no WAHA, exatamente como o template. O buttonLabel (rótulo do botão que abre a lista) não tem sentido em texto e é omitido.Reação: agnóstica e simétrica ao reply — só no oficial
reaction segue o mesmo contrato de replyToProviderId: passe em targetMessageId o msg_… que veio no evento message.received da mensagem que você quer reagir — o broker resolve o formato do provider internamente, você nunca lida com wamid. Diferente do reply (que degrada gracioso sem a citação quando o alvo é desconhecido), a reação não tem "reagir a nada": sem um alvo resolvível o envio falha com o erro terminal invalid_recipient (não é retentado — a mesma corrida não passa a resolver numa nova tentativa). Só funciona no canal oficial; no não-oficial (WAHA) responde unsupported_capability. O nome antigo targetProviderMessageId ainda é aceito como alias deprecated (o broker o normaliza para targetMessageId) — código novo deve usar só targetMessageId.Recebendo uma reação
message.received, uma reação chega com data.message.kind === 'reaction' e data.message.targetMessageId — o msg_… da mensagem reagida, já traduzido (o wamid cru nunca é entregue). Quando o alvo não passou pelo broker (mensagem anterior à integração, ou de outro canal), o campo vem ausente e targetMessageUnknown: true sinaliza o motivo — o mesmo padrão de replyTo/replyToUnknown.Responder uma mensagem recebida
replyToProviderId com o mesmo messageId (msg_…) que veio no evento message.received — o broker resolve o wamid internamente, você não precisa dele. Se a citada é desconhecida, o envio degrada gracioso (vai sem a citação, não falha).Envio em lote
messages.sendBatch(channelRef, { messages: [...] }) manda uma lista de envios: cada item é idêntico ao input de send (to, content, idempotencyKey obrigatória, opcionalmente replyToProviderId, ttlSeconds, metadata) — não há um content comum ao lote, então itens podem ter kind diferentes entre si. O channelRef aceita id (ch_…) ou número (E.164 sem +), igual a send — o lote inteiro resolve um único número. Máximo de 2000 itens por chamada, num corpo de até 512KB.
Admissão assíncrona: o retorno só confirma o aceite
results volta com status: 'accepted' e o messageId já atribuído — a chamada confirma só que o item entrou na fila de admissão, nunca o desfecho final. Fila, cota, canal e compliance são resolvidos depois, por um worker; acompanhe o resultado real de cada mensagem pelo evento message.status do seu webhook (uma falha de admissão chega como failed, nunca como um segundo retorno desta chamada).metadata, idempotencyKey, replyToProviderId: sempre por item
messages é um envio completo e independente. metadata mora dentro de cada item: contrato/nome/documento é dado por pessoa, nunca compartilhado entre os envios do lote.const { results } = await wa.messages.sendBatch('5511999999999', {
messages: [
{
to: '5511999990001',
idempotencyKey: 'promo-a',
content: { kind: 'text', text: 'Promoção só hoje!' },
metadata: { contrato: 'CT-001' },
},
{
to: '5511999990002',
idempotencyKey: 'promo-b',
content: { kind: 'text', text: 'Promoção só hoje!' },
metadata: { contrato: 'CT-002' },
},
],
})
// todo item veio 'accepted' — o desfecho real chega pelo webhook message.status.
for (const item of results) console.log('aceito', item.messageId)Resposta 202
{
"results": [
{
"idempotencyKey": "promo-a",
"index": 0,
"messageId": "msg_7b3e1d9a2c4f6081",
"status": "accepted"
},
{
"idempotencyKey": "promo-b",
"index": 1,
"messageId": "msg_7b3e1d9a2c4f6081",
"status": "accepted"
}
]
}Lote heterogêneo: text + template + media no mesmo envio
Como cada item carrega o seu próprio content, um lote não precisa ser uniforme — é só uma lista de envios independentes disparados numa única chamada. Nada impede misturar um lembrete de texto, uma confirmação por template (com bodyParams dentro do content, como no envio unitário) e uma mídia no mesmo lote:
await wa.messages.sendBatch('5511999999999', {
messages: [
{ to: '5511900000001', idempotencyKey: 'het-text',
content: { kind: 'text', text: 'Olá!' } },
{ to: '5511900000002', idempotencyKey: 'het-tpl',
content: { kind: 'template', name: 'confirmacao_pedido', language: 'pt_BR',
bodyParams: ['Ana', '1234'] } },
{ to: '5511900000003', idempotencyKey: 'het-media',
content: { kind: 'media', media: 'image', url: 'https://cdn.exemplo.com/nf.png' } },
],
})Template com params dentro do content
Quando um item tem content.kind === 'template', os parâmetros de renderização — bodyParams, headerText, headerMediaUrl, buttonParams — vão dentro do próprio content, igual ao envio unitário: não há mais personalização "por fora" nem herança entre itens. Um lote de template é só um caso particular do lote heterogêneo acima, em que todo item aponta para o mesmo name/language com bodyParams diferentes:
await wa.messages.sendBatch('5511999999999', {
messages: [
{ to: '5511900000001', idempotencyKey: 'pedido-a',
content: { kind: 'template', name: 'confirmacao_pedido', language: 'pt_BR',
bodyParams: ['Ana', '1234'] } },
{ to: '5511900000002', idempotencyKey: 'pedido-b',
content: { kind: 'template', name: 'confirmacao_pedido', language: 'pt_BR',
bodyParams: ['Bruno', '5678'] } },
],
})Corpo estrito: campo desconhecido é rejeitado (HTTP)
messages) é validado estrito: uma propriedade fora do contrato — um bodyParams deixado fora do content, por exemplo — responde 422 em vez de ser ignorada em silêncio.Validação de forma é atômica: 1 item inválido reprova o lote inteiro
content contra o canal — é checada antes de aceitar qualquer um. O primeiro item com forma inválida (ex.: content ausente, bodyParams vazio, headerText e headerMediaUrl juntos) reprova o lote inteiro com 422 e nada é enviado.Enviar por um pool
Um pool é um agrupamento nomeado de números do tenant. messages.sendToPool(poolRef, input) tem a mesma assinatura de send — só troca o número do canal pelo poolRef: o slug do pool (recomendado, ex.: pool-de-vendas) ou o id pl_…. O slug é fixo — não muda quando o pool é renomeado — e o nome cru do pool não resolve mais. O broker escolhe, a cada envio, o número menos usado recentemente (LRU) e saudável do pool. Se nenhum número do pool estiver saudável, o envio falha com no_available_channel (409) — nada é admitido; para o catálogo de pools e o gerenciamento de vínculos, veja a referência HTTP em /api-pools.
sendBatchToPool: contrato ANTIGO, ainda síncrono
messages.sendBatchToPool(poolRef, input) aceita o mesmo corpo de sendBatch, mas o resultado é diferente: o lote por pool continua síncrono e não-atômico na admissão — cada item já volta resolvido, com messageId ou error na própria posição, sem o passo intermediário accepted. Não use o mesmo tipo de resultado de sendBatch para lê-lo.// mesma assinatura de send/sendBatch — troca o número do canal pelo poolRef.
// poolRef = o slug do pool (recomendado; fixo mesmo após renomear).
// O broker dispara pelo número menos usado recentemente (LRU) e saudável do pool.
const result = await wa.messages.sendToPool('pool-de-vendas', {
to: '5511999998888',
content: { kind: 'text', text: 'Olá!' },
idempotencyKey: 'promo-42',
})
// lote: cada item é roteado pelo LRU do pool, de forma independente
const batch = await wa.messages.sendBatchToPool('pool-de-vendas', {
messages: [
{ to: '5511999998888', idempotencyKey: 'promo-a',
content: { kind: 'text', text: 'Promoção!' } },
{ to: '5511988887777', idempotencyKey: 'promo-b',
content: { kind: 'text', text: 'Promoção!' } },
],
})Rastrear uma mensagem
messages.get(messageId) devolve estado e metadado — nunca o conteúdo. Estados e o último erro chegam também pelo evento message.status no seu webhook.
const m = await wa.messages.get(result.messageId)Resposta 200
{
"attempts": 0,
"channelId": "ch_9f2c8a1b0d3e4f50",
"createdAt": "2026-08-04T12:00:00.000Z",
"events": [
{
"brokerTimestamp": "2026-08-04T12:00:00.000Z",
"detail": {
"code": "accepted"
},
"outcome": "applied",
"providerTimestamp": null,
"state": "sent"
}
],
"id": "msg_7b3e1d9a2c4f6081",
"lastError": null,
"state": "sent",
"toAddress": "+5511•••7777",
"traceId": "tr_2f8b6d0a4c1e3597",
"updatedAt": "2026-08-04T12:00:00.000Z"
}O mesmo estado chega ao seu webhook, sem precisar de polling — o evento message.status avança por sent → delivered → read. Quando a entrega falha, ele vem com status: 'failed' e um errorCode da taxonomia:
Evento message.status
{
"brokerTimestamp": "2026-08-04T12:00:00.000Z",
"channelId": "ch_9f2c8a1b0d3e4f50",
"channelKind": "official",
"channelNumber": "5511999999999",
"data": {
"messageId": "msg_7b3e1d9a2c4f6081",
"status": "delivered"
},
"id": "evt_0a7d3f1c9b5e2648",
"provider": "meta",
"providerTimestamp": "2026-08-04T12:00:00.000Z",
"tenantId": "tn_1a5c9e3f7b2d4068",
"traceId": "tr_2f8b6d0a4c1e3597",
"type": "message.status"
}Entregue por POST com o header X-WaBroker-Signature (HMAC) — valide-o antes de confiar no corpo. Veja Verificar a assinatura.
Evento message.status.failed
{
"brokerTimestamp": "2026-08-04T12:00:00.000Z",
"channelId": "ch_9f2c8a1b0d3e4f50",
"channelKind": "official",
"channelNumber": "5511999999999",
"data": {
"errorCode": "provider_rejected",
"messageId": "msg_7b3e1d9a2c4f6081",
"status": "failed"
},
"id": "evt_0a7d3f1c9b5e2648",
"provider": "meta",
"providerTimestamp": "2026-08-04T12:00:00.000Z",
"tenantId": "tn_1a5c9e3f7b2d4068",
"traceId": "tr_2f8b6d0a4c1e3597",
"type": "message.status"
}Entregue por POST com o header X-WaBroker-Signature (HMAC) — valide-o antes de confiar no corpo. Veja Verificar a assinatura.
Campos do resultado de envio
| campo | descrição |
|---|---|
messageId | sempre msg_… — o broker nunca ENTREGA formato de provider (wamid) nos eventos; para responder, use este msg_… |
state | estado inicial admitido |
traceId | correlacionador para diagnóstico |
idempotent | true quando a idempotencyKey já havia sido usada |
- Endereços são sempre E.164 sem
+. - URLs de mídia devem ser públicas e válidas até o momento do envio.