Webhooks
O gateway entrega eventos via HTTP POST para a URL registrada em cada phone (ou SharedWebhook). É o mecanismo principal de notificação async.
Registro
Webhooks são configurados por phone no momento da criação ou via PATCH:
curl -X POST https://wpp.ogmma.com.br/v1/phones \
-H "X-API-Key: ak_live_..." \
-H "Content-Type: application/json" \
-d '{
"type": "BAILEYS",
"label": "Atendimento",
"webhook": {
"url": "https://api.minhaempresa.com/wpp-events",
"secret": "minha-chave-secreta-de-pelo-menos-16-caracteres",
"events": ["message:received", "message:status", "channel:connected"]
}
}'
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
url | URL HTTPS | sim | Endpoint que recebe POSTs |
secret | string ≥16 chars | sim | HMAC-SHA256 |
events | string[] | não | Lista de tipos. Vazio ([]) = recebe todos |
Atualizar webhook depois:
curl -X PATCH https://wpp.ogmma.com.br/v1/phones/<phoneId> \
-H "X-API-Key: ak_live_..." \
-d '{ "webhook": { "url": "https://nova-url.com/wpp", "secret": "novachavedepelomenos16chars" } }'
Headers da entrega
Toda entrega POST inclui:
| Header | Descrição |
|---|---|
Content-Type | application/json |
User-Agent | wpp-gateway/0.1 (+webhook) |
X-WppGateway-Event-Id | UUID único do evento (idempotência) |
X-WppGateway-Event-Type | Ex.: message:received |
X-WppGateway-Delivery-Id | UUID da tentativa de entrega (use para deduplicação) |
X-WppGateway-Attempt | Número da tentativa (1, 2, ..., 8) |
X-WppGateway-Trace-Id | Correlação ponta-a-ponta. Presente quando o evento decorre de uma chamada sua (ex.: message:status de um envio) e ecoa o requestId daquela request. Use para amarrar log do seu lado ao do gateway. Ausente em eventos não originados de request (inbound). |
X-WppGateway-Signature | HMAC-SHA256 do body raw (ver abaixo) |
Validação de assinatura HMAC
O gateway assina o body cru (JSON serializado) com HMAC-SHA256 usando o secret que você forneceu.
Algoritmo:
signature = HMAC_SHA256(secret, raw_body_bytes)
Resultado em hex (lowercase), enviado em X-WppGateway-Signature.
Validação em Node.js:
import crypto from 'node:crypto';
function verifyWebhook(rawBody, signatureHeader, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader)
);
}
Crítico: use o body cru (string ou Buffer antes do
JSON.parse). Reserializarreq.bodyquebra a assinatura — frameworks adicionam/remove espaços ou reordenam keys.
Express middleware exemplo:
app.post('/wpp-events',
express.raw({ type: 'application/json' }),
(req, res) => {
const raw = req.body.toString('utf8');
const sig = req.get('X-WppGateway-Signature');
if (!verifyWebhook(raw, sig, process.env.WPP_SECRET)) {
return res.status(401).send('invalid sig');
}
const event = JSON.parse(raw);
// ... processar event
res.sendStatus(200);
});
Envelope padrão
Todo evento tem o mesmo envelope:
{
"id": "evt-1234-...",
"type": "message:received",
"accountId": "a1b2c3d4-...",
"phoneId": "5f7b2e1c-...",
"timestamp": "2026-05-21T14:33:00.000Z",
"data": { /* específico do tipo */ }
}
idé estável durante retries (use para idempotência).timestampé quando o gateway publicou no event bus, não quando a Meta recebeu.
Eventos publicados
message:received
Mensagem inbound (cliente → seu phone).
{
"id": "evt-1234",
"type": "message:received",
"accountId": "...",
"phoneId": "5f7b2e1c-...",
"timestamp": "2026-05-21T14:33:00.000Z",
"data": {
"messageId": "9a1b2c3d-...",
"direction": "inbound",
"fromMe": false,
"type": "text",
"timestamp": "2026-06-04T12:00:00.000Z",
"content": { "text": "Quero saber sobre o produto X" },
"from": {
"canonicalKey": "5511988887777",
"pn": "5511988887777",
"lid": null,
"type": "pn"
},
"chat": {
"canonicalKey": "5511988887777",
"pn": "5511988887777",
"lid": null,
"type": "pn"
},
"quotedMessageId": null,
"pushName": "João da Silva"
}
}
Para mensagens de mídia (image, video, audio, document), o data inclui também um objeto media:
{
"messageId": "9a1b2c3d-...",
"direction": "inbound",
"fromMe": false,
"type": "image",
"timestamp": "2026-06-04T12:00:00.000Z",
"content": {},
"from": { "canonicalKey": "5511988887777", "pn": "5511988887777", "lid": null, "type": "pn" },
"chat": { "canonicalKey": "5511988887777", "pn": "5511988887777", "lid": null, "type": "pn" },
"media": {
"downloadUrl": "https://wpp.ogmma.com.br/v1/media/med-1234-...",
"mimeType": "image/jpeg",
"filename": "foto.jpg",
"caption": "Veja só!"
}
}
Campos do data:
| Campo | Tipo | Descrição |
|---|---|---|
messageId | string | UUID do gateway (use em quotedMessageId, react, edit, delete). O identificador nativo do WhatsApp nunca é exposto |
direction | enum | inbound |
fromMe | boolean | true = echo de mensagem enviada pelo próprio número (Baileys). WABA/IG sempre false. Use para não tratar echo como mensagem recebida |
type | enum | text, image, video, audio, document, sticker, location, contact, button_reply, list_reply, ... |
timestamp | ISO datetime | Quando o WhatsApp recebeu |
content | object | Schema varia por tipo (mesmo das Content*Schema em Messages) |
from | object | Identidade do remetente (ver abaixo) |
chat | object | Identidade do chat (igual a from em DM; em grupo é a identidade do grupo) |
quotedMessageId | string | null | Se a mensagem é reply, o messageId da original |
pushName | string | null | Nome do contato como aparece no WhatsApp |
media | object | null | Presente em tipos de mídia (ver abaixo) |
Media object:
| Campo | Tipo | Descrição |
|---|---|---|
downloadUrl | URL | Proxy URL do gateway (GET /v1/media/:id) — não o link cru |
mimeType | string | Ex.: image/jpeg |
filename | string | null | Nome do arquivo, se informado |
caption | string | null | Legenda da mídia, se houver |
Identity object:
| Campo | Tipo | Descrição |
|---|---|---|
canonicalKey | string | Chave estável da plataforma (use como to em reply) |
pn | string | null | Phone number (sem +) se conhecido |
lid | string | null | Baileys anonymous LID se aplicável |
type | enum | pn, lid, bsuid, username, igsid (Instagram) |
O envelope é canônico e idêntico entre canais (Baileys, WABA, Instagram) — você processa o mesmo shape independentemente da origem.
Instagram: mensagens IG usam o mesmo envelope canônico. A identidade do remetente vem como from.canonicalKey = "igsid:<id>" com type: "igsid":
{
"messageId": "9a1b2c3d-...",
"direction": "inbound",
"fromMe": false,
"type": "text",
"timestamp": "2026-06-04T12:00:00.000Z",
"content": { "text": "oi!" },
"from": { "canonicalKey": "igsid:1786543210...", "pn": null, "lid": null, "type": "igsid" },
"chat": { "canonicalKey": "igsid:1786543210...", "pn": null, "lid": null, "type": "igsid" },
"pushName": "maria.silva"
}
message:status
Atualização de status de mensagem outbound.
{
"type": "message:status",
"data": {
"messageId": "9a1b2c3d-...",
"status": "delivered",
"timestamp": "2026-06-04T12:00:00.000Z",
"recipient": { "canonicalKey": "5511988887777" },
"errors": []
}
}
Status possíveis (lowercase): sent, delivered, read, failed, deleted.
messageIdé o UUID do gateway — o mesmo retornado emPOST /v1/messages. O identificador nativo do WhatsApp nunca é exposto.recipient.canonicalKeyidentifica o destinatário no modelo de identidade unificado.- Para
failed, o arrayerrorstraz os motivos ([]quando não há erro).
Instagram: read receipts do Instagram chegam como message:status com status: "read", recipient.canonicalKey no formato igsid:<id> e um campo adicional watermark (timestamp Meta — todas as mensagens até esse ponto foram lidas):
{
"type": "message:status",
"data": {
"messageId": "9a1b2c3d-...",
"status": "read",
"timestamp": "2026-06-04T12:00:00.000Z",
"recipient": { "canonicalKey": "igsid:1786543210..." },
"watermark": "2026-06-04T11:59:58.000Z",
"errors": []
}
}
channel:qrcode
QR code Baileys atualizado.
{
"type": "channel:qrcode",
"data": { "qr": "2@LkPq...,raw QR data..." }
}
Use também GET /v1/phones/:id/qrcode que retorna dataUrl pronto pra <img>.
channel:pairing-code
Pairing code Baileys gerado.
{
"type": "channel:pairing-code",
"data": { "code": "ABCD1234", "phoneNumber": "5511988887777" }
}
Geralmente o cliente já faz polling em GET /pairing-code, este evento é informativo.
channel:connected
Phone entrou em CONNECTED.
{
"type": "channel:connected",
"data": { "phoneNumber": "5511999998888" }
}
Após receber, atualize sua UI e comece a aceitar mensagens.
channel:disconnected
Phone caiu. Pode ser temporário (auto-reconnect) ou definitivo.
{
"type": "channel:disconnected",
"data": { "reason": "LOGGED_OUT", "statusCode": 401, "attempts": 5 }
}
Reasons:
| Reason | Significado | Ação |
|---|---|---|
LOGGED_OUT | Usuário deslogou pelo celular | Re-conectar via QR/pairing (auth state foi limpo) |
MAX_RECONNECT | Worker desistiu após 15 tentativas | POST /reconnect manual |
| (sem reason) | Disconnect transiente | Worker já tenta reconectar; aguarde |
media:available
Mídia inbound WABA baixada e disponível (o gateway baixa da Meta e guarda no
S3). Em Baileys a mídia já vem inline no message:received (campo media); na
WABA o download é assíncrono e a disponibilidade chega neste evento, correlacionado
pelo messageId do gateway.
{
"type": "media:available",
"data": {
"mediaId": "med-1234-...",
"messageId": "9a1b2c3d-...",
"contentType": "image/jpeg",
"sizeBytes": 84213,
"filename": null,
"downloadUrl": "https://wpp.ogmma.com.br/v1/media/med-1234-...",
"expiresAt": "2026-06-11T12:00:00.000Z"
}
}
Baixe via downloadUrl (proxy do gateway, TTL 7d). Dê POST /v1/media/:id/ack após
processar pra liberar storage.
media:expired
Mídia atingiu TTL 7 dias sem ACK.
{
"type": "media:expired",
"data": { "mediaId": "med-1234-...", "messageId": "9a1b2c3d-...", "expiresAt": "..." }
}
Após este evento, GET /v1/media/:id retorna 410.
flow:data_exchange
Callback de WhatsApp Flow modo data_exchange.
{
"type": "flow:data_exchange",
"data": {
"flowId": "...",
"metaFlowId": "...",
"action": "navigate",
"screen": "PERSONAL_INFO",
"flowToken": "user-12345-form-abc",
"payload": { "userName": "João", "email": "joao@x.com" }
}
}
Use para receber dados dos formulários sem persistir no gateway.
group:lifecycle / group:participants / group:settings / group:status
Eventos de WABA Groups. Cada um carrega o payload normalizado da Meta.
template:status
Mudança de status de template WABA (APPROVED, REJECTED, PAUSED, etc).
{
"type": "template:status",
"data": {
"templateName": "boas_vindas_v2",
"language": "pt_BR",
"status": "APPROVED",
"reason": null,
"metaTemplateId": "987654321"
}
}
phone:quality_update — qualidade da conexão (WABA)
Qualidade do número atribuída pela Meta. É como você monitora a "saúde"
da conexão WABA: quedas pra YELLOW/RED indicam risco de throttle ou
bloqueio (muitos bloqueios/denúncias de usuários). WABA-only (Baileys não
tem rating). É push-only — chega quando a Meta reavalia.
{
"type": "phone:quality_update",
"data": {
"display_phone_number": "5511999998888",
"event": "FLAGGED", // ou "UPGRADE" / "DOWNGRADE" / "ONBOARDING"
"current_limit": "TIER_1K", // limite de conversas/24h
"quality_rating": "YELLOW" // GREEN | YELLOW | RED | UNKNOWN
}
}
quality_rating | Significado |
|---|---|
GREEN | Alta qualidade — sem restrições |
YELLOW | Qualidade média — monitorar |
RED | Baixa — risco de queda de tier / restrição |
status do phone (CONNECTED/DISCONNECTED) é o estado da conexão;
quality_rating é a reputação do número na Meta. São coisas diferentes —
um número pode estar CONNECTED e RED.
template:quality_update
Qualidade de um template específico (engajamento/denúncias). Pode levar à pausa automática do template pela Meta.
{
"type": "template:quality_update",
"data": {
"message_template_name": "promo_black_friday",
"message_template_language": "pt_BR",
"previous_quality_score": "GREEN",
"new_quality_score": "YELLOW"
}
}
webhook:failed
Evento meta: publicado quando uma entrega de webhook foi para a DLQ (todas as 8 tentativas falharam). Útil se você tem múltiplos webhooks ou um dashboard de saúde.
{
"type": "webhook:failed",
"data": {
"deliveryId": "...",
"originalEventId": "...",
"originalEventType": "message:received",
"reason": "HTTP 503: Service Unavailable"
}
}
Retry & DLQ
O gateway tenta entregar com backoff exponencial:
| Tentativa | Delay desde a anterior |
|---|---|
| 1 | 0s (imediata) |
| 2 | 30s |
| 3 | 2min |
| 4 | 10min |
| 5 | 1h |
| 6 | 6h |
| 7 | 24h |
| 8 (última) | — |
Critério de sucesso: HTTP 2xx. Outros códigos (incluindo 3xx, 4xx, 5xx) → retry. Timeout: 10s por tentativa (WEBHOOK_TIMEOUT_MS).
Após 8 falhas, a entrega vai para status: DLQ e o evento webhook:failed é publicado.
Recovery & replay
GET /v1/events
Lista entregas (DLQ ou não) com filtros.
Query
| Param | Tipo | Default |
|---|---|---|
status | PENDING, DELIVERED, FAILED, DLQ | — |
from | ISO datetime | — |
to | ISO datetime | — |
phoneId | UUID | — |
type | string (event type) | — |
limit | int | 50 (máx 200) |
cursor | UUID | — |
curl 'https://wpp.ogmma.com.br/v1/events?status=DLQ&limit=20' \
-H "X-API-Key: ak_live_..."
Response 200 — { items: [...], nextCursor }.
GET /v1/events/:deliveryId
Detalhe de uma entrega (com histórico de tentativas).
POST /v1/events/:deliveryId/replay
Re-enfileira a entrega. Marca status como PENDING e dispara nova tentativa imediata. Use para corrigir webhooks que estavam fora (deploy quebrado, etc).
curl -X POST https://wpp.ogmma.com.br/v1/events/dlv-1234-.../replay \
-H "X-API-Key: ak_live_..."
Response 202 — { "status": "enqueued", "deliveryId": "..." }
Errors
| HTTP | Code | Quando |
|---|---|---|
| 404 | DELIVERY_NOT_FOUND | — |
| 409 | ALREADY_DELIVERED | Status já é DELIVERED |
Idempotência do consumidor
O gateway garante at-least-once delivery (não exactly-once). Pode haver entregas duplicadas em:
- Replay manual.
- Webhook do cliente que retorna 2xx mas falhou em persistir.
Use X-WppGateway-Event-Id (estável entre retries) como chave de idempotência no seu lado. Exemplo simples:
const seen = new Set();
app.post('/wpp-events', (req, res) => {
const eventId = req.get('X-WppGateway-Event-Id');
if (seen.has(eventId)) return res.sendStatus(200);
seen.add(eventId);
// ... processar
res.sendStatus(200);
});
Em produção, use Redis SETNX ou tabela de eventos consumidos.
Boas práticas
- Responda rápido (≤ 5s ideal, 10s é o limite). Processamento pesado deve ir pra fila assíncrona do seu lado.
- Sempre retorne 2xx se conseguir parsear o evento, mesmo que sua lógica de negócio falhe — guarde no DB e processe depois.
- Valide a assinatura SEMPRE. Sem isso qualquer um pode forjar eventos.
- Subscreva apenas eventos que você usa (
events: [...]no registro). Reduz volume. - Monitore DLQ. Crie alerta para
webhook:failedou polling emGET /v1/events?status=DLQ.