Skip to content

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/json
User-Agent: WppHub/1.0
X-Hub-Event: message.received
X-Hub-Delivery: 01HZTQDELIVERY01...
X-Hub-Attempt: 1
X-Hub-Timestamp: 1714200000
X-Hub-Signature: sha256=<hex>
X-Hub-Signature-V2: t=1714200000,v1=<hex>
HeaderNotas
User-Agentfixo WppHub/1.0 (override via env WEBHOOK_USER_AGENT)
X-Hub-Eventnome do evento — ver Eventos
X-Hub-DeliveryULID unico por delivery — usar pra idempotency no consumer
X-Hub-AttemptNumero 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-TimestampUnix seconds do envio
X-Hub-SignatureHMAC SHA-256 do body bruto com webhook_secret da app — esquema legacy, deprecated em 2026-08-04
X-Hub-Signature-V2HMAC 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-Signature continuara presente apenas pra debug — nao deve ser usado pra autenticar.

V2 — formato e validacao

X-Hub-Signature-V2: t=1714200000,v1=4f2a8c...64-hex-chars
  • t = unix seconds do envio (mesmo valor do header X-Hub-Timestamp)
  • v1 = HMAC-SHA256(secret, "<t>.<body>") em hex

Receiver deve:

  1. Parsear t e v1 do header.
  2. Validar skew: abs(now_unix_seconds - t) <= 300 (rejeita replays antigos e timestamps futuros impossiveis).
  3. Recalcular HMAC com a string concatenada <t>.<body_bruto>.
  4. Comparar com v1 em 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 hmac
import hashlib
import os
from 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. Sempre timingSafeEqual / hash_equals / compare_digest
  • ❌ Logar a webhook_secret ou 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

  1. Hoje: receiver valida X-Hub-Signature (legacy). Continua funcionando.
  2. Atualize o receiver pra preferir X-Hub-Signature-V2 quando presente; caia em legacy se ausente. Veja exemplo Node.js acima.
  3. Em 2026-08-04: hub para de enviar X-Hub-Signature autenticavel — receiver passa a depender exclusivamente de V2.
  4. 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:

TentativaDelay desde envio anterior
1imediata
230s
32min
410min
51h
66h
724h

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=10000 ms)

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

  1. Use webhook.site ou ngrok pra expor sua maquina
  2. Crie app com webhook_url apontando pra la
  3. Mande mensagem teste — webhook chega
  4. Use GET /v1/webhook-deliveries/:id/payload pra 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).