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, ..., 9)
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 = "sha256=" + hex(HMAC_SHA256(secret, raw_body_bytes))

Enviado em X-WppGateway-Signature no formato sha256=<hex> (hex em minúsculo) — o prefixo faz parte do valor.

Validação em Node.js:

import crypto from 'node:crypto';

function verifyWebhook(rawBody, signatureHeader, secret) {
const expected =
'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader ?? '');
// timingSafeEqual lança erro se os tamanhos diferem: compare o tamanho antes.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

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": "+5511999998888",
"pn": "5511999998888@s.whatsapp.net",
"lid": null,
"type": "pn"
},
"chat": {
"canonicalKey": "+5511999998888",
"pn": "5511999998888@s.whatsapp.net",
"lid": null,
"type": "pn"
},
"pushName": "João da Silva"
}
}

O envelope é canônico e idêntico entre canais (Baileys / WABA / Instagram) — você processa o mesmo shape independentemente da origem.

Campos do data:

CampoTipoDescrição
messageIdstringUUID do gateway — o único identificador de mensagem exposto (use em quotedMessageId, react, edit, delete). O ID nativo do canal nunca é exposto
directionenumSempre inbound neste evento
fromMebooleantrue = a mensagem saiu do próprio número — é eco, não mensagem do contato. Baileys: enviada por outro dispositivo do número; WABA: eco de coexistência (source: business_app); Instagram: sempre false
sourcestringbusiness_app quando o eco veio do WhatsApp Business no celular do dono (coexistência). Ausente nos demais casos
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, com name). Ausente no inbound WABA e Instagram
quotedMessageIdstringSe a mensagem é reply, o messageId do gateway da original. Ausente quando não é reply
pushNamestringNome do contato como aparece no WhatsApp. Ausente quando desconhecido
mediaobjectPresente apenas para tipos de mídia em Baileys e Instagram (ver abaixo)

Identity object:

CampoTipoDescrição
canonicalKeystringChave estável da plataforma (use como to em reply). Telefone em E.164 com +; senão bsuid:..., lid:..., igsid:... ou o JID do grupo
pnstring | nullJID do telefone (5511999998888@s.whatsapp.net) se conhecido
lidstring | nullJID do LID anônimo do Baileys (12345@lid) se aplicável
typeenumpn, lid, bsuid, username, igsid (Instagram), group
namestringSó em grupo (Baileys): o assunto do grupo. Ausente nos demais casos

Media object — presente quando type é de mídia, em Baileys e Instagram. Na WABA ele não vem no message:received: o gateway baixa a mídia da Meta de forma assíncrona e avisa pelo evento media:available (abaixo), correlacionado pelo messageId.

{
"messageId": "9a1b2c3d-...",
"direction": "inbound",
"fromMe": false,
"type": "image",
"timestamp": "2026-06-04T12:00:00.000Z",
"content": { "caption": "Veja só!", "mimeType": "image/jpeg" },
"from": { "canonicalKey": "+5511999998888", "pn": "5511999998888@s.whatsapp.net", "lid": null, "type": "pn" },
"chat": { "canonicalKey": "+5511999998888", "pn": "5511999998888@s.whatsapp.net", "lid": null, "type": "pn" },
"media": {
"downloadUrl": "/v1/media/med-1234-...",
"mimeType": "image/jpeg",
"caption": "Veja só!"
}
}
CampoTipoDescrição
downloadUrlstringBaileys: caminho do proxy do gateway (/v1/media/:id), relativo — prefixe a URL base da API. Instagram: URL do CDN do Instagram, que expira em ~1h
mimeTypestringEx.: image/jpeg. Ausente no Instagram
filenamestringNome do arquivo (relevante p/ document), se informado
captionstringLegenda da mídia, se houver

Campos sem valor vêm ausentes, não null.

Instagram: usa o mesmo envelope canônico. A identidade IG vem como from.canonicalKey = "igsid:<id>" com from.type = "igsid"; pn e lid são null, e não há chat nem pushName.

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

message:status

