Webhooks
Os payloads que o Bandmaster entrega na sua URL e como verificar a assinatura HMAC.
Webhook é a forma de receber: tudo que chega do WhatsApp — mensagens dos seus
clientes, status de entrega, falhas de envio — o Bandmaster entrega por POST na
URL configurada em PUT /phones/webhook, com
content-type: application/json. Você não precisa consultar nada em intervalos:
receba, responda 2xx e guarde.
Como a entrega funciona
- Cada evento é um POST, com timeout de 15 segundos.
- O corpo é o JSON serializado uma única vez — é exatamente sobre esses bytes que a assinatura é calculada.
- Responda 2xx. Um erro no seu endpoint é registrado do nosso lado, mas o evento não é reenviado automaticamente: trate a entrega como tentativa única e persista o evento antes de processá-lo.
- Como não há reenvio, também não há garantia de ordem entre eventos distintos.
Mensagem recebida
Disparado quando o número recebe uma mensagem no WhatsApp. Vai assinado (quando há chave). O formato depende da versão de webhook configurada para o número: sem configuração, é a v1.
v1 (padrão)
Objeto plano, sem o número do negócio:
POST na sua URL — mensagem v1
https://sua-aplicacao.com.br/webhooks/bandmaster›headers
- content-type:
- application/json
- X-Webhook-Signature:
- <hmac-sha256-hex>
{
"user_id": "BR.1729384756102938",
"from_user_id": "BR.1729384756102938",
"username": null,
"wa_id": "5511988887777",
"number": "5511988887777",
"message_id": "wamid.HBgMNTUxMTk4ODg4Nzc3NxUCABIYFjNFQjBGNkI4QjBCQkFBNUJGMjlCNjcA=",
"name": "Maria Souza",
"timestamp": "1783559940",
"broker": "360",
"content": {
"body": "Olá! Tudo bem?"
},
"type": "text",
"identifier": "104582",
"isEcho": false,
"from": "5511988887777",
"to": "5511999990000"
}a sua resposta ao Bandmaster
v2
Envelope versionado, com contato e mensagem separados:
POST na sua URL — mensagem v2
https://sua-aplicacao.com.br/webhooks/bandmaster›headers
- content-type:
- application/json
- X-Webhook-Signature:
- <hmac-sha256-hex>
{
"version": 2,
"broker": "360",
"identifier": "104582",
"is_echo": false,
"contact": {
"user_id": "BR.1729384756102938",
"phone": "5511988887777",
"username": null,
"name": "Maria Souza"
},
"business_phone": "5511999990000",
"message": {
"id": "wamid.HBgMNTUxMTk4ODg4Nzc3NxUCABIYFjNFQjA3RTZFM0YzRjM5RTRENjIxMkYA=",
"type": "text",
"timestamp": "1783560844",
"content": {
"body": "Olá"
}
}
}a sua resposta ao Bandmaster
O envelope, campo a campo
| v1 | v2 | O que é |
|---|---|---|
user_id | contact.user_id | BSUID do contato — a identidade estável |
from_user_id | — | BSUID de quem enviou; só na v1 |
username | contact.username | nome de usuário do WhatsApp; costuma vir null |
wa_id | contact.phone | telefone do contato — pode vir null |
number | — | mesmo telefone do contato; só na v1 |
name | contact.name | nome do perfil; null em eco |
message_id | message.id | id da mensagem no WhatsApp (wamid...) |
timestamp | message.timestamp | unix em segundos, como string |
type | message.type | o tipo (text, image, …) |
content | message.content | o conteúdo, que muda por tipo |
broker | broker | 360 ou meta |
identifier | identifier | o identificador do seu número |
isEcho | is_echo | ⚠ camelCase na v1, snake_case na v2 |
from / to | — / business_phone | quem enviou / o número do negócio |
context | message.context | só quando a mensagem é resposta a outra |
media_url | message.media_url | só em mídia — a cópia permanente |
Campos ausentes não vêm como null: eles simplesmente não aparecem no
objeto. context e media_url são assim.
Cada tipo de mensagem
O envelope acima não muda de um tipo para o outro: o que muda é o content
(e, na mídia, o media_url). Abaixo, só a parte que varia — na v1 ela fica no
topo do objeto; na v2, dentro de message.
text
Texto puro. O content traz só o corpo.
{
"type": "text",
"content": { "body": "Olá! Preciso de ajuda." }
}image e video
Mídia com legenda opcional (caption vem null quando não há).
Dois media_url, e eles não são a mesma coisa
O media_url de dentro do content aponta para o servidor da Meta
(lookaside.fbsbx.com) e expira — repare no ext da própria URL.
O media_url de fora (na v2, dentro de message) é a cópia permanente no
nosso storage: bandmaster.s3.us-east-2.amazonaws.com/bandmaster-<media_id>.
É essa que você usa.
{
"type": "image",
"content": {
"media_id": "1428573910284756",
"media_url": "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=1428573910284756&source=webhook&ext=1783560409&hash=ATx2xjIxsLNsK0YkH2bb1dph5-4_KNyPI09ieM5I99mTjA",
"mime_type": "image/jpeg",
"caption": null,
"sha256": "9r1kSg2Xb0mQ7dV5cJt8yPzA3hLwN6oU4eR1fK0iM2s="
},
"media_url": "https://bandmaster.s3.us-east-2.amazonaws.com/bandmaster-1428573910284756"
}audio, voice e sticker
Mesma forma da imagem, sem caption.
{
"type": "audio",
"content": {
"media_id": "1428573910284756",
"media_url": "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=1428573910284756&source=webhook&ext=1783560409&hash=ATx2xjIxsLNsK0YkH2bb1dph5-4_KNyPI09ieM5I99mTjA",
"mime_type": "audio/ogg",
"sha256": "9r1kSg2Xb0mQ7dV5cJt8yPzA3hLwN6oU4eR1fK0iM2s="
},
"media_url": "https://bandmaster.s3.us-east-2.amazonaws.com/bandmaster-1428573910284756"
}document
Como a imagem, mais o filename.
{
"type": "document",
"content": {
"media_id": "1573920486135742",
"media_url": "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=1573920486135742&source=webhook&ext=1783560409&hash=ATx2xjIxsLNsK0YkH2bb1dph5-4_KNyPI09ieM5I99mTjA",
"caption": "Nota fiscal",
"filename": "invoice-2026.pdf",
"mime_type": "application/pdf",
"sha256": "Kp7xQ1vB8nR4tY6uI0oP2aS5dF3gH9jL1zX4cV6bN8m="
},
"media_url": "https://bandmaster.s3.us-east-2.amazonaws.com/bandmaster-1573920486135742"
}location
Só coordenadas.
Sem nome nem endereço
Mesmo que o cliente envie um local nomeado, chegam apenas lat e lon. O
nome e o endereço do ponto não são repassados.
{
"type": "location",
"content": { "lat": -23.55052, "lon": -46.633308 }
}contacts
O cartão de contato como o WhatsApp o envia.
{
"type": "contacts",
"content": {
"contacts": [
{
"name": {
"first_name": "Jane",
"last_name": "Roe",
"formatted_name": "Jane Roe"
},
"phones": [
{ "phone": "+5511900001111", "type": "WORK", "wa_id": "5511900001111" }
],
"emails": [{ "email": "jane@example.com", "type": "WORK" }]
}
]
}
}order
O carrinho que o cliente montou no seu catálogo.
{
"type": "order",
"content": {
"catalog_id": "194836412116",
"product_items": [
{
"product_retailer_id": "SKU-001",
"quantity": 2,
"item_price": 49.9,
"currency": "BRL"
}
]
}
}interactive
A resposta a uma mensagem interativa que você mandou.
O type da mensagem continua interactive; qual interação foi vem em
content.type: button_reply (botão tocado) ou list_reply (linha escolhida na
lista). O id é o que você definiu no envio.
{
"type": "interactive",
"content": {
"id": "btn_confirm",
"title": "Confirmar",
"type": "button_reply"
}
}button (resposta rápida de template)
Quando o cliente toca um botão de resposta rápida de um template, a mensagem
chega como type: "text", com o rótulo do botão no body. Não há um tipo
button na entrega: trate como texto.
{
"type": "text",
"content": { "body": "Parar promoções" }
}reaction
Reação chega vazia
A mensagem de reação é entregue com content: null: o emoji e a mensagem
reagida não são repassados hoje. Na prática, você só sabe que houve uma
reação — não qual, nem a quê. É uma limitação conhecida; não construa lógica em
cima dela.
{
"type": "reaction",
"content": null
}O que nunca chega
Mensagens de tipo unknown e system são descartadas antes da entrega — o seu
endpoint não recebe nada. Contexto de anúncio (referral) também não é
repassado.
Resposta a uma mensagem (citação)
Quando o cliente responde citando outra mensagem, o webhook traz um context —
em qualquer tipo. Na v1 ele fica no topo; na v2, em message.context.
O context.id é reescrito para o id interno da mensagem citada no
Bandmaster (o mesmo id que o envio devolveu), e não o id do broker — assim ele
casa direto com o que você guardou.
"context": {
"from": "5511777776666",
"id": "665f1c9a2b1e4a0012ab34cd",
"forwarded": false
}Mensagens enviadas pelo operador (eco)
Se alguém da sua equipe responde pelo aplicativo do WhatsApp, a mensagem também
chega no seu webhook, marcada com isEcho: true (v1) ou is_echo: true (v2) —
qualquer tipo pode ser um eco. Nesse caso o name e o username do contato vêm
null.
Quem enviou: BSUID e telefone
Todo webhook de mensagem traz duas formas de identificar o cliente:
| Campo (v1) | Campo (v2) | O que é |
|---|---|---|
user_id | contact.user_id | o BSUID do contato — a identidade estável |
from_user_id | — | o BSUID de quem enviou a mensagem |
wa_id | contact.phone | o telefone — pode vir null |
O telefone só chega quando a Meta o disponibiliza. Se o cliente usa nome de usuário e o número está retido, o campo vem nulo e o BSUID é tudo o que você tem — e é o suficiente para responder.
mensagem v2 — cliente sem telefone
https://sua-aplicacao.com.br/webhooks/bandmaster›headers
- content-type:
- application/json
- X-Webhook-Signature:
- <hmac-sha256-hex>
{
"version": 2,
"broker": "360",
"identifier": "104582",
"is_echo": false,
"contact": {
"user_id": "BR.1729384756102938",
"phone": null,
"username": "maria.souza",
"name": "Maria Souza"
},
"business_phone": "5511999990000",
"message": {
"id": "wamid.HBgMNTUxMTk4ODg4Nzc3NxUCABIYFjNFQjA5RUY1RDEwQTE4MzFGM0M5REUA=",
"type": "text",
"timestamp": "1783561048",
"content": {
"body": "Olá!"
}
}
}a sua resposta ao Bandmaster
Não indexe o contato só pelo telefone
Um contato sem número quebra qualquer busca feita por telefone. Use o
user_id (BSUID) como chave e trate o telefone como opcional — para
responder, o BSUID basta.
Status de mensagem
Disparado a cada mudança de status de uma mensagem enviada. Vai assinado. Diferente das mensagens recebidas, o status tem uma forma só — não muda entre v1 e v2.
O id é o identificador da mensagem no Bandmaster — o mesmo que
o envio devolve. Guarde-o: é por ele que você casa cada
status com a mensagem que mandou.
Os três têm a mesma forma; muda só o status. São os únicos que trazem pricing
e conversation.
{
"id": "6690b2f18a1b4a0012ab34cd",
"billable": true,
"identifier": "104582",
"recipient": "5511988887777",
"status": "delivered",
"errors": [],
"timestamp": "1783560402",
"webhook": "https://sua-aplicacao.com.br/webhooks/bandmaster",
"pricing": {
"billable": true,
"category": "user_initiated",
"pricing_model": "CBP"
},
"conversation": {
"id": "b1f9c2e0a1b2c3d4e5f60718",
"expiration_timestamp": 1783646802,
"origin": { "type": "user_initiated" }
}
}Os campos do status
| Campo | O que é |
|---|---|
id | id da mensagem no Bandmaster — o que o envio devolveu |
status | sent, delivered, read, failed ou deleted |
recipient | o telefone de destino |
billable | se a conversa é cobrada; vem null em failed |
errors | só em failed — o motivo, como a Meta o descreve |
timestamp | unix em segundos, como string |
webhook | a sua URL — vem sempre |
message_id | só em deleted — o wamid da mensagem no WhatsApp |
pricing | só em sent/delivered/read |
conversation | só em sent/delivered/read |
pricing diz como a conversa foi tarifada:
| Campo | Valores |
|---|---|
billable | true ou false |
category | business_initiated (você começou), user_initiated (o cliente começou) ou referral_conversion (veio de um anúncio) |
pricing_model | CBP (por conversa) ou NBP (por mensagem) |
conversation identifica a janela de conversa em que a mensagem caiu:
| Campo | O que é |
|---|---|
id | o id da conversa na Meta — o mesmo em todas as mensagens da janela |
origin.type | quem abriu a janela: as mesmas três categorias do pricing |
expiration_timestamp | quando a janela fecha (unix em segundos); pode não vir |
recipient_user_id não vem no status
O recipient_user_id (o BSUID do destinatário) está declarado no nosso tipo
interno, mas é descartado antes do envio: ele não chega no webhook de
status. Para casar o status com o contato, use o id da mensagem.
(Ele existe, sim, no webhook de falha de envio — que é outro evento, com outra forma.)
Falha de envio
Disparado quando a mensagem que você enfileirou não consegue sair. Vai assinado.
Não confunda com o status failed
Este evento vem de outro lugar e tem outra forma que o
status failed: ele traz recipient_user_id (o BSUID do
destinatário), não traz o campo webhook, e o timestamp aqui é um
número em milissegundos — não a string em segundos dos demais webhooks.
POST na sua URL — falha de envio
https://sua-aplicacao.com.br/webhooks/bandmaster›headers
- content-type:
- application/json
- X-Webhook-Signature:
- <hmac-sha256-hex>
{
"billable": false,
"id": "6690b2f18a1b4a0012ab34cd",
"identifier": "104582",
"recipient": "5511988887777",
"recipient_user_id": "BR.1729384756102938",
"status": "failed",
"timestamp": 1783560405000,
"errors": [
{ "code": 131026, "title": "Message undeliverable" }
]
}a sua resposta ao Bandmaster
O recipient é o telefone; o recipient_user_id, o BSUID — quando a mensagem
foi endereçada a um BSUID, é ele que vem preenchido.
Erro de template
Disparado quando um envio usa um template_id desconhecido, ou quando um
template AUTHENTICATION vai para um destino que não o aceita.
POST na sua URL — erro de template (sem assinatura)
https://sua-aplicacao.com.br/webhooks/bandmaster›headers
- content-type:
- application/json
{
"message_id": "6690b2f18a1b4a0012ab34cd",
"identifier": "seu-identifier",
"error": {
"title": "Template not found",
"details": "O template informado não existe para este número."
}
}a sua resposta ao Bandmaster
Evento de chamada
Disparado quando a Meta notifica um evento de chamada (calls) para o número. O
corpo é identifier mais o conteúdo do evento como veio da Meta.
Estes dois eventos chegam sem assinatura
Erro de template e evento de chamada são enviados sem o header
X-Webhook-Signature, mesmo quando o número tem chave configurada. Se o seu
endpoint rejeita tudo que chega sem assinatura — como recomendamos abaixo —
receba esses dois eventos em uma rota separada, ou trate-os explicitamente
antes da verificação.
Verificação da assinatura
| Item | Valor |
|---|---|
| Header | X-Webhook-Signature |
| Algoritmo | HMAC-SHA256 |
| Formato | hex minúsculo, 64 caracteres |
| Chave | a chave do número, ou a padrão da empresa |
O exemplo abaixo é real: a assinatura é o HMAC-SHA256 do corpo exatamente como
ele aparece, sob a chave chave-de-exemplo-0123456789abcdef. Use os três para
testar o seu verificador de ponta a ponta antes de ligá-lo em produção.
POST /webhooks/bandmaster HTTP/1.1
Content-Type: application/json
X-Webhook-Signature: 3bfb54ce0f8bff465c795c3b9cf436279bd00ddeefea44cb0d3f25e26ebcded2
{"id":"msg-1","status":"sent"}O que exatamente é assinado
O corpo cru da requisição, byte a byte, e nada mais. Sem timestamp, sem URL, sem outros headers.
Isso importa porque serialização JSON não é canônica: {"a":1,"b":2} e
{"b":2,"a":1} são o mesmo objeto e bytes diferentes — e produzem assinaturas
diferentes. Verifique contra os bytes que você recebeu, antes de fazer o
parse.
Node.js
const { createHmac, timingSafeEqual } = require("node:crypto");
function verify(rawBody, receivedSignature, secret) {
// Um segredo vazio não pode verificar: HMAC sob chave vazia é uma assinatura
// que o atacante produz tão facilmente quanto você.
if (!secret || !receivedSignature) return false;
const expected = Buffer.from(createHmac("sha256", secret).update(rawBody).digest("hex"));
// Enviamos sempre hex minúsculo; normalizar evita falha com header em maiúsculas.
const received = Buffer.from(receivedSignature.toLowerCase());
// timingSafeEqual lança com tamanhos diferentes. O tamanho de um digest
// SHA-256 é constante e não vaza nada.
if (expected.length !== received.length) return false;
return timingSafeEqual(expected, received);
}Ligado ao Express — capture o corpo cru, não verifique contra req.body já
parseado:
app.post(
"/webhooks/bandmaster",
express.raw({ type: "application/json" }),
(req, res) => {
if (!verify(req.body, req.get("X-Webhook-Signature"), process.env.SECRET)) {
return res.sendStatus(401);
}
const payload = JSON.parse(req.body.toString("utf8"));
// processe o payload...
res.sendStatus(200);
},
);Python
import hmac
from hashlib import sha256
def verify(raw_body: bytes, received_signature: str | None, secret: str | None) -> bool:
# Segredo ausente não verifica nada — falhe alto em vez de aceitar tudo.
if not secret or not received_signature:
return False
expected = hmac.new(secret.encode("utf-8"), raw_body, sha256).hexdigest()
# compare_digest é comparação em tempo constante.
return hmac.compare_digest(expected, received_signature.lower())Ligado ao Flask — request.get_data() devolve os bytes crus:
from flask import Flask, request, abort
import json, os
app = Flask(__name__)
@app.post("/webhooks/bandmaster")
def bandmaster_webhook():
raw = request.get_data()
if not verify(raw, request.headers.get("X-Webhook-Signature"), os.environ.get("SECRET")):
abort(401)
payload = json.loads(raw)
# processe o payload...
return "", 200Comparação em tempo constante
Compare sempre em tempo constante (timingSafeEqual, hmac.compare_digest). Um
=== no hex vaza, pelo tempo de resposta, quantos caracteres iniciais o atacante
acertou — o que transforma a forja em uma busca caractere a caractere.
Sem header
Um número sem chave — própria ou da empresa — recebe webhooks sem
assinatura, sem o header X-Webhook-Signature. É o comportamento anterior à
assinatura, mantido por compatibilidade.
Depois de configurar a chave, rejeite o que chegar sem header
Tratar header ausente como "não assinado, então tudo bem" remove a proteção por completo: o atacante simplesmente omite o header. (As exceções conhecidas são os eventos de erro de template e de chamada, que hoje chegam sem assinatura.)
De onde vem a chave
Na hora de assinar, vale a chave do número; se ele não tiver, vale a padrão
da empresa; se nenhuma existir, o webhook vai sem assinatura.
GET /phones/security_key
diz qual das duas está em uso, no campo source.
Quando source é company, rodar a chave da empresa muda a assinatura de
todos os números que a herdam de uma vez. Dê chave própria ao número que
precisa rodar sozinho.
Rotação da chave
Gravar uma chave nova invalida os caches de onde os nossos serviços assinam.
- 200 — os caches foram limpos. Nada mais assina com a chave antiga.
- 503 — a chave foi gravada, mas um cache não pôde ser invalidado. A chave antiga continua valendo. Repita a chamada; é seguro repetir.
Ignorar o 503 deixa a chave antiga em uso até o cache expirar sozinho: 60 segundos para mensagens e status, até uma hora para as notificações de falha de envio.
Para rodar a chave sem perder entregas: aceite a assinatura antiga ou a nova até ver um 200, e só então pare de aceitar a antiga.
O que a assinatura não cobre
Ela não inclui timestamp nem nonce: uma requisição capturada pode ser reenviada
indefinidamente contra um verificador que só confere a assinatura. Se replay é um
risco para você, deduplique pelo id da mensagem no payload.