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-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": {
"whatsappMessageId": "ABCD1234EFGH...",
"fromMe": false,
"timestamp": 1716301980,
"type": "text",
"content": { "text": "Quero saber sobre o produto X" },
"mediaUrl": null,
"mediaStorageKey": null,
"quotedMessageId": null,
"pushName": "João da Silva",
"metadata": {},
"from": {
"canonicalKey": "+5511988887777",
"pn": "5511988887777",
"lid": null,
"type": "phone"
},
"chat": {
"canonicalKey": "+5511988887777",
"pn": "5511988887777",
"lid": null,
"type": "phone"
}
}
}

Campos do data:

CampoTipoDescrição
whatsappMessageIdstringID nativo (use em quotedMessageId, react, edit, delete)
fromMebooltrue se enviada pelo próprio número (caso multi-device)
timestampintUnix seconds quando o WhatsApp recebeu
typeenumtext, image, video, audio, document, sticker, location, contact, button_reply, list_reply, ...
contentobjectSchema varia por tipo (mesmo das Content*Schema em Messages)
mediaUrlURL | nullProxy URL do gateway (apenas para tipos de mídia). Cliente faz GET /v1/media/:id
mediaStorageKeystring | nullS3 key interna (útil para debugging)
quotedMessageIdstring | nullSe a mensagem é reply, ID da original
pushNamestring | nullNome do contato como aparece no WhatsApp
metadataobjectAdicional por tipo (ex.: button payload, location coords)
fromobjectIdentidade do remetente (ver abaixo)
chatobjectIdentidade do chat (igual a from em DM; em grupo é o JID do grupo)

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
typeenumphone, lid, bsuid, username

message:status

Atualização de status de mensagem outbound.

{
"type": "message:status",
"data": {
"messageId": "9a1b2c3d-...",
"whatsappMessageId": "ABCD1234...",
"status": "DELIVERED",
"tracked": true
}
}

Status possíveis: QUEUED, SENT, DELIVERED, READ, FAILED.

  • messageId é o UUID local (presente quando o gateway disparou; pode estar ausente em status só recebidos via webhook Meta).
  • tracked: true significa que o gateway tinha a mensagem em messages_sent (vs. confirmation cross-device).
  • Para FAILED, há também error: <string> com motivo.

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:expired

Mídia atingiu TTL 7 dias sem ACK.

{
"type": "media:expired",
"data": { "mediaId": "med-1234-...", "messageId": "ABCD1234...", "expiresAt": "..." }
}

Após este evento, GET /v1/media/:id retorna 410.


agent:handoff

AI Agent decidiu (ou foi forçado a) transferir conversa para humano.

{
"type": "agent:handoff",
"data": {
"conversationId": "...",
"contactKey": "+5511988887777",
"reason": "manual",
"manual": true
}
}

manual: true = via API (POST .../handoff). manual: false = regra automática (keyword detectada, afterTurns atingido, etc).

Ver AI Agent.


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"
}
}

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.