Pular para o conteúdo principal

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"]
}
}'
CampoTipoObrigatórioDescrição
urlURL HTTPSsimEndpoint que recebe POSTs
secretstring ≥16 charssimHMAC-SHA256
eventsstring[]nãoLista 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:

HeaderDescrição
Content-Typeapplication/json
User-Agentwpp-gateway/0.1 (+webhook)
X-WppGateway-Event-IdUUID único do evento (idempotência)
X-WppGateway-Event-TypeEx.: message:received
X-WppGateway-Delivery-IdUUID da tentativa de entrega (use para deduplicação)
X-WppGateway-AttemptNúmero da tentativa (1, 2, ..., 8)
X-WppGateway-Trace-IdCorrelaçã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-SignatureHMAC-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). Reserializar req.body quebra 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:

CampoTipoDescrição
messageIdstringUUID do gateway (use em quotedMessageId, react, edit, delete). O identificador nativo do WhatsApp nunca é exposto
directionenuminbound
fromMebooleantrue = echo de mensagem enviada pelo próprio número (Baileys). WABA/IG sempre false. Use para não tratar echo como mensagem recebida
typeenumtext, image, video, audio, document, sticker, location, contact, button_reply, list_reply, ...
timestampISO datetimeQuando o WhatsApp recebeu
contentobjectSchema varia por tipo (mesmo das Content*Schema em Messages)
fromobjectIdentidade do remetente (ver abaixo)
chatobjectIdentidade do chat (igual a from em DM; em grupo é a identidade do grupo)
quotedMessageIdstring | nullSe a mensagem é reply, o messageId da original
pushNamestring | nullNome do contato como aparece no WhatsApp
mediaobject | nullPresente em tipos de mídia (ver abaixo)

Media object:

CampoTipoDescrição
downloadUrlURLProxy URL do gateway (GET /v1/media/:id) — não o link cru
mimeTypestringEx.: image/jpeg
filenamestring | nullNome do arquivo, se informado
captionstring | nullLegenda da mídia, se houver

Identity object:

CampoTipoDescrição
canonicalKeystringChave estável da plataforma (use como to em reply)
pnstring | nullPhone number (sem +) se conhecido
lidstring | nullBaileys anonymous LID se aplicável
typeenumpn, 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 em POST /v1/messages. O identificador nativo do WhatsApp nunca é exposto.
  • recipient.canonicalKey identifica o destinatário no modelo de identidade unificado.
  • Para failed, o array errors traz 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:

ReasonSignificadoAção
LOGGED_OUTUsuário deslogou pelo celularRe-conectar via QR/pairing (auth state foi limpo)
MAX_RECONNECTWorker desistiu após 15 tentativasPOST /reconnect manual
(sem reason)Disconnect transienteWorker 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_ratingSignificado
GREENAlta qualidade — sem restrições
YELLOWQualidade média — monitorar
REDBaixa — risco de queda de tier / restrição
Status do canal vs qualidade

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:

TentativaDelay desde a anterior
10s (imediata)
230s
32min
410min
51h
66h
724h
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

ParamTipoDefault
statusPENDING, DELIVERED, FAILED, DLQ
fromISO datetime
toISO datetime
phoneIdUUID
typestring (event type)
limitint50 (máx 200)
cursorUUID
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

HTTPCodeQuando
404DELIVERY_NOT_FOUND
409ALREADY_DELIVEREDStatus 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

  1. Responda rápido (≤ 5s ideal, 10s é o limite). Processamento pesado deve ir pra fila assíncrona do seu lado.
  2. Sempre retorne 2xx se conseguir parsear o evento, mesmo que sua lógica de negócio falhe — guarde no DB e processe depois.
  3. Valide a assinatura SEMPRE. Sem isso qualquer um pode forjar eventos.
  4. Subscreva apenas eventos que você usa (events: [...] no registro). Reduz volume.
  5. Monitore DLQ. Crie alerta para webhook:failed ou polling em GET /v1/events?status=DLQ.