Bandmaster docs

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

BandmasterPOSThttps://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"
}
Você200 OK✓✓

a sua resposta ao Bandmaster

v2

Envelope versionado, com contato e mensagem separados:

POST na sua URL — mensagem v2

BandmasterPOSThttps://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á"
  }
}
}
Você200 OK✓✓

a sua resposta ao Bandmaster

O envelope, campo a campo

v1v2O que é
user_idcontact.user_idBSUID do contato — a identidade estável
from_user_idBSUID de quem enviou; só na v1
usernamecontact.usernamenome de usuário do WhatsApp; costuma vir null
wa_idcontact.phonetelefone do contato — pode vir null
numbermesmo telefone do contato; só na v1
namecontact.namenome do perfil; null em eco
message_idmessage.idid da mensagem no WhatsApp (wamid...)
timestampmessage.timestampunix em segundos, como string
typemessage.typeo tipo (text, image, …)
contentmessage.contento conteúdo, que muda por tipo
brokerbroker360 ou meta
identifieridentifiero identificador do seu número
isEchois_echocamelCase na v1, snake_case na v2
from / to— / business_phonequem enviou / o número do negócio
contextmessage.contextsó quando a mensagem é resposta a outra
media_urlmessage.media_urlsó 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_idcontact.user_ido BSUID do contato — a identidade estável
from_user_ido BSUID de quem enviou a mensagem
wa_idcontact.phoneo 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

BandmasterPOSThttps://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á!"
  }
}
}
Você200 OK✓✓

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

CampoO que é
idid da mensagem no Bandmaster — o que o envio devolveu
statussent, delivered, read, failed ou deleted
recipiento telefone de destino
billablese a conversa é cobrada; vem null em failed
errorssó em failed — o motivo, como a Meta o descreve
timestampunix em segundos, como string
webhooka sua URL — vem sempre
message_idsó em deleted — o wamid da mensagem no WhatsApp
pricingsó em sent/delivered/read
conversationsó em sent/delivered/read

pricing diz como a conversa foi tarifada:

CampoValores
billabletrue ou false
categorybusiness_initiated (você começou), user_initiated (o cliente começou) ou referral_conversion (veio de um anúncio)
pricing_modelCBP (por conversa) ou NBP (por mensagem)

conversation identifica a janela de conversa em que a mensagem caiu:

CampoO que é
ido id da conversa na Meta — o mesmo em todas as mensagens da janela
origin.typequem abriu a janela: as mesmas três categorias do pricing
expiration_timestampquando 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

BandmasterPOSThttps://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" }
]
}
Você200 OK✓✓

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)

BandmasterPOSThttps://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."
}
}
Você200 OK✓✓

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

ItemValor
HeaderX-Webhook-Signature
AlgoritmoHMAC-SHA256
Formatohex minúsculo, 64 caracteres
Chavea 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 "", 200

Comparaçã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.

Nesta página