Como enviar botões no WhatsApp pela API
Botão no WhatsApp não é um endpoint separado: é uma mensagem de tipo interactive no mesmo envio de sempre. O que muda é o objeto do tipo — e o caminho de volta, porque a escolha do cliente chega como uma mensagem recebida, não como resposta da sua chamada.
O que você precisa
- Uma chave de API (
hub_pk_...) com o escopomessages:send, ou o token do canal (hub_ch_...). Crie a chave em /painel/chaves. - O id do canal. Liste os canais da organização em
GET /api/v1/channels. - Uma janela de atendimento aberta. Mensagem
interactiveé conteúdo livre, então vale a mesma regra das 24 horas do envio de texto. Para iniciar conversa com botão, o caminho é um template com botões — veja enviar template. - Um webhook configurado para
message.received. Sem ele você manda o botão e nunca fica sabendo o que o cliente apertou.
Como o corpo é montado
O corpo é o mesmo de qualquer envio: to, type e o objeto com o nome do tipo. Com type: "interactive", o objeto é interactive, e ele é repassado à Cloud API da Meta como você mandou.
Vale ser explícito sobre o que isso significa, porque muda onde você procura a resposta quando algo não encaixa: o schema de interactive é o da Meta, não um schema nosso. Só button e list são aceitos, e campo desconhecido é recusado. Um campo novo da Meta é recusado aqui com 400 INVALID_MESSAGE até sair uma release nossa — tipos como cta_url, flow e product ainda não passam — e um campo que a Meta recusar continua recusado, mesmo que o Hub tenha respondido 202. Para o schema completo, a fonte é a referência de mensagens da Cloud API.
button e list — no estado em que a Meta os documenta hoje. Se um exemplo daqui divergir da referência da Meta, a referência da Meta ganha.Botões de resposta rápida
interactive.type: "button" — até três botões lado a lado. É o formato para pergunta fechada: confirmar, recusar, escolher entre poucas opções.
curl -X POST https://crprohub.com/api/v1/channels/CHANNEL_UUID/messages \
-H "Authorization: Bearer hub_pk_EXEMPLO_NAO_REAL" \
-H "Idempotency-Key: 8c1f2a63-77b0-4d31-9e5a-2b6c0d4e8f10" \
-H "Content-Type: application/json" \
-d '{
"to": "5521999999999",
"type": "interactive",
"interactive": {
"type": "button",
"body": { "text": "Confirma o horário de amanhã, às 14h?" },
"action": {
"buttons": [
{ "type": "reply", "reply": { "id": "confirma_14h", "title": "Confirmar" } },
{ "type": "reply", "reply": { "id": "remarcar", "title": "Remarcar" } }
]
}
}
}'{
"data": {
"message_id": "wamid.EXEMPLO123",
"status": "accepted"
}
}A resposta é 202 — aceita e enfileirada, não entregue. Como em qualquer envio, o header Idempotency-Key é obrigatório; a mesma chave com corpo diferente responde 409 IDEMPOTENCY_KEY_REUSED.
O id de cada botão é o que volta para você quando o cliente aperta. Trate-o como identificador estável do seu domínio (confirma_14h), nunca como cópia do rótulo: o title é texto de interface e vai mudar — o id é contrato.
Lista de opções
interactive.type: "list" — um único botão que abre um menu com seções e linhas. Use quando as opções não cabem em três, ou quando cada opção precisa de uma descrição.
{
"to": "5521999999999",
"type": "interactive",
"interactive": {
"type": "list",
"header": { "type": "text", "text": "Horários" },
"body": { "text": "Escolha um horário para amanhã." },
"footer": { "text": "Dá para remarcar depois." },
"action": {
"button": "Ver horários",
"sections": [
{
"title": "Manhã",
"rows": [
{ "id": "h_09", "title": "09:00", "description": "Com a Ana" },
{ "id": "h_11", "title": "11:00", "description": "Com o Bruno" }
]
},
{
"title": "Tarde",
"rows": [
{ "id": "h_14", "title": "14:00", "description": "Com a Ana" }
]
}
]
}
}
}A diferença estrutural em relação aos botões: action.button é o rótulo do botão que abre a lista — não é uma opção — e as opções ficam em action.sections[].rows[]. Cada linha tem id, title e description opcional. Os id precisam ser únicos na mensagem inteira, não só dentro da seção.
Os limites são da Meta
Estes limites são validados pelo próprio Hub, antes da chamada à Meta: estourar qualquer um responde 400 INVALID_MESSAGE. Confira na referência dela antes de assumir que um valor continua valendo:
button— no máximo 3 botões, com título de até 20 caracteres cada.list— 1 botão de abertura, até 10 seções e no máximo 10 linhas somando todas as seções.- Títulos de linha de até 24 caracteres, descrições de até 72. O que passa disso é recusado, não truncado.
idde botão aceita até 256 caracteres, eidde linha de lista até 200, e precisa ser único dentro da mensagem.- Emoji e markdown do WhatsApp valem no
body, mas contam caracteres nos rótulos — um título “curto” com emoji pode estourar o limite.
Como a resposta do cliente volta
Não existe callback do toque no botão. O que o cliente escolheu chega como uma mensagem normal, no evento message.received, com type: "interactive":
{
"id": "5d2b9a71-6f30-4c2e-9a41-7c8b0e1d2f34",
"event": "message.received",
"channel_id": "9f6a9c1e-2f3d-4a5b-8c7d-1e2f3a4b5c6d",
"occurred_at": "2026-08-21T12:03:11.000Z",
"data": {
"message_id": "wamid.EXEMPLO456",
"from": "5521999999999",
"type": "interactive",
"text": null,
"contact": { "wa_id": "5521999999999", "name": "Ana", "user_id": null },
"content": {
"available": true,
"type": "interactive",
"text": null,
"media": null,
"location": null,
"interactive": {
"type": "button_reply",
"button_reply": { "id": "confirma_14h", "title": "Confirmar" }
},
"button": null,
"order": null
}
}
}Repare no text: vem null. Esse é o erro mais comum de quem já tinha um handler de texto funcionando e liga botões depois — o handler lê data.text, encontra nulo e descarta a resposta como se fosse mensagem vazia. A escolha está em data.content.interactive, e o campo que o seu código deve comparar é o id, não o title.
Para a lista, muda só o miolo do objeto interactive:
"interactive": {
"type": "list_reply",
"list_reply": {
"id": "h_09",
"title": "09:00",
"description": "Com a Ana"
}
}O payload acima está abreviado. Além do que aparece ali, data traz contacts, errors e message — este último é a mensagem crua da Meta, útil quando você precisa de um campo que o formato normalizado ainda não expõe. O envelope (id, event, channel_id, occurred_at, data) é igual para todo evento; a assinatura e a deduplicação estão em webhooks.
QUICK_REPLY de template vem com type: "button" e os dados em data.content.button — não em content.interactive. Se você usa os dois caminhos, trate os dois campos: quem trata só um perde metade das respostas.Quando falha
401— chave errada, revogada, ou valor mandado sem o prefixoBearer.403— a credencial não tem o escopomessages:send.404— o canal não existe ou pertence a outra organização. São 404 nos dois casos de propósito: 403 revelaria que o recurso existe.409—Idempotency-Keyreaproveitada com corpo diferente.400 INVALID_MESSAGE— o corpo é JSON válido mas não é uma mensagem válida:type: "interactive"sem o objetointeractive, número malformado.429— limite de taxa. Veja limites.
Há um caso que não aparece em nenhum desses códigos: o objeto interactive passa aqui e a Meta recusa depois — título longo demais, id repetido, seção sem linha. O envio já respondeu 202, e a recusa chega como message.status ou aparece no histórico em GET /api/v1/channels/{id}/messages, com status em failed e o motivo em error_code. É por isso que tratar 202 como “enviado” na sua interface engana especialmente aqui: a montagem do menu tem muito mais como dar errado do que um texto simples.
Toda resposta traz o header x-request-id. Guarde-o: é o que liga a sua chamada ao registro em /painel/logs. O contrato do endpoint está em referência de mensagens e a tabela de códigos em erros.