O corpo traz o destinatário em to, o type (text, template, image, video, audio, document, interactive ou location) e o conteúdo correspondente. O mesmo endpoint envia pelo WhatsApp e pelo Direct do Instagram (veja Enviar para o Instagram). A conta é derivada da chave de API, então você nunca passa um id de conta. Os exemplos de envio abaixo usam uma chave de sandbox (bz_sandbox_): funcionam sem número real, sem credenciais da Meta e sem consumir quota; troque para uma chave bz_live_ quando for enviar de verdade. Os exemplos de erro mais adiante (número ambíguo, fora da janela, quota, falha da Meta) ilustram cenários de produção que o sandbox determinístico não reproduz.
Um envio aceito responde 201:
HTTP/1.1 201 Created
{
"id": "0f8a4c1e-5b7d-4e2a-9c3f-1a2b3c4d5e6f",
"wamid": "wamid.HBgNNTUzMTk4ODg4Nzc3NxUCABEYEjA...",
"to": "5531988887777",
"sent_to": "5531988887777",
"status": "sent"
}id é o UUID da mensagem na BotoZap e wamid o id dela na Meta; to é o destinatário que você endereçou (telefone só com dígitos) e sent_to é o endereço que foi de fato à Meta (veja Destinatário). status: "sent" quer dizer que a Meta aceitou o envio, não que entregou: a entrega e a leitura chegam depois, como eventos no seu webhook (veja Eventos de webhook). A resposta ainda pode trazer warnings (ex.: consent_unspecified) e, no sandbox, sandbox: true.
Enviar um texto (sandbox)
Para type: "text", mande o corpo em text.body (até 4096 caracteres). Texto livre só é aceito dentro da janela de 24h (o contato precisa ter enviado uma mensagem nas últimas 24 horas); fora dela, use um template. O número mágico +5500000000001 sempre tem a janela aberta.
Enviar uma mensagem de texto (sandbox)
Testa o envio ponta a ponta sem credenciais Meta e sem custo, usando o número mágico de happy path do sandbox.
curl https://botozap.com.br/api/v1/messages \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+5500000000001",
"type": "text",
"text": {
"body": "Oi! 👋 Tudo bem?"
}
}'Resposta 201
{
"id": "5b0a9d0e-6e1a-4f3b-8c2d-1a9f6e0b7c11",
"wamid": "wamid.HBgNNTU5MTk5OTk5OTk5FQIAERgSM0FCQzFEMkUzRjRBNUI2QzdEAA==",
"to": "5500000000001",
"sent_to": "5500000000001",
"status": "sent",
"sandbox": true
}Chave sandbox (`bz_sandbox_`) nunca chama a Meta e não consome cota. A origem é sempre o número sintético da conta; o destino pode ser qualquer telefone, e a entrega é simulada. Os números mágicos escolhem o cenário.
`to` é o destinatário que você endereçou, só com dígitos; `sent_to` é o endereço usado no envio. No sandbox os dois são iguais.
O número mágico de happy path mantém a janela de 24h sempre aberta. Para outros destinos, texto fora da janela é 422 `outside_window`, como em produção.
Depois do 201, o BotoZap simula o ciclo de vida (delivered → read no happy path) e dispara seu webhook assinado, como em produção.
Enviar um template (sandbox)
Para type: "template", informe o nome do template e o idioma em template.language.code. Templates reengajam contatos fora da janela de 24h. No sandbox qualquer nome é aceito (a Meta não é chamada); em produção, o template precisa existir e estar APPROVED na conta do WhatsApp — veja Templates para criar e acompanhar a aprovação.
Enviar um template (sandbox)
No sandbox, qualquer nome de template é aceito — a Meta não é chamada, então não há catálogo para validar contra.
curl https://botozap.com.br/api/v1/messages \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+5500000000001",
"type": "template",
"template": {
"name": "boas_vindas",
"language": {
"code": "pt_BR"
}
}
}'Resposta 201
{
"id": "5b0a9d0e-6e1a-4f3b-8c2d-1a9f6e0b7c11",
"wamid": "wamid.HBgNNTU5MTk5OTk5OTk5FQIAERgSM0FCQzFEMkUzRjRBNUI2QzdEAA==",
"to": "5500000000001",
"sent_to": "5500000000001",
"status": "sent",
"warnings": [
{
"code": "consent_unspecified",
"message": "Consentimento de marketing não informado para este contato."
}
],
"sandbox": true
}Como no texto, o destino pode ser qualquer telefone: o sandbox simula a entrega sem chamar a Meta. Template não depende da janela de 24h.
Template fora do catálogo da conta conta como MARKETING para a guarda de consentimento. Sem Consentimento de marketing registrado para o contato, o envio segue e a resposta traz `warnings` com `consent_unspecified`.
Em produção (chave live), o template precisa existir e estar APPROVED na WABA; quem recusa um nome desconhecido é a Meta.
Template com variáveis, mídia e botões
Um template com {{1}}, {{2}}… no corpo, cabeçalho de mídia ou botão dinâmico precisa dos valores em template.components, no formato da Cloud API da Meta: um item por parte do template, com os valores em parameters, na ordem das variáveis.
POST /api/v1/messages
{
"to": "+5531988887777",
"type": "template",
"template": {
"name": "pedido_enviado",
"language": { "code": "pt_BR" },
"components": [
{
"type": "header",
"parameters": [
{ "type": "image", "image": { "link": "https://cdn.exemplo.com/pedido.jpg" } }
]
},
{
"type": "body",
"parameters": [
{ "type": "text", "text": "Maria" },
{ "type": "text", "text": "#4821" }
]
},
{
"type": "button",
"sub_type": "url",
"index": "0",
"parameters": [{ "type": "text", "text": "4821" }]
},
{
"type": "button",
"sub_type": "copy_code",
"index": "1",
"parameters": [{ "type": "coupon_code", "coupon_code": "BOTO10" }]
}
]
}
}- corpo —
{ "type": "body", "parameters": [...] }, um{ "type": "text", "text": "..." }por variável, na ordem{{1}},{{2}}…; - cabeçalho de texto com variável — o mesmo formato, em
"type": "header"(só uma variável); - cabeçalho de mídia (
image,videooudocument) — porlinkhttps público ou porid, omedia_idque POST /api/v1/media devolve. É o único lugar onde ummedia_idvale no envio:
{
"type": "header",
"parameters": [
{
"type": "document",
"document": { "id": "1234567890123456", "filename": "boleto-4821.pdf" }
}
]
}- botão de URL dinâmica —
"sub_type": "url"com o trecho variável da URL; botão de copiar código —"sub_type": "copy_code"comcoupon_code; resposta rápida —"sub_type": "quick_reply", opcionalmente com{ "type": "payload", "payload": "..." }. Oindexé a posição do botão no template, como texto ("0","1"…).
A BotoZap faz só uma checagem estrutural antes da Meta: components precisa ser uma lista de objetos, com no máximo 20 itens e 32 KB serializado — fora disso, 422 invalid_request. O resto (quantidade de parâmetros, tipo do cabeçalho, índice do botão) quem valida é a Meta, contra o template aprovado: um erro ali volta como 422 meta_error, com o código da Meta em error.meta_code (ex.: 132000, número de parâmetros diferente do template). No sandbox a Meta não é chamada, então components inválido para o template passa.
Consentimento no envio de template
Template de categoria MARKETING para um Contato com Consentimento de marketing revoked responde 422 consent_revoked. UTILITY e AUTHENTICATION não são bloqueados por essa revogação. Sem registro o envio segue e a resposta traz aviso consent_unspecified. O dev pode declarar a base legal com consent_basis (texto curto): grava granted com origem api e o envio segue.
Erro: consentimento de marketing recusado
Template MARKETING para um Contato com Consentimento de marketing revoked responde 422 consent_revoked. UTILITY e AUTHENTICATION não são bloqueados. Sem registro o envio segue com aviso. consent_basis grava o aceite (origem api) e o envio segue.
curl https://botozap.com.br/api/v1/messages \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+5511988887777",
"type": "template",
"template": {
"name": "promo_semana",
"language": {
"code": "pt_BR"
}
}
}'Resposta 422
{
"error": {
"code": "consent_revoked",
"message": "Este contato recusou mensagens de marketing neste canal.",
"outcome": "rejected",
"retry": "unknown",
"next_step": "Causa ou transitoriedade não comprovada. Correlacione o código seguro, Número e instante com Conexões e Eventos da Conta. HTTP isolado não identifica a causa."
}
}A categoria vem do catálogo de templates da conta. Template fora do catálogo conta como MARKETING.
Declare a base legal com `consent_basis` (texto curto) para gravar o aceite origem `api` e enviar.
Um template MARKETING para um Contato sem registro de consentimento responde 201 com `warnings` contendo `consent_unspecified`.
Criar um template
Enviar e criar são endpoints diferentes. POST /api/v1/messages usa um nome já aprovado; POST /api/v1/templates submete um template novo à Meta. Informe a conexão com waba_connection_id ou phone_number_id — os dois vêm de GET /api/v1/phone_numbers. Sem um dos dois, a API responde 422 missing_connection antes de olhar o restante do payload. Filtros, ciclo de aprovação e o que só o painel faz estão em Templates.
Criar um template
Submete um template à Meta. Informe a WABA com `waba_connection_id` ou `phone_number_id` — os dois vêm de `GET /api/v1/phone_numbers`.
curl https://botozap.com.br/api/v1/templates \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "pedido_confirmado",
"language": "pt_BR",
"category": "UTILITY",
"phone_number_id": "109876543210987",
"components": [
{
"type": "BODY",
"text": "Olá {{1}}, seu pedido foi confirmado.",
"example": {
"body_text": [
[
"Maria"
]
]
}
}
]
}'Resposta 201
{
"data": {
"id": "5b0a9d0e-6e1a-4f3b-8c2d-1a9f6e0b7c11",
"name": "pedido_confirmado",
"language": "pt_BR",
"category": "UTILITY",
"status": "PENDING",
"rejection_reason": null,
"meta_template_id": "123456789012345",
"components": [
{
"type": "BODY",
"text": "Olá {{1}}, seu pedido foi confirmado.",
"example": {
"body_text": [
[
"Maria"
]
]
}
}
],
"waba_connection_id": "5b0a9d0e-6e1a-4f3b-8c2d-1a9f6e0b7c11",
"created_at": "2026-07-14T12:00:00.000Z",
"last_synced_at": "2026-07-14T12:00:00.000Z"
}
}Sem `waba_connection_id` e sem `phone_number_id`: 422 `missing_connection`. Conexão ou número que não é da conta: 404 `not_found`.
`name` só aceita minúsculas, números e underscore (`pedido_confirmado`). `language` no formato `pt_BR`. `category`: `UTILITY`, `MARKETING` ou `AUTHENTICATION`.
O corpo é obrigatório. Variáveis precisam ser sequenciais (`{{1}}`, `{{2}}`) e cada uma leva um valor em `example.body_text`. Payload local inválido é 422 `invalid_template` — a Meta não é chamada.
Em live o 201 volta com `status: PENDING`; só envie o template quando a listagem mostrar `APPROVED`. A Graph recusando o conteúdo é 422 `meta_error`; 5xx da Graph é 502 `meta_error`.
Chave sandbox cria o template já `APPROVED`, sem chamar a Meta. Use a conexão/número sandbox da conta — misturar live e sandbox é 404.
Nome e idioma que já existem no catálogo da conexão: 409 `duplicate`, em live e no sandbox, antes de chamar a Meta. O template existente não é alterado.
Um UUID inventado não cai em missing_connection: conexão ou número que não é da conta é 404 not_found.
Erro: criar template sem conexão
O POST de template não adivinha a WABA. Sem `waba_connection_id` e sem `phone_number_id`, a API recusa antes de validar o restante do payload.
curl https://botozap.com.br/api/v1/templates \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "pedido_confirmado",
"language": "pt_BR",
"category": "UTILITY",
"components": [
{
"type": "BODY",
"text": "Olá, seu pedido foi confirmado."
}
]
}'Resposta 422
{
"error": {
"code": "missing_connection",
"message": "Informe 'waba_connection_id' ou 'phone_number_id' para criar o template."
}
}Pegue os dois ids em `GET /api/v1/phone_numbers`: `waba_connection_id` é o UUID da conexão; `phone_number_id` é o id Meta do número. Basta um dos dois.
Enviar mídia por link
Para image, video, audio e document, mande um objeto com o mesmo nome do tipo e um link https público. Quem baixa o arquivo é a Meta, no instante do envio: o link precisa estar acessível nessa hora.
POST /api/v1/messages
{
"to": "+5531988887777",
"type": "document",
"document": {
"link": "https://cdn.exemplo.com/boleto-4821.pdf",
"caption": "Seu boleto do pedido #4821",
"filename": "boleto-4821.pdf"
}
}link— obrigatório,https://, até 2048 caracteres. Sem o objeto do tipo, ou semlink, 422missing_media; link que não é https, 422invalid_request;caption— opcional, até 1024 caracteres, em imagem, vídeo e documento. Áudio não tem legenda:captionnum áudio é 422invalid_request;filename— opcional, só em documento, até 240 caracteres (vazio = a Meta deriva o nome do link); nos outros tipos é ignorado.
Mensagem de mídia não aceita media_id. Um objeto com id e sem link é recusado antes da Meta. O media_id de POST /api/v1/media serve para o cabeçalho de template (acima):
POST /api/v1/messages
{ "to": "+5531988887777", "type": "image", "image": { "id": "1234567890123456" } }
HTTP/1.1 422 Unprocessable Entity
{
"error": {
"code": "invalid_request",
"message": "Envio de 'image' por media id ainda não é suportado; informe 'image.link' (URL https pública).",
"outcome": "rejected",
"retry": "after_correction",
"next_step": "Corrija o campo indicado na resposta conforme o contrato de POST /api/v1/messages antes de tentar novamente."
}
}No WhatsApp a BotoZap não sonda o link antes de enviar: o tamanho e o formato quem confere é a Meta, e um arquivo recusado volta como 422 meta_error. Os tetos da Cloud API por tipo:
| Tipo | Tamanho máximo | Legenda |
|---|---|---|
image (imagem) | 5 MB | sim, até 1024 caracteres |
video (vídeo) | 16 MB | sim, até 1024 caracteres |
audio (áudio) | 16 MB | não |
document (documento) | 100 MB | sim, até 1024 caracteres |
Mídia é resposta livre: vale a janela de 24h, como no texto. O SDK expõe messages.sendMedia com os mesmos campos; o SDK e o MCP apenas delegam ao endpoint canônico — não baixam o arquivo no processo do agente.
await boto.messages.sendMedia({
to: "+5531988887777",
type: "image",
link: "https://cdn.exemplo.com/pedido.jpg",
caption: "Seu pedido ficou pronto",
});A mídia que o contato manda (e como baixá-la) está em Mídia.
Botões, listas e botão de link
Dentro da janela de 24h, o WhatsApp aceita mensagens interativas livres: é o caminho para um agente de IA oferecer opções sem template. Mande type: "interactive" e o objeto interactive no mesmo formato da Cloud API da Meta. A BotoZap valida a forma e os limites antes de chamar a Meta e envia só os campos do contrato; campo desconhecido é 422 invalid_request com o nome do campo, não campo ignorado.
Botões de resposta (interactive.type: "button"): até 3 botões. O toque do contato chega como mensagem interactive com content.button_reply.id igual ao id do botão.
Enviar botões de resposta (sandbox)
Até 3 botões de resposta dentro da janela de 24h, no mesmo formato da Cloud API. O toque do contato volta como mensagem `interactive` com `content.button_reply.id`.
curl https://botozap.com.br/api/v1/messages \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+5500000000001",
"type": "interactive",
"interactive": {
"type": "button",
"header": {
"type": "text",
"text": "Pedido #4821"
},
"body": {
"text": "Podemos entregar amanhã entre 9h e 12h?"
},
"footer": {
"text": "Loja Exemplo"
},
"action": {
"buttons": [
{
"type": "reply",
"reply": {
"id": "entrega_confirmar",
"title": "Confirmar"
}
},
{
"type": "reply",
"reply": {
"id": "entrega_remarcar",
"title": "Remarcar"
}
}
]
}
}
}'Resposta 201
{
"id": "5b0a9d0e-6e1a-4f3b-8c2d-1a9f6e0b7c11",
"wamid": "wamid.HBgNNTU5MTk5OTk5OTk5FQIAERgSM0FCQzFEMkUzRjRBNUI2QzdEAA==",
"to": "5500000000001",
"sent_to": "5500000000001",
"status": "sent",
"sandbox": true
}Mensagem livre, como o texto: fora da janela de 24h é 422 `outside_window`, e no Free conta no teto de respostas 1:1.
Header de mídia (`image`, `video`, `document`) só por `link` https. Campo fora do contrato é 422 `invalid_request` com o nome do campo.
Lista (interactive.type: "list"): um botão que abre até 10 opções, em até 10 seções. A escolha chega com content.list_reply.id.
Enviar uma lista de opções (sandbox)
Um botão que abre até 10 opções, em até 10 seções. A escolha volta como mensagem `interactive` com `content.list_reply.id`.
curl https://botozap.com.br/api/v1/messages \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+5500000000001",
"type": "interactive",
"interactive": {
"type": "list",
"body": {
"text": "Escolha o melhor horário para a visita técnica."
},
"action": {
"button": "Ver horários",
"sections": [
{
"title": "Manhã",
"rows": [
{
"id": "visita_0900",
"title": "09:00",
"description": "Técnica Ana"
},
{
"id": "visita_1100",
"title": "11:00"
}
]
},
{
"title": "Tarde",
"rows": [
{
"id": "visita_1500",
"title": "15:00"
}
]
}
]
}
}
}'Resposta 201
{
"id": "5b0a9d0e-6e1a-4f3b-8c2d-1a9f6e0b7c11",
"wamid": "wamid.HBgNNTU5MTk5OTk5OTk5FQIAERgSM0FCQzFEMkUzRjRBNUI2QzdEAA==",
"to": "5500000000001",
"sent_to": "5500000000001",
"status": "sent",
"sandbox": true
}Com mais de uma seção, cada uma precisa de `title`. Os `id` das opções são únicos na lista inteira.
Botão de link (interactive.type: "cta_url"): um botão que abre uma URL https. O toque não volta como mensagem.
Enviar um botão de link (sandbox)
curl https://botozap.com.br/api/v1/messages \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+5500000000001",
"type": "interactive",
"interactive": {
"type": "cta_url",
"body": {
"text": "Seu pedido saiu para entrega. Acompanhe pelo link."
},
"action": {
"name": "cta_url",
"parameters": {
"display_text": "Rastrear pedido",
"url": "https://loja.exemplo.com/pedidos/4821"
}
}
}
}'Resposta 201
{
"id": "5b0a9d0e-6e1a-4f3b-8c2d-1a9f6e0b7c11",
"wamid": "wamid.HBgNNTU5MTk5OTk5OTk5FQIAERgSM0FCQzFEMkUzRjRBNUI2QzdEAA==",
"to": "5500000000001",
"sent_to": "5500000000001",
"status": "sent",
"sandbox": true
}| Campo | Limite |
|---|---|
header | Opcional. Em button e cta_url: text (até 60 caracteres), image, video ou document por link https (sem media id). Em list, só text. |
body.text | Obrigatório. Até 1024 caracteres em button e cta_url; 4096 em list. |
footer.text | Opcional, até 60 caracteres. |
action.buttons[].reply | 1 a 3 botões; title até 20 caracteres, id até 256; ids e títulos únicos. |
action.button (lista) | Obrigatório, até 20 caracteres. |
action.sections (lista) | 1 a 10 seções e até 10 opções somando todas; title da seção até 24 caracteres, obrigatório com mais de uma seção. Em cada opção: id até 200 (único na lista), title até 24, description opcional até 72. |
action.parameters (cta_url) | display_text até 20 caracteres; url https. action.name é "cta_url". |
Outros subtipos da Cloud API (flow, product, product_list, location_request_message, carousel...) respondem 422 unsupported_type com o motivo:
Erro: subtipo interativo não suportado
Subtipos da Cloud API que a BotoZap não envia (Flows, produto, catálogo, pedido de localização...) são recusados antes da Meta.
curl https://botozap.com.br/api/v1/messages \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+5500000000001",
"type": "interactive",
"interactive": {
"type": "flow",
"body": {
"text": "Preencha seu cadastro"
},
"action": {
"name": "flow",
"parameters": {}
}
}
}'Resposta 422
{
"error": {
"code": "unsupported_type",
"message": "'interactive.type' 'flow' não é suportado: WhatsApp Flows não são enviados pela API do BotoZap. Use 'button', 'list' ou 'cta_url'.",
"outcome": "rejected",
"retry": "after_correction",
"next_step": "Corrija o campo indicado na resposta conforme o contrato de POST /api/v1/messages antes de tentar novamente."
}
}É mensagem livre, com as mesmas regras do texto: vale a janela de 24h e, no Free, o teto de respostas 1:1. O SDK e o MCP ainda não têm método dedicado para interactive e location: use a REST.
Localização
type: "location" manda o cartão de localização. latitude (−90 a 90) e longitude (−180 a 180) em graus decimais, como número ou texto; name (até 1000 caracteres) e address (até 1000) são opcionais. Na listagem, o content guarda as coordenadas como número, a mesma forma da localização recebida.
Enviar uma localização (sandbox)
curl https://botozap.com.br/api/v1/messages \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+5500000000001",
"type": "location",
"location": {
"latitude": -19.9365,
"longitude": -43.9351,
"name": "Loja Savassi",
"address": "Rua Pernambuco, 1000 - Savassi, Belo Horizonte - MG"
}
}'Resposta 201
{
"id": "5b0a9d0e-6e1a-4f3b-8c2d-1a9f6e0b7c11",
"wamid": "wamid.HBgNNTU5MTk5OTk5OTk5FQIAERgSM0FCQzFEMkUzRjRBNUI2QzdEAA==",
"to": "5500000000001",
"sent_to": "5500000000001",
"status": "sent",
"sandbox": true
}Mensagem livre: vale a janela de 24h. `latitude` vai de -90 a 90 e `longitude` de -180 a 180; `name` e `address` são opcionais.
Reagir a uma mensagem
type: "reaction" põe um emoji numa mensagem que o contato enviou. Em reaction.message_id, informe o id (UUID) da mensagem na BotoZap ou o wamid dela; em reaction.emoji, um único emoji. Emoji vazio ("") retira a reação.
Reagir a uma mensagem do contato (WhatsApp)
Reage com um emoji a uma mensagem RECEBIDA deste contato, de até 30 dias. `reaction.message_id` aceita o `id` (UUID) da mensagem na BotoZap ou o `wamid` dela; emoji vazio (`""`) retira a reação.
curl https://botozap.com.br/api/v1/messages \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+5511988887777",
"type": "reaction",
"reaction": {
"message_id": "wamid.HBgNNTUxMTk4ODg4Nzc3NxUCABIYFjNFQjBDMEU0QTlEOEY3QjZDNUE0AA==",
"emoji": "👍"
}
}'Resposta 201
{
"id": "5b0a9d0e-6e1a-4f3b-8c2d-1a9f6e0b7c11",
"wamid": "wamid.HBgNNTU5MTk5OTk5OTk5FQIAERgSM0FCQzFEMkUzRjRBNUI2QzdEAA==",
"type": "reaction",
"to": "5511988887777",
"sent_to": "5511988887777",
"status": "sent",
"reaction": {
"message_id": "5b0a9d0e-6e1a-4f3b-8c2d-1a9f6e0b7c11",
"wamid": "wamid.HBgNNTUxMTk4ODg4Nzc3NxUCABIYFjNFQjBDMEU0QTlEOEY3QjZDNUE0AA==",
"emoji": "👍",
"action": "react"
}
}A reação é gravada como mensagem `reaction` (o `id` da resposta) e o `reaction.message_id` da resposta é o `id` da mensagem alvo.
Para reação a Meta manda só o status `sent`: não espere `delivered` nem `read`.
Vale a janela de 24h (422 `outside_window`). Mensagem de outra conversa é 422 `reaction_target_not_found`; alvo com mais de 30 dias, 422 `reaction_target_expired`.
No Free, reação não conta no teto de respostas 1:1 e segue saindo quando o teto acaba.
- A mensagem alvo precisa ser da mesma conversa (o contato do
to, pelo mesmo Número) e ter no máximo 30 dias — limite da Meta. Mensagem enviada pela empresa, outra reação ou mensagem apagada pelo contato não recebem reação. A API recusa antes da Meta com 422reaction_target_not_found,reaction_target_invalidoureaction_target_expired. - Vale a janela de 24h. A reação não é cobrada pela Meta e, no Free, não conta no teto de respostas 1:1.
- A resposta é 201 e a reação fica gravada como mensagem
reaction, comcontent.message_idigual aowamiddo alvo (a mesma forma da reação que o contato manda). No painel, ela aparece embaixo da mensagem alvo. - Para reação, a Meta manda só o status
sent: não esperedeliverednemread. Se mesmo assim a Meta não entregar (alvo apagado depois do envio, por exemplo), ela avisa por webhook com o erro131009.
O que cada canal envia pela API
A tabela sai da mesma fonte que a API consulta. Um tipo que o canal não envia é recusado antes da Meta: 422 unsupported_type no WhatsApp e channel_not_supported no Instagram. As regras próprias do Instagram estão em Enviar para o Instagram.
type | ||
|---|---|---|
text | envia | envia |
template | envia | não envia — 422 channel_not_supported |
image | envia | envia |
video | envia | envia |
audio | envia | envia |
document | envia | envia |
sticker | não envia — 422 unsupported_type | não envia — 422 channel_not_supported |
interactive | envia | não envia — 422 channel_not_supported |
location | envia | não envia — 422 channel_not_supported |
contacts | não envia — 422 unsupported_type | não envia — 422 channel_not_supported |
reaction | envia | envia |
- Botões, listas e botão de link saem como
interactivedentro da janela (veja acima); fora dela, só pelos botões de um template aprovado. reactionsai como reação a uma mensagem recebida (veja acima).contactsestickernão saem pela API do WhatsApp. Quando o contato os manda, eles chegam normalmente, com essetype.- Não há endpoint para marcar mensagem como lida nem para mostrar "digitando…".
Escolher o número de origem
O campo from aceita o phone_number_id Meta ou o UUID interno do Número. É opcional quando a conta tem um único número conectado; com mais de um número, é obrigatório. Só contam números de conexão ativa e do mesmo ambiente da chave (sandbox ou produção).
Enviar por um número específico (`from`)
Contas com mais de um número conectado precisam informar `from` (o `phone_number_id` Meta ou o UUID interno) para desambiguar por qual número enviar.
curl https://botozap.com.br/api/v1/messages \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+5511988887777",
"type": "template",
"template": {
"name": "pedido_confirmado",
"language": {
"code": "pt_BR"
}
},
"from": "112233445566778"
}'Resposta 201
{
"id": "5b0a9d0e-6e1a-4f3b-8c2d-1a9f6e0b7c11",
"wamid": "wamid.HBgNNTU5MTk5OTk5OTk5FQIAERgSM0FCQzFEMkUzRjRBNUI2QzdEAA==",
"to": "5511988887777",
"sent_to": "5511988887777",
"status": "sent"
}`from` é obrigatório quando a conta tem mais de um número conectado — sem ele a API responde 422 `ambiguous_phone`.
Use o `phone_number_id` Meta ou o UUID interno retornado por `GET /api/v1/phone_numbers`.
Sem from numa conta com mais de um número, a API recusa:
Erro: número de origem ambíguo
Quando a conta tem mais de um número conectado e `from` não é informado, a API recusa em vez de adivinhar por qual número enviar.
curl https://botozap.com.br/api/v1/messages \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+5511988887777",
"type": "template",
"template": {
"name": "pedido_confirmado",
"language": {
"code": "pt_BR"
}
}
}'Resposta 422
{
"error": {
"code": "ambiguous_phone",
"message": "Mais de um número conectado; informe 'from' (phone_number_id).",
"outcome": "rejected",
"retry": "after_correction",
"next_step": "Informe em 'from' o Número correto da Conta e do ambiente da chave."
}
}Toda recusa de `POST /api/v1/messages` traz, além de `code` e `message`, `outcome` (`rejected`: a mensagem não saiu; `unknown`: a Meta pode tê-la recebido), `retry` e `next_step`.
Antes de enviar: o número está pronto?
GET /api/v1/phone_numbers/{id} aceita tanto o UUID interno do Número quanto o phone_number_id da Meta, e traz connection_status, token_status e quality_rating. Para uma resposta de "dá para enviar agora?", use GET /api/v1/phone_numbers/{id}/health (escopo numbers:read, chave de produção; também aceita os dois ids). Com a conexão ativa, ela faz uma checagem ao vivo do token na Meta; bloqueio ou conexão revogada não viram saudáveis por causa de um token válido.
GET /api/v1/phone_numbers/106540352242922/health
HTTP/1.1 200 OK
{
"data": {
"status": "unhealthy",
"timestamp": "2026-09-25T14:02:11.000Z",
"error": "…motivo… …ação…",
"checks": { "token_validity": "invalid" },
"readiness": {
"state": "reconnect_required",
"reason": "token_invalid",
"next_action": "reconnect",
"observed_at": "2026-09-25T14:02:11.000Z",
"stale": false,
"live_check": "invalid",
"title": "…",
"detail": "…",
"action": "…"
},
"phone_number_id": "106540352242922",
"customer_id": "7c1d…",
"waba_connection_id": "3b9e…"
}
}| Campo | Valores |
|---|---|
status | healthy (pronto, ou sandbox), degraded (sem observação suficiente da Meta ainda), unhealthy (reconectar ou bloqueio da Meta) e error (não deu para ler ou checar agora). Fora de healthy, error resume o motivo e a ação. |
readiness.state | ready, reconnect_required, blocked, unknown, unavailable ou sandbox; o porquê vem em readiness.reason (ex.: token_invalid, connection_revoked, account_restricted). |
readiness.next_action | reconnect, resolve_in_meta, sync, retry_read ou none. |
readiness.live_check | ok (token válido agora), invalid (a Meta recusou o token), missing (sem token guardado), unavailable (a Meta não respondeu; nada foi concluído) ou skipped (sem checagem: sandbox, conexão inativa ou bloqueada). |
readiness.title, detail, action | Textos em português, os mesmos do painel, prontos para mostrar ao seu usuário. detail e action podem ser null. |
readiness.observed_at, stale | Quando o estado foi observado; stale: true quando a observação tem mais de 24h (informativo, não bloqueia). |
checks.token_validity | valid, invalid ou missing; só aparece quando houve checagem ao vivo. |
A janela de atendimento de 24h
Todo envio que não é template — texto, mídia (image, video, audio, document), interactive, location e reaction — só é aceito enquanto a janela de 24h está aberta, contada desde a última mensagem que o contato enviou para você. A API recusa com 422 outside_window antes de chamar a Meta, sem gastar quota:
Erro: fora da janela de 24h
Todo envio que não é template (texto, `image`, `video`, `audio` ou `document`) só é permitido enquanto a janela de atendimento (24h desde a última mensagem do contato) está aberta. Fora dela, a API recusa ANTES de chamar a Meta.
curl https://botozap.com.br/api/v1/messages \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+5511977776666",
"type": "text",
"text": {
"body": "Oi, tudo bem?"
}
}'Resposta 422
{
"error": {
"code": "outside_window",
"message": "Fora da janela de 24h: este contato não enviou mensagem nas últimas 24h. Envie um template (type: 'template') para reengajar.",
"outcome": "rejected",
"retry": "after_correction",
"next_step": "Use um Template aprovado ou aguarde uma nova mensagem do Contato para abrir a janela de 24h."
}
}Para reengajar um contato fora da janela, envie um template aprovado (`type: "template"`). A recusa não consome cota.
Para reengajar um contato fora da janela, envie um template aprovado. No sandbox, o número +5500000000003 responde automaticamente ao seu envio (abrindo a janela de verdade), para você testar esse fluxo ponta a ponta.
Destinatário: telefone ou BSUID
O campo to aceita um número em formato internacional (E.164, ex.: +5531988887777) ou um BSUID do WhatsApp (ex.: BR.1A2B...). O BSUID é um identificador opaco que a Meta usa quando o contato não expõe o telefone; trate os dois como uma chave e nunca remova caracteres não numéricos, pois isso destrói o BSUID. Veja Conceitos para os detalhes.
Ao enviar, a BotoZap resolve o contato pelas identidades que já conhece (telefone e BSUID da mesma pessoa) e entrega pelo endereço com melhor chance de entrega. Hoje isso significa preferir o telefone: a Meta aceita o envio para BSUID, mas marca a mensagem como failed (erro 131026) quando o destinatário não tem username ativo. Na prática: BSUID de um contato cujo telefone conhecemos sai pelo telefone; contato que só tem BSUID sai pelo BSUID; destinatário que nunca vimos sai exatamente como você mandou. A resposta do envio traz o endereço usado no campo sent_to. Para desligar a normalização num envio específico, passe to_mode: "raw" no corpo.
Uma ressalva importante: templates de autenticação (OTP) exigem número de telefone. Se o destinatário é um BSUID cujo telefone conhecemos, a normalização acima resolve e o envio segue pelo telefone. Sem telefone conhecido, a API bloqueia o envio com o código auth_template_bsuid.
Enviar para o Instagram
O mesmo endpoint atende os dois canais. O campo to aceita o IGSID do contato (o identificador opaco dele na sua conta profissional, de 16 a 32 dígitos; com 7 a 15 dígitos o endereço é lido como telefone do WhatsApp), e o canal do envio sai da forma do endereço — from só é necessário quando a conta tem mais de uma conta do Instagram ativa.
Responder um direct do Instagram
O mesmo endpoint envia pelos dois canais. `to` aceita o IGSID do contato; o canal sai da forma do endereço, e `from` só é necessário quando a conta tem mais de uma Conta de canal do mesmo canal.
curl https://botozap.com.br/api/v1/messages \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "17841400000000123",
"type": "text",
"text": {
"body": "Tem sim! Sábado às 10h está livre 😊"
}
}'Resposta 201
{
"id": "5b0a9d0e-6e1a-4f3b-8c2d-1a9f6e0b7c11",
"wamid": null,
"external_id": "aWdEZ...mlk",
"channel": "instagram",
"type": "text",
"to": "17841400000000123",
"sent_to": "17841400000000123",
"status": "sent"
}Dentro da janela de 24h a resposta é livre. Fora dela a API responde 422 `window_closed` com a data limite: até 7 dias depois da última mensagem do contato ainda dá para responder com `tag: "human_agent"` — etiqueta em liberação: por enquanto, fora das 24h é sempre 422 `window_closed`.
O canal aceita até 1000 BYTES de texto (acento e emoji contam mais de um byte); acima disso a API responde 422 `text_too_long` sem chamar a Meta.
`template`, `interactive`, `location`, `contacts` e sticker próprio não existem no Instagram: pedir um deles é 422 `channel_not_supported`.
As regras do Instagram são diferentes das do WhatsApp e a API as aplica antes de chamar a Meta:
- texto até 1000 bytes (acento e emoji contam mais de um byte) — acima disso, 422
text_too_long; - a janela de 24h vale aqui também, mas a saída fora dela é outra: não existe template para reengajar, e sim a etiqueta de atendente humano (logo abaixo);
- imagem até 8 MB (JPEG, PNG ou GIF); áudio, vídeo e PDF até 25 MB — veja Arquivos;
template,interactive,location,contactse sticker próprio não existem no canal — 422channel_not_supported.- texto ou arquivo enviado pela API conta como resposta da equipe, como no WhatsApp: o agente de IA daquela conversa fica pausado até alguém devolver a conversa à IA. Com outro envio em andamento na mesma conversa, a resposta é 409
conversation_dispatch_busy. A reação não pausa nada.
Fora das 24h: atendente humano (em liberação)
Em liberação: a etiqueta depende de uma permissão da Meta que ainda está em análise. Por enquanto, todo envio fora das 24h é 422 window_closed, com ou sem tag: "human_agent" (dentro das 24h a tag é aceita e não muda nada). O que segue descreve o comportamento depois da liberação.
Passadas as 24h desde a última mensagem do contato, o Instagram só aceita a resposta com a etiqueta de atendente humano, e por no máximo 7 dias. A API não aplica a etiqueta sozinha: a Meta restringe o uso a atendimento humano de verdade, então quem declara é você, com tag: "human_agent".
Responder fora das 24h como atendente humano (em liberação)
Em liberação: a etiqueta depende de uma permissão da Meta ainda em análise, e por enquanto este envio é 422 `window_closed`. Depois da liberação: passadas as 24h e antes dos 7 dias, o Instagram só aceita a resposta com a etiqueta de atendente humano. A API nunca a aplica sozinha: a Meta restringe o uso a atendimento humano de verdade, então quem declara é você, com `tag: "human_agent"`.
curl https://botozap.com.br/api/v1/messages \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "17841400000000123",
"type": "text",
"tag": "human_agent",
"text": {
"body": "Oi! Desculpe a demora — consegui o horário que você pediu."
}
}'Resposta 201
{
"id": "5b0a9d0e-6e1a-4f3b-8c2d-1a9f6e0b7c11",
"wamid": null,
"external_id": "aWdEZ...mlk",
"channel": "instagram",
"type": "text",
"to": "17841400000000123",
"sent_to": "17841400000000123",
"status": "sent"
}Sem a etiqueta, o mesmo envio é 422 `window_closed` — e a mensagem do erro traz a data limite.
Depois dos 7 dias a etiqueta não salva mais nada: a resposta é 422 `conversation_closed` e quem reabre a conversa é o contato.
A etiqueta é do Instagram. Num envio pelo WhatsApp, `tag` é recusado com 422 `channel_not_supported`; fora da janela, o WhatsApp reengaja por template.
São três estados, e a régua é sempre a última mensagem do contato:
- dentro das 24h — resposta livre, sem etiqueta nenhuma;
- entre 24h e 7 dias — só com
tag: "human_agent"; sem ela, 422window_closed, e a mensagem do erro traz a data limite; - depois dos 7 dias — 422
conversation_closed: não há saída pelo lado do negócio, quem reabre a conversa é o contato.
A etiqueta é do Instagram. Num envio pelo WhatsApp, tag é recusado com 422 channel_not_supported — lá o caminho fora da janela continua sendo o template aprovado.
Opções de resposta
Uma mensagem do Instagram pode levar até 13 opções de resposta — os chips que o contato responde com um toque. O toque volta como mensagem recebida com o título do chip no corpo, e o payload escolhido chega em context.quick_reply.payload do evento instagram.message.received (o mesmo campo quando o contato toca um botão que a Meta entrega como messaging_postbacks).
Anexar opções de resposta a um direct
Uma mensagem do Instagram pode levar até 13 opções de resposta — os chips que o contato responde com um toque. O toque volta como mensagem recebida com o título do chip, e o `payload` escolhido chega em `context.quick_reply` do evento `instagram.message.received`.
curl https://botozap.com.br/api/v1/messages \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "17841400000000123",
"type": "text",
"text": {
"body": "Tenho estes horários no sábado:"
},
"quick_replies": [
{
"title": "9h",
"payload": "sabado_09"
},
{
"title": "10h30",
"payload": "sabado_1030"
},
{
"title": "Outro horário",
"payload": "sabado_outro"
}
]
}'Resposta 201
{
"id": "5b0a9d0e-6e1a-4f3b-8c2d-1a9f6e0b7c11",
"wamid": null,
"external_id": "aWdEZ...mlk",
"channel": "instagram",
"type": "text",
"to": "17841400000000123",
"sent_to": "17841400000000123",
"status": "sent"
}Até 13 chips por mensagem, com `title` de até 20 caracteres — acima disso, 422 `too_many_quick_replies` ou `quick_reply_title_too_long`, sem chamar a Meta.
`payload` é opcional (até 1000 caracteres): sem ele, o próprio título volta no toque; acima do teto, 422 `quick_reply_payload_too_long`.
`content_type` aceita `text` (o padrão), `user_phone_number` e `user_email`. Nos dois últimos o Instagram preenche o chip com o dado do perfil de quem tocou — não mande `title` nem `payload`.
Erro de forma (lista que não é lista, item sem `title`, `content_type` fora dos três) é 422 `invalid_quick_replies`, com o número do item na mensagem.
Opções de resposta acompanham `type: "text"` e só existem no Instagram: num envio pelo WhatsApp, `quick_replies` é 422 `channel_not_supported` (lá os botões pertencem ao template aprovado).
Só dentro da janela de 24h: fora dela a resposta com `tag: "human_agent"` sai sem chips, e `quick_replies` é 422 `quick_replies_window_closed`.
- até 13 por mensagem — acima disso, 422
too_many_quick_replies; titlede até 20 caracteres — acima disso, 422quick_reply_title_too_long;payloadé opcional (até 1000 caracteres): sem ele, o próprio título volta no toque; acima do teto, 422quick_reply_payload_too_long;content_typeaceitatext(padrão),user_phone_numbereuser_email; nos dois últimos quem preenche o chip é o Instagram, com o dado do perfil de quem tocou.- só dentro da janela de 24h: fora dela a resposta com a etiqueta de atendente humano sai sem chips, e
quick_repliesé 422quick_replies_window_closed.
Qualquer outro problema de forma — a lista não é uma lista, um item sem title, um content_type fora dos três — é 422 invalid_quick_replies, e a mensagem diz qual item da lista recusou.
As opções de resposta acompanham type: "text" (pedi-las em outro tipo também é invalid_quick_replies) e só existem no Instagram: num envio pelo WhatsApp, quick_replies é 422 channel_not_supported — lá os botões pertencem ao template aprovado.
Arquivos
image, video, audio e document funcionam como no WhatsApp: você informa um link https público, e quem busca o arquivo é a Meta, no instante do envio. Não existe media_id na plataforma do Instagram — o link precisa estar acessível quando você chama a API.
- imagem até 8 MB, nos formatos JPEG, PNG e GIF;
- áudio e vídeo até 25 MB;
- documento até 25 MB e só em PDF — o Instagram não tem documento genérico como o WhatsApp;
captioné recusado com 422unsupported_caption: uma mensagem do Instagram leva texto ou anexo, nunca os dois. Mande a legenda como uma segunda mensagem.
Antes de chamar a Meta, a API faz um HEAD curto no link (3 s, sem baixar o arquivo) e recusa com 422 o que o canal não aceita: unsupported_media_type quando o content-type não é um formato do tipo pedido, e media_too_large quando o content-length passa do teto. Se o host não responde ao HEAD ou não manda esses cabeçalhos, o envio segue e quem decide é a Meta — um link acima do teto volta então como 422 meta_error, com a mensagem dela. A sonda é só do Instagram: no WhatsApp o link vai direto para a Meta, sem HEAD prévio (veja Enviar mídia por link).
Um arquivo por mensagem. O Instagram aceita até 10 imagens numa conversa, mas o envio leva um anexo por chamada: para mandar várias, faça uma chamada por arquivo. Cada uma vira uma mensagem própria, com o seu external_id. Anexo e texto também não vão juntos — veja unsupported_caption acima.
No caminho contrário, o anexo que o contato manda (imagem, vídeo, áudio, arquivo e o coraçãozinho) chega em instagram.message.received com o type correspondente, e o arquivo é espelhado no nosso armazenamento. Em content.media.id (e em cada item de content.attachments[]) vem o id da mídia, que você passa em GET /v1/media/{id} para obter uma URL de download assinada — como faz com o media_id do WhatsApp. A url ao lado é a da CDN da Meta e expira em horas: use-a só para consumo imediato. Uma publicação ou um reel compartilhado sem texto chega como type: "unknown" com content.type: "share"; com texto, chega como type: "text" e o compartilhamento vem em content.share. A Meta não entrega o arquivo, mas a mensagem existe e abre a janela de 24h.
O Instagram também aceita type: "reaction": reagir a uma mensagem da conversa com reaction: { message_id, emoji }, onde message_id é o external_id da mensagem alvo. Emoji vazio retira a reação. A resposta é 200 (não 201: nenhuma mensagem nova é criada) e o id é o da mensagem reagida (null quando ela não está no histórico da BotoZap). Sem reaction.message_id, 422 missing_reaction_target; fora da janela de 24h, a reação é recusada com 422 window_closed (ou conversation_closed depois de 7 dias). No WhatsApp a reação tem outro contrato (201 e mensagem gravada): veja Reagir a uma mensagem.
Misturar os canais entre to e from também é recusado, em vez de virar um erro opaco da Meta:
Erro: destinatário e origem de canais diferentes
O canal do envio sai da origem quando `from` é informado. Endereçar um telefone a partir de uma Conta do Instagram (ou um IGSID a partir de um Número) é recusado antes de chegar à Meta.
curl https://botozap.com.br/api/v1/messages \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+5511988887777",
"type": "text",
"text": {
"body": "Oi!"
},
"from": "17841400000000999"
}'Resposta 422
{
"error": {
"code": "channel_mismatch",
"message": "A origem informada é uma Conta do Instagram e o destinatário não é um IGSID. Informe um 'to' do mesmo canal do 'from'.",
"outcome": "rejected",
"retry": "unknown",
"next_step": "Causa ou transitoriedade não comprovada. Correlacione o código seguro, Número e instante com Conexões e Eventos da Conta. HTTP isolado não identifica a causa."
}
}`GET /api/v1/channel_accounts` lista as Contas de canal com `channel`, `display` e o id que serve de `from`.
Com mais de uma conta do Instagram ativa e sem from, a resposta é 422 ambiguous_channel_account (no WhatsApp, o equivalente é ambiguous_phone). Use GET /api/v1/channel_accounts para listar Números e Contas do Instagram com o id que serve de from.
Sandbox do Instagram
Uma chave bz_sandbox_ também envia pelo Instagram, sem conta profissional conectada e sem custo: criar a primeira chave sandbox provisiona uma Conta do Instagram sintética na sua conta (e o envio confere de novo, caso ela falte), e os IGSIDs mágicos abaixo escolhem o cenário. Nada disso chama a Meta — os eventos são injetados no mesmo pipeline que processa os webhooks reais, então o que você testa é o seu handler de verdade, com assinatura HMAC e retry. Detalhe que muda a ordem dos eventos: para a janela de 24h existir, o primeiro envio a um IGSID mágico (ou o primeiro depois que a janela dele vence) injeta antes um direct do contato, então o seu handler recebe um instagram.message.received (com Contato e Conversa) antes do instagram.message.sent desse envio.
| Destinatário (IGSID) | Comportamento |
|---|---|
17000000000000001 | Envio aceito (instagram.message.sent) e confirmação de leitura (instagram.message.read); a janela de 24h fica sempre aberta. |
17000000000000002 | A Meta recusa com o erro 551 (pessoa indisponível): 422 meta_error, mensagem failed na conversa e instagram.message.failed. |
17000000000000003 | Envio aceito e, segundos depois, o contato reage (instagram.message.reaction) e responde (instagram.message.received). Se a mensagem levou opções de resposta, a resposta volta como o toque no primeiro chip de texto, com o payload dele em context.quick_reply.payload (só o content_type: "text" carrega payload — os de perfil quem preenche é o Instagram). |
17000000000000004 | Janela de 24h vencida, dentro dos 7 dias: texto livre é 422 window_closed e o envio só sai com tag: "human_agent" — enquanto a etiqueta estiver em liberação, é 422 window_closed com ou sem ela. |
| qualquer outro IGSID | 422 window_closed — como em produção: no Instagram quem abre a conversa é sempre o contato. |
Envio pelo sandbox não consome a franquia do plano, e a conta sintética é invisível para uma chave bz_live_ (e vice-versa): as leituras de /v1 filtram o ambiente nos dois sentidos, inclusive GET /v1/events.
Erros de envio
Todo erro de POST /api/v1/messages vem no envelope { "error": { "code", "message" } } com quatro campos extras de diagnóstico, para você decidir se pode repetir sem mandar a mensagem duas vezes:
outcome—rejected(recusado antes de a Meta aceitar; a mensagem não saiu) ouunknown(a chamada à Meta começou e o resultado não é conhecido; ela pode ter saído);retry—after_correction(corrija algo antes: campo, plano, conexão),backoff(falha temporária antes da Meta: repita com espera crescente),reconcile_first(consulte a mensagem e os eventos antes de repetir) ouunknown(causa não comprovada: investigue antes de repetir);next_step— a orientação em português, pronta para log ou para um operador;meta_code— o código numérico da Graph API, só quando a Meta respondeu com erro.
A API não repete nada sozinha. Os códigos abaixo estão na ordem em que são avaliados; os de validação vêm antes de qualquer consulta, e a Meta só é chamada depois de todos passarem. Erros comuns a toda a API (401, 500 etc.) estão em Erros.
| HTTP | code | Quando acontece | O que fazer | outcome / retry |
|---|---|---|---|---|
| 403 | forbidden_scope | A chave não tem o escopo messages:send. | Use uma chave com o escopo ou peça ao dono da Conta para ajustá-lo. | rejected / after_correction |
| 429 | rate_limited | Orçamento de requisições da REST /v1 por Conta esgotado. | Respeite o cabeçalho Retry-After e repita com backoff. | rejected / backoff |
| 400 | invalid_json | O corpo não é JSON válido. | Corrija o corpo. | rejected / after_correction |
| 422 | missing_to | to ausente ou fora de formato: não é telefone E.164 nem BSUID. | Informe o telefone em E.164 ou o BSUID. | rejected / after_correction |
| 422 | invalid_request | Campo com forma inválida: to_mode diferente de raw, texto acima de 4096 caracteres, caption em áudio ou acima de 1024, link que não é https, mídia por id, template.components fora do teto, template.language.code malformado, consent_basis inválido, interactive ou location fora do formato ou dos limites (campo desconhecido incluso). | A message diz qual campo; corrija e envie de novo. | rejected / after_correction |
| 422 | unsupported_type | type que o WhatsApp não envia pela API (veja a tabela de tipos), ou interactive.type fora de button, list e cta_url (ex.: flow, product). | Use text, template, image, video, audio, document, interactive, location ou reaction; em interactive, button, list ou cta_url. | rejected / after_correction |
| 422 | missing_text | type: "text" sem text.body. | Mande o texto em text.body. | rejected / after_correction |
| 422 | missing_template | type: "template" sem template.name. | Informe o nome do template aprovado. | rejected / after_correction |
| 422 | missing_media | Tipo de mídia sem o objeto correspondente ou sem link. | Envie { link: "https://..." } no campo do tipo (image, video...). | rejected / after_correction |
| 422 | missing_reaction_target | type: "reaction" sem reaction.message_id. | Informe o id (UUID) ou o wamid da mensagem recebida a reagir. | rejected / after_correction |
| 422 | invalid_reaction | reaction.emoji que não é um único emoji (texto, dígito solto, dois emojis) ou que não é texto. | Mande um emoji só, ou "" para retirar a reação. | rejected / after_correction |
| 422 | no_phone | Nenhum Número ativo no ambiente da chave, ou o from não é um Número ativo da Conta. | Confira o Número em GET /v1/phone_numbers e a Conexão dele. | rejected / after_correction |
| 422 | ambiguous_phone | A Conta tem mais de um Número ativo e o envio não trouxe from. | Informe from com o phone_number_id da Meta ou o UUID do Número. | rejected / after_correction |
| 422 | outside_window | Texto, mídia, interactive, location ou reaction com a janela de 24h fechada. | Envie um template aprovado ou espere o contato escrever. | rejected / after_correction |
| 422 | reaction_target_not_found | reaction.message_id não é uma mensagem da conversa com este contato (inclusive mensagem de outra conversa). | Use o id ou o wamid de uma mensagem que este contato enviou, pelo mesmo Número. | rejected / after_correction |
| 422 | reaction_target_invalid | O alvo é uma mensagem enviada pela empresa, outra reação ou uma mensagem que o contato apagou. | Reaja a uma mensagem recebida do contato. | rejected / after_correction |
| 422 | reaction_target_expired | O alvo tem mais de 30 dias (limite da Meta para reação). | Responda com texto; a Meta não aceita reação a mensagens mais antigas. | rejected / after_correction |
| 422 | account_restricted | Template a partir de uma WABA com banimento ou restrição ativa na Meta. Resposta dentro da janela não passa por essa checagem. | O dono resolve a restrição no WhatsApp Manager; respostas na janela continuam saindo. | rejected / after_correction |
| 503 | capacity_unavailable | Template: não foi possível ler o estado da WABA para a checagem acima. | Falha temporária antes da Meta. Repita com backoff. | rejected / unknown |
| 422 | auth_template_bsuid | Template de autenticação para um contato conhecido só por BSUID. | Envie para o telefone do contato. | rejected / unknown |
| 422 | consent_revoked | Template MARKETING para um Contato com consentimento de marketing revogado. | Não reenvie marketing. Templates UTILITY e AUTHENTICATION seguem liberados. | rejected / unknown |
| 409 | not_connected | O Número não tem token de acesso: a conexão ficou incompleta. | Refaça a conexão do Número no painel. | rejected / after_correction |
| 503 | billing_unavailable | A consulta de plano ou de consumo falhou. | Falha temporária antes da Meta. Repita com backoff, sem mexer no plano. | rejected / backoff |
| 429 | free_form_limit_reached | Plano Free: o teto mensal de respostas 1:1 (texto, mídia, interactive ou location na janela) acabou. Painel, API e agentes de IA somam no mesmo teto. Reação não conta nem é barrada pelo teto. | Assine o BotoZap (1:1 ilimitado) ou espere o próximo mês. Templates seguem pela cota de templates. | rejected / after_correction |
| 402 | quota_exceeded | Template além da cota mensal de templates do plano (todo plano com teto, não só o Free), ou assinatura com pagamento pendente (aí vale para qualquer envio). | Faça upgrade, regularize a cobrança ou espere o próximo período. | rejected / after_correction |
| 409 | conversation_dispatch_busy | Há um envio em andamento, ou um envio de resultado desconhecido aguardando conciliação, na mesma conversa. | Este envio não saiu. Consulte a conversa e os Eventos antes de repetir; um envio desconhecido precisa ser conciliado em Envios pendentes no painel. | rejected / reconcile_first |
| 503 | dispatch_unavailable | Não foi possível abrir o registro de envio da conversa. A Meta não foi chamada. | Repita com backoff. | rejected / backoff |
| 409 | whatsapp_disconnected | A Meta recusou o token da WABA (erro 190 da Graph): expirado ou revogado. | Reconecte o Número em Configurações › Canais no painel. | rejected / after_correction |
| 422 | meta_error | A Meta recusou o envio (resposta 4xx). O código dela vem em meta_code. | Leia a message e o meta_code; corrija antes de repetir. | rejected / unknown |
| 502 | meta_error | A Meta falhou (5xx) ou não respondeu. A mensagem pode ter saído. | Não reenvie às cegas: consulte a conversa e os eventos antes. | unknown / reconcile_first |
| 502 | send_failed | Falha de rede ou inesperada durante a chamada à Meta. A mensagem pode ter saído. | Mesmo cuidado do meta_error 502: concilie antes de repetir. | unknown / reconcile_first |
Limites do plano
São duas réguas separadas. Template (mensagem iniciada pelo negócio) consome a cota mensal de templates do plano — de qualquer plano com teto, não só do Free. Esgotada, o envio de template responde 402 quota_exceeded; o mesmo código aparece em qualquer envio quando a assinatura está com pagamento pendente (veja Transmissões e limites):
Erro: cota mensal de templates esgotada
Template (mensagem iniciada pelo negócio) consome a cota mensal de templates do plano, em qualquer plano com teto. Esgotada a cota, novos templates são recusados antes de chamar a Meta até o próximo período ou um upgrade.
curl https://botozap.com.br/api/v1/messages \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+5511966665555",
"type": "template",
"template": {
"name": "aviso_importante",
"language": {
"code": "pt_BR"
}
}
}'Resposta 402
{
"error": {
"code": "quota_exceeded",
"message": "Limite do plano atingido neste período. Faça upgrade para continuar enviando.",
"outcome": "rejected",
"retry": "after_correction",
"next_step": "Confira Plano e uso da Conta; regularize a cobrança ou aguarde a renovação do limite indicado antes de tentar novamente."
}
}Resposta 1:1 (texto ou mídia dentro da janela) não consome esta cota. No Free ela tem teto próprio, com 429 `free_form_limit_reached`.
Com a assinatura paga em pagamento pendente, qualquer envio responde 402 `quota_exceeded`, com uma mensagem pedindo a regularização.
Chave sandbox não passa por esta cota.
Resposta 1:1 (texto, mídia, interactive ou location dentro da janela) não consome a cota de templates. No BotoZap ela é ilimitada; no Free, o teto é de 500 respostas 1:1 por mês, somando painel, API e agentes de IA. Passado o teto, a resposta é 429 free_form_limit_reached (a reação não conta e continua saindo) — não confunda com o 429 rate_limited, que é o limite de requisições (120 a cada 10 segundos por Conta) e passa sozinho:
Erro: teto de respostas 1:1 do Free
No plano Free, a resposta 1:1 (texto ou mídia dentro da janela de 24h) tem teto de 500 por mês, somando painel, API e agentes de IA. Atingido o teto, a API recusa antes de chamar a Meta. No BotoZap a resposta 1:1 é ilimitada.
curl https://botozap.com.br/api/v1/messages \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+5511944443333",
"type": "text",
"text": {
"body": "Claro, já verifico para você."
}
}'Resposta 429
{
"error": {
"code": "free_form_limit_reached",
"message": "Limite de 500 respostas 1:1 por mês do plano Free atingido. Assine o BotoZap em /plano para respostas ilimitadas.",
"outcome": "rejected",
"retry": "after_correction",
"next_step": "Confira Plano e uso da Conta; regularize a cobrança ou aguarde a renovação do limite indicado antes de tentar novamente."
}
}Não confunda com o 429 `rate_limited`, que é o limite de requisições e passa sozinho. Este só libera no próximo mês ou com a assinatura do BotoZap.
Template não entra neste teto: ele consome a cota de templates (402 `quota_exceeded`).
Se a consulta de cobrança ou de consumo estiver indisponível, a API responde 503 billing_unavailable, com outcome: "rejected" e retry: "backoff". A mensagem foi recusada antes do envio à Meta: tente novamente com backoff. Essa falha temporária não exige upgrade nem alteração da assinatura e vale para WhatsApp e Instagram. Sandbox não passa por nenhuma das duas réguas.
Quando a Meta responde
Uma recusa da Meta (4xx) vem como 422 meta_error com o código dela em meta_code:
HTTP/1.1 422 Unprocessable Entity
{
"error": {
"code": "meta_error",
"message": "<mensagem da Meta>",
"outcome": "rejected",
"meta_code": 132000,
"retry": "unknown",
"next_step": "Causa ou transitoriedade não comprovada. ..."
}
}O token recusado (Graph 190) é a exceção: vira 409 whatsapp_disconnected, e o caminho é reconectar o Número em Configurações › Canais no painel. E quando a própria Meta falha ao processar o envio (timeout ou 5xx), o resultado é ambíguo. Veja com cuidado antes de reenviar:
Erro: a Meta falhou ao processar o envio
A Graph API respondeu com um erro de servidor (5xx) ao tentar enviar a mensagem.
curl https://botozap.com.br/api/v1/messages \
-X POST \
-H "X-API-Key: $BOTOZAP_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+5511955554444",
"type": "template",
"template": {
"name": "status_pedido",
"language": {
"code": "pt_BR"
}
}
}'Resposta 502
{
"error": {
"code": "meta_error",
"message": "Erro interno do Graph.",
"outcome": "unknown",
"retry": "reconcile_first",
"next_step": "Consulte a mensagem e os Eventos da Conta antes de repetir. Aceitação não prova entrega; um resultado desconhecido pode ter sido aceito pela Meta."
}
}`outcome: "unknown"` e `retry: "reconcile_first"`: confira o resultado antes de repetir. Quando a Meta informa um código de erro, ele vem em `meta_code`.
Um 502 (ou timeout) aqui NÃO prova que a mensagem não saiu — a Meta pode tê-la aceitado do lado dela mesmo assim.
Sem identificador confirmado, a mensagem não aparece em `GET /api/v1/messages` e a cota do período não é debitada. O BotoZap mantém um recibo de envio desconhecido e bloqueia novos envios nessa conversa com 409 `conversation_dispatch_busy`. Um gestor deve conferir o resultado e registrar a evidência em Envios pendentes antes de tentar novamente.
O contrato completo de confiabilidade e os casos ambíguos de envio estão em Confiabilidade e entrega.
Listar e buscar mensagens
GET /api/v1/messages lista as mensagens da conta, mais recentes primeiro, paginada por cursor (limit de 1 a 100, padrão 20; after/before com os cursores de paging):
Listar mensagens
Lista as mensagens da conta, mais recentes primeiro, paginado por cursor. `sort=created_at` (padrão) ordena pela chegada ao BotoZap; `sort=event_at`, pela data real da mensagem.
curl https://botozap.com.br/api/v1/messages?limit=2 \
-H "X-API-Key: $BOTOZAP_KEY"Resposta 200
{
"data": [],
"paging": {
"cursors": {
"before": "MjAyNi0wNy0xNFQxMjowMDowMC4wMDBa",
"after": "MjAyNi0wNy0xNFQxMjowMDowMC4wMDBa"
},
"next": "MjAyNi0wNy0xNFQxMjowMDowMC4wMDBa",
"previous": "MjAyNi0wNy0xNFQxMjowMDowMC4wMDBa"
}
}`sort=created_at` (padrão) é a ordem de chegada ao BotoZap: use-a para sincronizar com `before`, porque nenhuma mensagem entra atrás de um cursor já lido. Nessa ordem, o histórico importado do app na coexistência (`source: "history"`) aparece como recente, porque chegou agora.
`sort=event_at` ordena pela data real da mensagem (`event_at`) — o jeito de mostrar uma conversa em ordem cronológica com o histórico importado no lugar certo. O cursor carrega o valor da ordem que o gerou (em `event_at`, junto com o `id`, para mensagens do mesmo segundo não se perderem entre páginas): use-o com o mesmo `sort`. Outro valor em `sort` responde 422 `invalid_request`.
| Filtro | Aceita | Valor que não casa |
|---|---|---|
phone_number_id | O phone_number_id da Meta ou o UUID do Número. | Número que não é da conta: página vazia. |
conversation_id | UUID da conversa (o id de GET /api/v1/conversations). | Não-UUID ou de outra conta: página vazia. |
direction | inbound ou outbound. | Ignorado (lista sem o filtro). |
status | queued, sent, delivered, read, failed ou received. | Ignorado. |
message_type | O type da mensagem: text, image, video, audio, document, sticker, location, contacts, interactive, button, reaction, order, template, system ou unknown. | Ignorado. |
has_media | true (imagem, vídeo, áudio, documento ou sticker) ou false. | Ignorado. |
channel | whatsapp ou instagram. | 422 invalid_channel. |
channel_account_id | UUID da Conta de canal. | Não-UUID: 422 invalid_channel_account; de outra conta: página vazia. |
Repare na diferença: filtro com valor fora da lista é ignorado (você recebe tudo, não nada). Confira a grafia antes de tratar a lista como filtrada. A chave sandbox só vê as mensagens do sandbox, e a de produção só as de produção.
Ordem da listagem: sort
sort=created_at (o padrão) ordena pela chegada ao BotoZap. É a ordem para sincronizar: com before, você pega o que chegou depois do último cursor, e nada entra atrás de um cursor já lido — nem uma mensagem que a Meta reentregou com atraso.
sort=event_at ordena pela data real da mensagem (event_at: o horário da Meta quando existe, senão a chegada). Use para mostrar uma conversa em ordem cronológica. A diferença aparece no histórico importado na coexistência: ele chega agora, mas as mensagens são de até 180 dias atrás. Em created_at elas aparecem como as mais recentes; em event_at, no lugar certo da conversa. O cursor carrega o valor da ordem que o gerou, então use-o com o mesmo sort; em event_at ele também leva o id da mensagem, para que mensagens do mesmo segundo não se percam entre páginas. Outro valor em sort responde 422 invalid_request.
{id} em GET /api/v1/messages/{id} aceita o UUID interno da mensagem ou o wamid da Meta:
Buscar uma mensagem por id
`{id}` aceita o uuid interno da mensagem OU o `wamid` da Meta.
curl https://botozap.com.br/api/v1/messages/5b0a9d0e-6e1a-4f3b-8c2d-1a9f6e0b7c11 \
-H "X-API-Key: $BOTOZAP_KEY"Resposta 200
{
"data": {
"id": "5b0a9d0e-6e1a-4f3b-8c2d-1a9f6e0b7c11",
"wamid": "wamid.HBgNNTU5MTk5OTk5OTk5FQIAERgSM0FCQzFEMkUzRjRBNUI2QzdEAA==",
"external_id": "wamid.HBgNNTU5MTk5OTk5OTk5FQIAERgSM0FCQzFEMkUzRjRBNUI2QzdEAA==",
"channel": "whatsapp",
"channel_account": {
"id": "5b0a9d0e-6e1a-4f3b-8c2d-1a9f6e0b7c11",
"channel": "whatsapp",
"display": "+5511999990000"
},
"conversation_id": "5b0a9d0e-6e1a-4f3b-8c2d-1a9f6e0b7c11",
"phone_number_id": "5b0a9d0e-6e1a-4f3b-8c2d-1a9f6e0b7c11",
"contact_id": "5b0a9d0e-6e1a-4f3b-8c2d-1a9f6e0b7c11",
"direction": "outbound",
"type": "text",
"status": "sent",
"source": "api",
"content": {
"body": "Mensagem de teste"
},
"context": null,
"error": null,
"has_media": false,
"revoked_at": null,
"event_at": "2026-07-14T12:00:00.000Z",
"wa_timestamp": null,
"created_at": "2026-07-14T12:00:00.000Z"
}
}Mensagens editadas e apagadas no Instagram
Quando o contato edita um direct, a mensagem passa a trazer o texto novo em content (body no texto, caption na mídia) e content.edited com { count, at }: quantas vezes ela foi editada e quando foi a última edição. Quando ele cancela o envio, a mensagem ganha revoked_at com o horário. As duas mudanças também chegam como os Eventos instagram.message.edited e instagram.message.revoked, por GET /api/v1/events e pelo webhook; veja Eventos. O SDK tipa content.edited e revoked_at.
De onde veio a mensagem: source
Cada mensagem traz source, que diz por qual caminho ela entrou na conta. Não há filtro por source: filtre do seu lado.
source | O que é |
|---|---|
api | O padrão: o que passou pela Cloud API através da BotoZap — seus envios por POST /api/v1/messages, tudo o que a equipe envia pela tela de Conversas do painel (texto, templates e mensagens interativas) e as mensagens que o contato mandou. |
app | O eco do que o atendente mandou pelo app WhatsApp Business no celular, num número em coexistência. Não passou pela BotoZap. |
history | Conversas antigas importadas do app quando um número em coexistência é conectado (até 180 dias). Chegam em lotes, podem levar horas e trazem as duas direções. Veja Coexistência. |
broadcast | Disparo de uma transmissão. |
automation | Enviada pela própria plataforma, sem pessoa nem chamada sua — por exemplo, a confirmação automática de descadastro. |
Numa integração com coexistência (o mesmo número no app e na API), espere mensagens outbound que você não enviou: são as app, digitadas no celular. Cada uma também chega por webhook como whatsapp.message.echo aos Endpoints que assinam a categoria opt-in app_messages. Nem elas nem as history abrem a janela de 24h — só uma mensagem nova do contato abre. As history também não geram whatsapp.message.received (nem no webhook, nem em GET /api/v1/events) e não acionam agentes, follow-ups ou réguas: são importação, não conversa nova. Elas ficam disponíveis em GET /api/v1/messages (use sort=event_at para vê-las na ordem cronológica) e nas conversas de GET /api/v1/conversations, onde o last_message também traz source.
Montar um inbox (conversas)
Um inbox lista conversas, não mensagens. GET /api/v1/conversations devolve as conversas paginadas por cursor, e include=last_message traz junto a última mensagem de cada uma — sem esse parâmetro você faria uma chamada extra por conversa (20 conversas = 21 requisições). Some customer_id= para ver só as conversas dos números de um cliente; um cliente que não seja da sua conta responde 422 invalid_customer.
Listar conversas com a última mensagem
Lista as conversas da conta, mais recentes primeiro, paginado por cursor. `include=last_message` embute a última mensagem de cada conversa (`null` quando a conversa ainda não tem nenhuma) — é o que evita uma chamada extra por conversa ao montar um inbox. Para ver só as conversas de um cliente, some `customer_id=<uuid do cliente>`.
curl https://botozap.com.br/api/v1/conversations?limit=2&include=last_message \
-H "X-API-Key: $BOTOZAP_KEY"Resposta 200
{
"data": [],
"paging": {
"cursors": {
"before": "MjAyNi0wNy0xNFQxMjowMDowMC4wMDBa",
"after": "MjAyNi0wNy0xNFQxMjowMDowMC4wMDBa"
},
"next": "MjAyNi0wNy0xNFQxMjowMDowMC4wMDBa",
"previous": "MjAyNi0wNy0xNFQxMjowMDowMC4wMDBa"
}
}Sem `include`, a resposta não traz o campo `last_message` — o contrato antigo continua idêntico.
`last_message` é um subconjunto do objeto de `GET /api/v1/messages` (mesmos nomes e semântica, incluindo `type` e `source`). `source: "history"` = mensagem importada do app WhatsApp Business na coexistência, não uma mensagem nova; `app` = enviada pela equipe no app. Para o histórico completo da conversa, use `GET /api/v1/messages?conversation_id=&sort=event_at`.
`customer_id` que não seja um cliente da sua conta responde `422 invalid_customer` — nunca uma lista de outro tenant.
`entry_point` é `ctwa` quando algum inbound da conversa veio de anúncio Click-to-WhatsApp (não volta para `organic`), `organic` quando só houve inbound sem anúncio e `null` quando a origem é desconhecida. `referral` traz o último clique em anúncio (`source_id`, `source_url`, `headline`, `ctwa_clid`, `received_at`) ou `null`.
`fep_expires_at` é o fim da janela grátis do Free Entry Point informado pela Meta no status de um envio. `fep_reply_by` é uma ESTIMATIVA (último clique + 24h) do prazo para responder e abrir essa janela; fica `null` quando não há clique ou a janela já abriu. Nenhum dos dois muda a janela de 24h para texto livre.
A lista completa de filtros da rota está na Referência.
Cenários do sandbox
Números mágicos do ambiente sandbox. Cada um simula um ciclo de vida diferente, disparando seu webhook assinado exatamente como em produção:
| Destinatário | Comportamento |
|---|---|
+5500000000001 | sent → delivered → read; janela de 24h sempre aberta (bom para o primeiro texto). |
+5500000000002 | Falha: sent → failed, com erro Meta 131026 (não entregável). |
+5500000000003 | sent → delivered + resposta inbound simulada, que abre a janela real de 24h. |
| qualquer outro número | sent → delivered; janela de 24h se comporta como em produção (fechada até um inbound de verdade). |
