Webhook contract
Hub envia webhooks pra webhook_url da app sempre que algo relevante acontece (mensagem chega, chip cai, etc). Esta pagina documenta o contrato exato — headers, payload, seguranca, retry.
Anatomia de uma delivery
Method e URL
POST {webhook_url da app}webhook_url configurada em POST /v1/apps ou via PATCH /v1/apps/:id. Hub nao segue redirects (3xx) — defina a URL final.
Headers
Content-Type: application/jsonUser-Agent: WppHub/1.0X-Hub-Event: message.receivedX-Hub-Delivery: 01HZTQDELIVERY01...X-Hub-Attempt: 1X-Hub-Timestamp: 1714200000X-Hub-Signature: sha256=<hex>X-Hub-Signature-V2: t=1714200000,v1=<hex>| Header | Notas |
|---|---|
User-Agent | fixo WppHub/1.0 (override via env WEBHOOK_USER_AGENT) |
X-Hub-Event | nome do evento — ver Eventos |
X-Hub-Delivery | ULID unico por delivery — usar pra idempotency no consumer |
X-Hub-Attempt | Numero da tentativa atual (1, 2, 3…). Combinado com X-Hub-Delivery, permite dedup de retries: receiver guarda (delivery, attempt) em set; se chegar mesmo par 2x, retorna 200 sem reprocessar |
X-Hub-Timestamp | Unix seconds do envio |
X-Hub-Signature | HMAC SHA-256 do body bruto com webhook_secret da app — esquema legacy, deprecated em 2026-08-04 |
X-Hub-Signature-V2 | HMAC SHA-256 sobre ${timestamp}.${body} com webhook_secret. Formato t=<unix>,v1=<hex>. Recomendado — adiciona protecao contra replay |
Body
{ "event": "message.received", "timestamp": "2026-04-30T12:34:56.789Z", "data": { ... } // varia por evento}event repete o que vai no header X-Hub-Event (conveniencia pra parser unico). timestamp e o instante do envio (pode diferir do timestamp do recurso — ex: data.timestamp da mensagem original).
data esta documentado em Eventos.
Seguranca — validacao HMAC
Por que assinar
Sem signature, qualquer ator que descubra a webhook_url pode injetar payloads. HMAC com secret compartilhado garante que o webhook veio do hub.
Hub envia dois headers de assinatura em todo delivery:
X-Hub-Signature(legacy) — HMAC sobre o body bruto. Sem replay protection. Mantido pra compatibilidade.X-Hub-Signature-V2(recomendado) — HMAC sobre${timestamp}.${body}. Receiver valida skew de relogio (default 5min) e rejeita replays expirados.
Migracao recomendada: atualize seu receiver pra validar V2 quando presente (e cair em legacy se precisar). O esquema legacy sera removido em 2026-08-04 (90 dias apos esta atualizacao). Apos isso,
X-Hub-Signaturecontinuara presente apenas pra debug — nao deve ser usado pra autenticar.
V2 — formato e validacao
X-Hub-Signature-V2: t=1714200000,v1=4f2a8c...64-hex-charst= unix seconds do envio (mesmo valor do headerX-Hub-Timestamp)v1=HMAC-SHA256(secret, "<t>.<body>")em hex
Receiver deve:
- Parsear
tev1do header. - Validar skew:
abs(now_unix_seconds - t) <= 300(rejeita replays antigos e timestamps futuros impossiveis). - Recalcular HMAC com a string concatenada
<t>.<body_bruto>. - Comparar com
v1em tempo constante (crypto.timingSafeEqual/hmac.compare_digest).
import crypto from 'node:crypto'
function verifyV2(secret, header, body, nowSec = Math.floor(Date.now() / 1000)) { const parts = Object.fromEntries( header.split(',').map((kv) => { const i = kv.indexOf('=') return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()] }), ) const t = Number.parseInt(parts.t, 10) const v1 = parts.v1 if (!t || !v1) return false if (Math.abs(nowSec - t) > 300) return false // skew >5min const expected = crypto .createHmac('sha256', secret) .update(`${t}.${body}`, 'utf8') .digest('hex') if (v1.length !== expected.length) return false return crypto.timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(expected, 'hex'))}Legacy — validacao do body bruto (deprecated 2026-08-04)
HMAC-SHA256(key=webhook_secret, message=request_body_bytes)Resultado vai como hex no header X-Hub-Signature, prefixado por sha256=.
Validacao em Node.js
import crypto from 'node:crypto'import express from 'express'
const app = express()const SECRET = process.env.WPPHUB_WEBHOOK_SECRET
// CRITICO: precisa do body BRUTO (Buffer). Nao use express.json() antes.app.post( '/webhook', express.raw({ type: 'application/json' }), (req, res) => { const sig = req.header('x-hub-signature') || '' const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex')
if ( sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) ) { return res.status(401).send('invalid signature') }
const payload = JSON.parse(req.body.toString()) // processar payload.event + payload.data res.sendStatus(200) },)Validacao em Python (FastAPI)
import hmacimport hashlibimport osfrom fastapi import FastAPI, Request, HTTPException
SECRET = os.getenv('WPPHUB_WEBHOOK_SECRET').encode()app = FastAPI()
@app.post('/webhook')async def webhook(request: Request): body = await request.body() sig = request.headers.get('x-hub-signature', '') expected = 'sha256=' + hmac.new(SECRET, body, hashlib.sha256).hexdigest() if not hmac.compare_digest(sig, expected): raise HTTPException(401, 'invalid signature') payload = await request.json() # processar payload['event'] + payload['data'] return {'ok': True}Validacao em PHP
$body = file_get_contents('php://input');$sig = $_SERVER['HTTP_X_HUB_SIGNATURE'] ?? '';$expected = 'sha256=' . hash_hmac('sha256', $body, $_ENV['WPPHUB_WEBHOOK_SECRET']);
if (!hash_equals($sig, $expected)) { http_response_code(401); exit('invalid signature');}
$payload = json_decode($body, true);// processar $payload['event'] + $payload['data']http_response_code(200);Anti-padroes (NAO faça)
- ❌
JSON.stringify(JSON.parse(body))antes de calcular HMAC — Postgres JSONB nao preserva ordem de keys, qualquer reformatacao quebra - ❌ Comparar com
==— vulneravel a timing attack. SempretimingSafeEqual/hash_equals/compare_digest - ❌ Logar a
webhook_secretou a signature inteira em log de erro - ❌ Aceitar webhook sem validacao em prod (“vou ligar HMAC depois”)
- ❌ Validar so
X-Hub-Signature(legacy) sem checar timestamp — atacante que captura uma delivery pode reenviar pra sempre. Adote V2.
Plano de migracao recomendado
- Hoje: receiver valida
X-Hub-Signature(legacy). Continua funcionando. - Atualize o receiver pra preferir
X-Hub-Signature-V2quando presente; caia em legacy se ausente. Veja exemplo Node.js acima. - Em 2026-08-04: hub para de enviar
X-Hub-Signatureautenticavel — receiver passa a depender exclusivamente de V2. - Apos cutover: remova validacao de legacy do receiver pra fechar superficie.
Idempotency no consumer
X-Hub-Delivery e ULID unico por entrega. Hub pode reentregar (retry) — o X-Hub-Delivery se mantem dentro do retry, e o X-Hub-Attempt incrementa (1 → 2 → 3…).
Padrao recomendado — dedup por (delivery, attempt)
Receiver moderno trata cada (X-Hub-Delivery, X-Hub-Attempt) como evento unico. Se chegar o mesmo par 2x (rede dupla, retry do hub que voce ja respondeu mas resposta perdeu) → retorne 200 imediato sem reprocessar.
const deliveryId = req.header('x-hub-delivery')const attempt = parseInt(req.header('x-hub-attempt'), 10) || 1
const key = `${deliveryId}:${attempt}`if (await alreadyProcessed(key)) { // Hub mandou de novo (rede flap, NAT renegociacao, etc). // Resposta original foi perdida. Devolvemos 200 sem reprocessar. return res.sendStatus(200)}
await processOnce(req.body)await markProcessed(key)return res.sendStatus(200)Padrao simples — dedup so por delivery
Se quiser ignorar retries (qualquer attempt do mesmo delivery e considerado processado):
const deliveryId = req.header('x-hub-delivery')
if (await alreadyProcessed(deliveryId)) { return res.sendStatus(200)}
await processOnce(req.body)await markProcessed(deliveryId)Ambos sao validos. O segundo simplifica mas perde sinal de “quanto retentei” — se voce monitorar attempt counts pra alertar quando vai ficando alto, use o primeiro.
Tambem cada data.message_id (em message.received) e UUID/ULID unico — pode dedupar por ele se preferir.
Retry policy
Hub considera “sucesso” qualquer status 2xx retornado pela webhook_url. Caso contrario, retenta com backoff exponencial:
| Tentativa | Delay desde envio anterior |
|---|---|
| 1 | imediata |
| 2 | 30s |
| 3 | 2min |
| 4 | 10min |
| 5 | 1h |
| 6 | 6h |
| 7 | 24h |
Apos a 7a falha → status failed. Nao retenta automaticamente. Voce pode forcar via POST /v1/webhook-deliveries/:id/replay.
Quando hub PARA de retentar (vai pra failed na hora)
- HTTP 4xx exceto 408/429 — payload errado, retry inutil
- Erro de DNS persistente
Quando hub continua retentando
- HTTP 408, 429, 5xx
- Timeout de rede (default
WEBHOOK_TIMEOUT_MS=10000ms)
Implicacao pro consumer
- Responda 2xx rapido (< 10s) e processe async se necessario. Demorar = timeout = retry desnecessario.
- Em 5xx temporario do seu lado (deploy, etc), retorna 503 pra dar replays.
- Em 4xx (payload invalido), retorna 400 pra hub parar.
Ordem de eventos
Hub nao garante ordem absoluta de eventos. Em alta concorrencia voce pode receber message.received antes do chip.connected se a janela for muito curta. Use data.timestamp ou X-Hub-Timestamp pra ordenar do seu lado se precisar.
Eventos do mesmo recurso (ex: 2 message.status consecutivos da mesma mensagem: delivered → read) tambem podem chegar fora de ordem em situacoes raras. Sempre olhe o status mais “avancado” como verdade.
Throttling
Hub usa 1 worker por app com sua propria fila. Se sua webhook_url esta lenta, isso atrasa so as deliveries dessa app — outras apps nao sofrem.
Pra escalar throughput se voce ficar gargalo: 503 em pico → hub retenta → hora pra deploy/scale.
Como testar localmente
- Use webhook.site ou ngrok pra expor sua maquina
- Crie app com
webhook_urlapontando pra la - Mande mensagem teste — webhook chega
- Use
GET /v1/webhook-deliveries/:id/payloadpra ver exatamente o que foi enviado e validar HMAC do seu lado
Receiver dummy embutido (DEV-only)
Em ambiente DEV (NODE_ENV != production), hub expoe POST /admin/webhook-receiver?app_id=...&fail=N. Pode usar como webhook_url da sua app pra debug — receiver loga o payload, valida HMAC e suporta forced fail (?fail=500).