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, ..., 9) |
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 = "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). 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": "+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:
| Campo | Tipo | Descrição |
|---|---|---|
messageId | string | UUID do gateway — o único identificador de mensagem exposto (use em quotedMessageId, react, edit, delete). O ID nativo do canal nunca é exposto |
direction | enum | Sempre inbound neste evento |
fromMe | boolean | true = 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 |
source | string | business_app quando o eco veio do WhatsApp Business no celular do dono (coexistência). Ausente nos demais casos |
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, com name). Ausente no inbound WABA e Instagram |
quotedMessageId | string | Se a mensagem é reply, o messageId do gateway da original. Ausente quando não é reply |
pushName | string | Nome do contato como aparece no WhatsApp. Ausente quando desconhecido |
media | object | Presente apenas para tipos de mídia em Baileys e Instagram (ver abaixo) |
Identity object:
| Campo | Tipo | Descrição |
|---|---|---|
canonicalKey | string | Chave estável da plataforma (use como to em reply). Telefone em E.164 com +; senão bsuid:..., lid:..., igsid:... ou o JID do grupo |
pn | string | null | JID do telefone (5511999998888@s.whatsapp.net) se conhecido |
lid | string | null | JID do LID anônimo do Baileys (12345@lid) se aplicável |
type | enum | pn, lid, bsuid, username, igsid (Instagram), group |
name | string | Só 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ó!"
}
}
| Campo | Tipo | Descrição |
|---|---|---|
downloadUrl | string | Baileys: 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 |
mimeType | string | Ex.: image/jpeg. Ausente no Instagram |
filename | string | Nome do arquivo (relevante p/ document), se informado |
caption | string | Legenda 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>"comfrom.type = "igsid";pnelidsãonull, e não háchatnempushName.
{
"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 emPOST /v1/messagese o único identificador de mensagem exposto. -
recipient.canonicalKeyidentifica 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). Parafailed, traz os erros do canal:code— o campo para rotear. Traz o código numérico da Meta quando existe (132018validação de parâmetro,132000contagem,131047fora da janela de 24h,131026destinatário não recebe) e0quando 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, … ouSEND_FAILEDquando 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 porcode. Para detectar falha usestatus === 'failed'; no caminho do gateway otitleera sempreSEND_FAILEDaté 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
codeacima) não é retentada: o eventofailedchega 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:
| 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 |
PAIRING_RESET | Pareamento reiniciado via POST /:id/logout (credencial descartada) | Nada — a sessão nova já pede QR; leia GET /:id/qrcode |
MANUAL_DELETE | Phone apagado via DELETE /v1/phones/:id | Nada — o phone não existe mais |
| (sem reason) | Disconnect transiente | Worker 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"
}
}
}
| Campo | Tipo | Descrição |
|---|---|---|
from | string | canonicalKey antigo, a ser descontinuado |
into | string | canonicalKey canônico que passa a valer |
identity | object | Identidade 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: truenão é mensagem do cliente. Quem trata todomessage:receivedde 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. TratefromMeantes 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"
}
]
}
]
}
}
| Campo | Tipo | Descrição |
|---|---|---|
phase | int | 0 = dia 0–1, 1 = dia 1–90, 2 = dia 90–180 (dia 0 = onboarding) |
chunkOrder | int | Chunks não chegam em ordem — ordene por este campo |
progress | int | 0–100 do total da sincronização |
complete | boolean | true no último lote do chunk que fecha em 100% |
batchIndex / batchCount | int | Fatiamento do gateway — no máximo 200 mensagens por evento |
errors | array | ausente | Presente quando a Meta mandou erro junto com os dados: a importação chegou parcial |
messages[].messageId | string | Mesmo id que a mensagem já tem no gateway (ver eco, acima) — deduplique por ele |
messages[].fromMe | boolean | true = o negócio mandou. Não há direction aqui: no envelope canônico direction significa "evento entrante", não o sentido da mensagem |
messages[].timestamp | ISO datetime | Hora em que a mensagem existiu no WhatsApp, não a da importação |
messages[].status | string | ausente | Último status na origem, no domínio canônico: sent, delivered, read, failed. PLAYED da Meta vira read; PENDING sai ausente |
messages[].content.hasMedia | boolean | ausente | true = 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
timestampnum 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"
}
}
| Campo | Significado |
|---|---|
event | O que mudou. Ex.: ONBOARDING, THROUGHPUT_UPGRADE |
max_daily_conversations_per_business | Limite de mensagens do portfólio: TIER_250, TIER_2K, TIER_10K, TIER_100K, TIER_UNLIMITED, ... |
current_limit / old_limit | Campos 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 comowabaQualityRatingno phone (GET /v1/phones/:id). Não confunda com ostatusdo phone:status(CONNECTED/DISCONNECTED) é o estado da conexão;quality_ratingé a reputação do número na Meta — um número pode estarCONNECTEDeRED.
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.
| Tentativa | Espera desde a anterior |
|---|---|
| 1 | 0s (imediata) |
| 2 | 10s |
| 3 | 30s |
| 4 | 1min |
| 5 | 5min |
| 6 | 30min |
| 7 | 2h |
| 8 | 6h |
| 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
| 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.