Atualização de status de mensagem outbound. Envelope canônico, idêntico entre canais.

{
"type": "message:status",
"data": {
"messageId": "9a1b2c3d-...",
"status": "delivered",
"timestamp": "2026-06-04T12:00:00.000Z",
"recipient": { "canonicalKey": "+5511999998888" }
}
}

Status possíveis (lowercase): sent, delivered, read, failed, deleted.

  • messageId é o UUID do gateway — o mesmo retornado em POST /v1/messages e o único identificador de mensagem exposto.

  • recipient.canonicalKey identifica o destinatário no modelo de identidade unificado. Pode vir ausente (ex.: acks do Baileys e falhas detectadas no envio).

  • errors é um array (ausente em sucesso). Para failed, traz os erros do canal:

    • code — o campo para rotear. Traz o código numérico da Meta quando existe (132018 validação de parâmetro, 132000 contagem, 131047 fora da janela de 24h, 131026 destinatário não recebe) e 0 quando a falha não veio dela (rede, validação do gateway).
    • title — rótulo, não enum. Quando o gateway detecta a falha no envio, é o código de erro dele (META_BAD_REQUEST, INVALID_PTT_FORMAT, … ou SEND_FAILED quando não há um); quando a falha vem depois, pelo webhook de status da Meta, é o título textual dela (Message Undeliverable) — como já era antes desta versão. Os dois espaços de valores convivem no mesmo evento: roteie por code. Para detectar falha use status === 'failed'; no caminho do gateway o title era sempre SEND_FAILED até esta versão.
    • message — texto do canal; nas falhas detectadas pelo gateway vem truncado em 200 chars.

    Recusa determinística da Meta (a lista de code acima) não é retentada: o evento failed chega em segundos, não depois da janela de ~2 min de retries.

Instagram: os read receipts do IG são por watermark (marca tudo até um timestamp como lido), não por mensagem individual. Por isso o evento vem como message:status com status: "read", recipient.canonicalKey no formato igsid:<id> e um campo watermark (timestamp numérico da Meta, em epoch) — sem messageId neste caso específico, e sem timestamp dentro do data (use o do envelope):

{
"type": "message:status",
"timestamp": "2026-06-04T12:00:00.000Z",
"data": {
"status": "read",
"recipient": { "canonicalKey": "igsid:1786543210..." },
"watermark": 1780574398000
}
}

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
PAIRING_RESETPareamento reiniciado via POST /:id/logout (credencial descartada)Nada — a sessão nova já pede QR; leia GET /:id/qrcode
MANUAL_DELETEPhone apagado via DELETE /v1/phones/:idNada — o phone não existe mais
(sem reason)Disconnect transienteWorker já tenta reconectar; aguarde

identity:merged

Duas identidades que o gateway emitia como contatos distintos passaram a ser reconhecidas como o mesmo contato. Caso típico (Baileys): você inicia a conversa pelo telefone (+55...) mas as respostas chegam por um LID anônimo (lid:...); ao descobrir o vínculo, o gateway funde as duas e avisa. Na WABA acontece o mesmo quando um contato que só tinha BSUID (bsuid:...) passa a vir com telefone.

{
"type": "identity:merged",
"data": {
"from": "lid:12345",
"into": "+5511999998888",
"identity": {
"canonicalKey": "+5511999998888",
"pn": "5511999998888@s.whatsapp.net",
"lid": "12345@lid",
"type": "pn"
}
}
}
CampoTipoDescrição
fromstringcanonicalKey antigo, a ser descontinuado
intostringcanonicalKey canônico que passa a valer
identityobjectIdentidade unificada resultante (mesmo shape de from/chat)

Ação: funda no seu lado tudo que estava sob from na conversa de into. Mensagens futuras desse contato chegam sempre com canonicalKey = into.


Coexistência: eco, histórico e contatos

Só existem em número coexistente — o que vive ao mesmo tempo no WhatsApp Business do celular do dono e na Cloud API. Em número dedicado nenhum deles é emitido.

Eco — o dono respondeu pelo celular

