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-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": {
"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:
| Campo | Tipo | Descrição |
|---|---|---|
whatsappMessageId | string | ID nativo (use em quotedMessageId, react, edit, delete) |
fromMe | bool | true se enviada pelo próprio número (caso multi-device) |
timestamp | int | Unix seconds quando o WhatsApp recebeu |
type | enum | text, image, video, audio, document, sticker, location, contact, button_reply, list_reply, ... |
content | object | Schema varia por tipo (mesmo das Content*Schema em Messages) |
mediaUrl | URL | null | Proxy URL do gateway (apenas para tipos de mídia). Cliente faz GET /v1/media/:id |
mediaStorageKey | string | null | S3 key interna (útil para debugging) |
quotedMessageId | string | null | Se a mensagem é reply, ID da original |
pushName | string | null | Nome do contato como aparece no WhatsApp |
metadata | object | Adicional por tipo (ex.: button payload, location coords) |
from | object | Identidade do remetente (ver abaixo) |
chat | object | Identidade do chat (igual a from em DM; em grupo é o JID do grupo) |
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 | phone, 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: truesignifica que o gateway tinha a mensagem emmessages_sent(vs. confirmation cross-device).- Para
FAILED, há tambémerror: <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:
| 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: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:
| 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.