Enviar mensagens

Todo envio é um POST para /api/v1/messages. Comece pelo sandbox (sem custo, sem credenciais Meta) e depois troque a chave por uma de produção.

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, video ou document) — por link https público ou por id, o media_id que POST /api/v1/media devolve. É o único lugar onde um media_id vale 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" com coupon_code; resposta rápida — "sub_type": "quick_reply", opcionalmente com { "type": "payload", "payload": "..." }. O index é 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 sem link, 422 missing_media; link que não é https, 422 invalid_request;
  • caption — opcional, até 1024 caracteres, em imagem, vídeo e documento. Áudio não tem legenda: caption num áudio é 422 invalid_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:

TipoTamanho máximoLegenda
image (imagem)5 MBsim, até 1024 caracteres
video (vídeo)16 MBsim, até 1024 caracteres
audio (áudio)16 MBnão
document (documento)100 MBsim, 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
}
CampoLimite
headerOpcional. Em button e cta_url: text (até 60 caracteres), image, video ou document por link https (sem media id). Em list, só text.
body.textObrigatório. Até 1024 caracteres em button e cta_url; 4096 em list.
footer.textOpcional, até 60 caracteres.
action.buttons[].reply1 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 422 reaction_target_not_found, reaction_target_invalid ou reaction_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, com content.message_id igual ao wamid do 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 espere delivered nem read. Se mesmo assim a Meta não entregar (alvo apagado depois do envio, por exemplo), ela avisa por webhook com o erro 131009.

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.

typeWhatsAppInstagram
textenviaenvia
templateenvianão envia — 422 channel_not_supported
imageenviaenvia
videoenviaenvia
audioenviaenvia
documentenviaenvia
stickernão envia — 422 unsupported_typenão envia — 422 channel_not_supported
interactiveenvianão envia — 422 channel_not_supported
locationenvianão envia — 422 channel_not_supported
contactsnão envia — 422 unsupported_typenão envia — 422 channel_not_supported
reactionenviaenvia
  • Botões, listas e botão de link saem como interactive dentro da janela (veja acima); fora dela, só pelos botões de um template aprovado.
  • reaction sai como reação a uma mensagem recebida (veja acima).
  • contacts e sticker não saem pela API do WhatsApp. Quando o contato os manda, eles chegam normalmente, com esse type.
  • 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…"
  }
}
CampoValores
statushealthy (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.stateready, reconnect_required, blocked, unknown, unavailable ou sandbox; o porquê vem em readiness.reason (ex.: token_invalid, connection_revoked, account_restricted).
readiness.next_actionreconnect, resolve_in_meta, sync, retry_read ou none.
readiness.live_checkok (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, actionTextos em português, os mesmos do painel, prontos para mostrar ao seu usuário. detail e action podem ser null.
readiness.observed_at, staleQuando o estado foi observado; stale: true quando a observação tem mais de 24h (informativo, não bloqueia).
checks.token_validityvalid, 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, contacts e sticker próprio não existem no canal — 422 channel_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, 422 window_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;
  • title de até 20 caracteres — acima disso, 422 quick_reply_title_too_long;
  • 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 (padrão), user_phone_number e user_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 é 422 quick_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 422 unsupported_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
17000000000000001Envio aceito (instagram.message.sent) e confirmação de leitura (instagram.message.read); a janela de 24h fica sempre aberta.
17000000000000002A Meta recusa com o erro 551 (pessoa indisponível): 422 meta_error, mensagem failed na conversa e instagram.message.failed.
17000000000000003Envio 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).
17000000000000004Janela 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 IGSID422 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) ou unknown (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) ou unknown (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.

HTTPcodeQuando aconteceO que fazeroutcome / retry
403forbidden_scopeA 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
429rate_limitedOrçamento de requisições da REST /v1 por Conta esgotado.Respeite o cabeçalho Retry-After e repita com backoff.rejected / backoff
400invalid_jsonO corpo não é JSON válido.Corrija o corpo.rejected / after_correction
422missing_toto ausente ou fora de formato: não é telefone E.164 nem BSUID.Informe o telefone em E.164 ou o BSUID.rejected / after_correction
422invalid_requestCampo 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
422unsupported_typetype 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
422missing_texttype: "text" sem text.body.Mande o texto em text.body.rejected / after_correction
422missing_templatetype: "template" sem template.name.Informe o nome do template aprovado.rejected / after_correction
422missing_mediaTipo de mídia sem o objeto correspondente ou sem link.Envie { link: "https://..." } no campo do tipo (image, video...).rejected / after_correction
422missing_reaction_targettype: "reaction" sem reaction.message_id.Informe o id (UUID) ou o wamid da mensagem recebida a reagir.rejected / after_correction
422invalid_reactionreaction.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
422no_phoneNenhum 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
422ambiguous_phoneA 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
422outside_windowTexto, mídia, interactive, location ou reaction com a janela de 24h fechada.Envie um template aprovado ou espere o contato escrever.rejected / after_correction
422reaction_target_not_foundreaction.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
422reaction_target_invalidO 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
422reaction_target_expiredO 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
422account_restrictedTemplate 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
503capacity_unavailableTemplate: 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
422auth_template_bsuidTemplate de autenticação para um contato conhecido só por BSUID.Envie para o telefone do contato.rejected / unknown
422consent_revokedTemplate MARKETING para um Contato com consentimento de marketing revogado.Não reenvie marketing. Templates UTILITY e AUTHENTICATION seguem liberados.rejected / unknown
409not_connectedO 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
503billing_unavailableA consulta de plano ou de consumo falhou.Falha temporária antes da Meta. Repita com backoff, sem mexer no plano.rejected / backoff
429free_form_limit_reachedPlano 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
402quota_exceededTemplate 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
409conversation_dispatch_busyHá 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
503dispatch_unavailableNão foi possível abrir o registro de envio da conversa. A Meta não foi chamada.Repita com backoff.rejected / backoff
409whatsapp_disconnectedA 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
422meta_errorA 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
502meta_errorA 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
502send_failedFalha 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`.

FiltroAceitaValor que não casa
phone_number_idO phone_number_id da Meta ou o UUID do Número.Número que não é da conta: página vazia.
conversation_idUUID da conversa (o id de GET /api/v1/conversations).Não-UUID ou de outra conta: página vazia.
directioninbound ou outbound.Ignorado (lista sem o filtro).
statusqueued, sent, delivered, read, failed ou received.Ignorado.
message_typeO type da mensagem: text, image, video, audio, document, sticker, location, contacts, interactive, button, reaction, order, template, system ou unknown.Ignorado.
has_mediatrue (imagem, vídeo, áudio, documento ou sticker) ou false.Ignorado.
channelwhatsapp ou instagram.422 invalid_channel.
channel_account_idUUID 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.

sourceO que é
apiO 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.
appO eco do que o atendente mandou pelo app WhatsApp Business no celular, num número em coexistência. Não passou pela BotoZap.
historyConversas 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.
broadcastDisparo de uma transmissão.
automationEnviada 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árioComportamento
+5500000000001sent → delivered → read; janela de 24h sempre aberta (bom para o primeiro texto).
+5500000000002Falha: sent → failed, com erro Meta 131026 (não entregável).
+5500000000003sent → delivered + resposta inbound simulada, que abre a janela real de 24h.
qualquer outro númerosent → delivered; janela de 24h se comporta como em produção (fechada até um inbound de verdade).