Chega como um message:received comum, com fromMe: true e source: "business_app". from e chat são o cliente (igual ao eco do Baileys): em 1:1 o dono da conversa é o outro lado, e é por ele que a thread é encontrada.

{
"type": "message:received",
"data": {
"messageId": "9a1b2c3d-...",
"direction": "inbound",
"fromMe": true,
"source": "business_app",
"type": "text",
"timestamp": "2026-08-31T14:02:11.000Z",
"content": { "text": "das 9 às 13h" },
"from": { "canonicalKey": "+5511999998888", "pn": "5511999998888@s.whatsapp.net", "lid": null, "type": "pn" },
"chat": { "canonicalKey": "+5511999998888", "pn": "5511999998888@s.whatsapp.net", "lid": null, "type": "pn" }
}
}

Sem tratar o eco, o inbox mente: a conversa fica como "ninguém atendeu", uma atendente responde de novo e o cliente recebe duas respostas.

fromMe: true não é mensagem do cliente. Quem trata todo message:received de número WABA como inbound do contato vai fazer o agente de IA responder ao próprio dono e contar a mensagem dele como não lida. Trate fromMe antes de ligar coexistência.

O messageId é o mesmo que o gateway já usou para aquela mensagem: se ela saiu por POST /v1/messages, é o id que a API devolveu; caso contrário é determinístico sobre a mensagem do WhatsApp. A mesma mensagem chegando de novo (reentrega, ou também pelo histórico) traz o mesmo id, e o produto deduplica.

history:messages

Conversa passada do celular, em lotes. Disparado por POST /v1/phones/:id/waba/smb-sync (ou pelo sync no Embedded Signup).

{
"type": "history:messages",
"data": {
"phase": 1,
"chunkOrder": 2,
"progress": 55,
"complete": false,
"batchIndex": 0,
"batchCount": 3,
"threads": [
{
"chat": { "canonicalKey": "+5511999998888", "pn": "5511999998888@s.whatsapp.net", "lid": null, "type": "pn" },
"messages": [
{
"messageId": "9a1b2c3d-...",
"fromMe": true,
"type": "text",
"timestamp": "2026-03-12T18:22:35.000Z",
"content": { "text": "Segue o link" },
"status": "read"
}
]
}
]
}
}
CampoTipoDescrição
phaseint0 = dia 0–1, 1 = dia 1–90, 2 = dia 90–180 (dia 0 = onboarding)
chunkOrderintChunks não chegam em ordem — ordene por este campo
progressint0–100 do total da sincronização
completebooleantrue no último lote do chunk que fecha em 100%
batchIndex / batchCountintFatiamento do gateway — no máximo 200 mensagens por evento
errorsarray | ausentePresente quando a Meta mandou erro junto com os dados: a importação chegou parcial
messages[].messageIdstringMesmo id que a mensagem já tem no gateway (ver eco, acima) — deduplique por ele
messages[].fromMebooleantrue = o negócio mandou. Não há direction aqui: no envelope canônico direction significa "evento entrante", não o sentido da mensagem
messages[].timestampISO datetimeHora em que a mensagem existiu no WhatsApp, não a da importação
messages[].statusstring | ausenteÚltimo status na origem, no domínio canônico: sent, delivered, read, failed. PLAYED da Meta vira read; PENDING sai ausente
messages[].content.hasMediaboolean | ausentetrue = a mensagem tinha mídia. O histórico não traz a mídia: o id do store da Meta não é exposto e não há ingestão em massa

Grave timestamp num campo próprio. Usar a hora de ingestão embaralha a thread importada, e isso não se recupera sem reimportar — o que a Meta só permite depois de desconectar e refazer o Embedded Signup.

Mensagens de mídia podem chegar com type: "unsupported" e o conteúdo vazio (a Meta usa media_placeholder no payload dela, que o normalizador não reconhece como tipo próprio): o conteúdo vem num webhook history seguinte, e só para mídia dos últimos 14 dias. Mensagens marcadas com content.hasMedia tiveram o id do store da Meta removido — a mídia do histórico não é baixada pelo gateway, só a do eco em tempo real (que chega por media:available como qualquer inbound). Conversa de grupo não entra no histórico.

