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-Timestamp: 1714200000
X-Hub-Signature: sha256=<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-TimestampUnix seconds do envio
X-Hub-SignatureHMAC SHA-256 do body bruto com webhook_secret da app

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.

Algoritmo

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”)

Idempotency no consumer

X-Hub-Delivery e ULID unico por entrega. Hub pode reentregar (retry) — o X-Hub-Delivery se mantem dentro do retry, mas voce deve dedupar caso a caso:

const deliveryId = req.header('x-hub-delivery')
if (await alreadyProcessed(deliveryId)) {
return res.sendStatus(200)
}
// processa idempotente
await markProcessed(deliveryId)

Tambem cada data.message_id (em message.received) e UUID/ULID unico. Voce 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).