Seções

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:

channelRefexemploquando usar
telefone (E.164 sem +)5511999999999o 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
ts
// 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

Endereçe o envio pelo telefone: 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/{}).

ts
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

Como 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

ts
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

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

ts
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.

ts
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

Oficial → template nativo na Meta. Não-oficial → o broker renderiza este mesmo payload em texto. Se o template não existir no workspace: erro template_not_found.

Janela de 24h

Fora da janela de 24h desde a última mensagem do contato, um número oficial só entrega templates. Texto livre nessa situação falha com 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):

ts
// 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

Botões funcionam nos dois lanes — nativo no oficial; no não-oficial viram botões de resposta rápida do WhatsApp. Lista é nativa no oficial; no não-oficial (WAHA, que não expõe lista interativa) o broker renderiza o mesmo payload em texto — o corpo, o título de cada seção e as linhas numeradas (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

A 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

No evento 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

Para citar uma mensagem, passe 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

Cada item do 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

Não existe envelope compartilhado — cada item de 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.
ts
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

json
{
  "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:

ts
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:

ts
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)

No transporte HTTP por baixo do SDK, o corpo de envio (envelope do lote e cada item de 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

Diferente da admissão (assíncrona — ver o callout acima), a forma de cada item — parse do envio e do seu 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.
ts
// 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.

ts
const m = await wa.messages.get(result.messageId)

Resposta 200

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

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

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

campodescrição
messageIdsempre msg_… — o broker nunca ENTREGA formato de provider (wamid) nos eventos; para responder, use este msg_…
stateestado inicial admitido
traceIdcorrelacionador para diagnóstico
idempotenttrue 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.