Mensagens sem hora utilizável são descartadas, não carimbadas com a hora da importação — carimbar embaralharia a thread de forma irreversível. O gateway registra a contagem em log de erro.

history:failed

O dono recusou compartilhar o histórico (erro 2593109 da Meta), ou a sincronização falhou. Precisa ser tratado — sem ele a tela de importação espera um lote que nunca vem.

{
"type": "history:failed",
"data": { "declined": true, "errors": [{ "code": 2593109, "title": "..." }] }
}

Assine os eventos antes de importar

history:messages, history:failed e contacts:sync são tipos novos. Um webhook com lista events explícita não os recebe até que sejam adicionados — lista vazia recebe tudo. Como o sync da Meta é de chance única, o gateway recusa POST /v1/phones/:id/waba/smb-sync com 409 WEBHOOK_EVENTS_MISSING enquanto os tipos não estiverem assinados, em vez de queimar a chance.

contacts:sync

A agenda do celular do dono — no lote inicial e a cada alteração feita depois.

{
"type": "contacts:sync",
"data": {
"contacts": [
{
"action": "add",
"contact": { "canonicalKey": "+5511999998888", "pn": "5511999998888@s.whatsapp.net", "lid": null, "type": "pn" },
"fullName": "Pablo Morales",
"firstName": "Pablo",
"timestamp": "2026-08-31T14:02:11.000Z"
}
]
}
}

action é add (inclusão ou edição) ou remove. O nome vem da agenda do dono: é sugestão de exibição, não identidade — não deixe sobrescrever nome dado pela sua equipe.


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. Vale também para a mídia do eco de coexistência.

{
"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), emitida quando a Meta notifica a revisão. Serve para saber da aprovação ou da rejeição sem relistar GET /v1/templates.

{
"type": "template:status",
"data": {
"templateName": "boas_vindas_v2",
"language": "pt_BR",
"status": "APPROVED",
"reason": null,
"metaTemplateId": "987654321"
}
}

phone:quality_update — limite e throughput do número (WABA)

Mudança no limite de mensagens ou no throughput do número, avaliada pela Meta. WABA-only (Baileys não tem limite de tier). É push-only — chega quando a Meta reavalia. O data é o value do webhook phone_number_quality_update da Meta, repassado sem transformação.

{
"type": "phone:quality_update",
"data": {
"display_phone_number": "5511999998888",
"event": "THROUGHPUT_UPGRADE",
"max_daily_conversations_per_business": "TIER_UNLIMITED"
}
}
CampoSignificado
eventO que mudou. Ex.: ONBOARDING, THROUGHPUT_UPGRADE
max_daily_conversations_per_businessLimite de mensagens do portfólio: TIER_250, TIER_2K, TIER_10K, TIER_100K, TIER_UNLIMITED, ...
current_limit / old_limitCampos antigos da Meta (anunciados para remoção em fev/2026). Podem ainda aparecer

Qualidade do número não vem neste evento. O quality_rating (GREEN / YELLOW / RED / UNKNOWN) é lido da Meta e exposto como wabaQualityRating no phone (GET /v1/phones/:id). Não confunda com o status do phone: status (CONNECTED/DISCONNECTED) é o estado da conexão; quality_rating é a reputação do número na Meta — 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. O data é o value do webhook message_template_quality_update da Meta, repassado sem transformação.

{
"type": "template:quality_update",
"data": {
"message_template_id": 987654321,
"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 9 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 até 9 vezes: a primeira na hora e cada nova tentativa depois da espera abaixo.

TentativaEspera desde a anterior
10s (imediata)
210s
330s
41min
55min
630min
72h
86h
9 (última)12h

Janela total até a DLQ: ~20h40.

Critério de sucesso: HTTP 2xx. Outros códigos (incluindo 3xx, 4xx, 5xx) → retry. Timeout: 10s por tentativa (WEBHOOK_TIMEOUT_MS).

Após 9 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.