Webhooks
O PopinaFlow entrega eventos de domínio (pedidos, alterações de cardápio, ajustes de estoque, exports fiscais, etc.) à sua App como POST HTTP. Cada entrega carrega uma assinatura HMAC para que você verifique a origem.
Formato da entrega
Cada evento é um POST JSON para o webhookUrl registrado na App:
POST /seu/webhook HTTP/1.1
Host: sua-app.com.br
Content-Type: application/json
X-PopinaFlow-Event: order.created
X-PopinaFlow-Signature: sha256=4f8b3a9c1e7d2b6a8f5c4e9d3a2b1c8f7e6d5a4b3c2d1e0f9a8b7c6d5e4f3a2b
X-PopinaFlow-Delivery: 65f1c8a2e4b0a8d1e5c3b9a7O corpo é o payload canônico do evento. Os headers:
| Header | Significado |
|---|---|
X-PopinaFlow-Event | Nome do evento (ex: order.created). |
X-PopinaFlow-Signature | sha256=<hex> — HMAC do corpo bruto com o signingSecret da installation. |
X-PopinaFlow-Delivery | UUID/ObjectId único da tentativa de entrega — chave de deduplicação. |
Verificação de assinatura
Cada webhook é assinado com HMAC-SHA256 usando o signingSecret per-installation. O secret é gerado no installApp (32 bytes random → 64 hex chars), persistido server-side, e entregue ao publisher fora de banda durante o onboarding — nunca aparece em qualquer resposta da API após o install.
A assinatura é computada como:
signature = HEX(HMAC_SHA256(signing_secret, raw_request_body))O header chega como sha256=<hex> (convenção GitHub/Stripe).
Exemplo Node.js (Express)
import express from 'express';
import { createHmac, timingSafeEqual } from 'crypto';
const SIGNING_SECRET = process.env.POPINAFLOW_SIGNING_SECRET!;
const app = express();
// CRÍTICO: preserve o raw body — JSON.parse mata a assinatura.
app.post('/seu/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const signatureHeader = req.header('X-PopinaFlow-Signature') ?? '';
const deliveryId = req.header('X-PopinaFlow-Delivery') ?? '';
const event = req.header('X-PopinaFlow-Event') ?? '';
const rawBody = req.body as Buffer;
if (!verifySignature(rawBody, signatureHeader, SIGNING_SECRET)) {
return res.status(401).send('invalid signature');
}
// Idempotência: rejeite IDs já vistos
if (alreadyProcessed(deliveryId)) {
return res.status(200).send('duplicate');
}
markProcessed(deliveryId);
const payload = JSON.parse(rawBody.toString('utf8'));
// Enfileire processamento async — responda 2xx em < 10s
enqueue(event, payload);
res.status(202).send('ok');
},
);
function verifySignature(
rawBody: Buffer,
signatureHeader: string,
signingSecret: string,
): boolean {
const provided = signatureHeader.startsWith('sha256=')
? signatureHeader.slice(7)
: signatureHeader;
const expected = createHmac('sha256', signingSecret)
.update(rawBody)
.digest('hex');
const a = Buffer.from(provided, 'hex');
const b = Buffer.from(expected, 'hex');
if (a.length !== b.length) return false;
return timingSafeEqual(a, b); // comparação constante-tempo
}Exemplo Python (Flask)
import hmac
import hashlib
from flask import Flask, request, abort
app = Flask(__name__)
SIGNING_SECRET = os.environ["POPINAFLOW_SIGNING_SECRET"].encode()
@app.post("/seu/webhook")
def webhook():
raw_body = request.get_data() # bytes brutos, NAO json
signature_header = request.headers.get("X-PopinaFlow-Signature", "")
delivery_id = request.headers.get("X-PopinaFlow-Delivery", "")
event = request.headers.get("X-PopinaFlow-Event", "")
if not verify_signature(raw_body, signature_header, SIGNING_SECRET):
abort(401)
if already_processed(delivery_id):
return "duplicate", 200
mark_processed(delivery_id)
payload = request.get_json()
enqueue_async(event, payload)
return "ok", 202
def verify_signature(raw_body: bytes, header: str, secret: bytes) -> bool:
provided = header[7:] if header.startswith("sha256=") else header
expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(provided, expected) # constante-tempoRegras de canonicalização
- A assinatura é computada sobre o
JSON.stringify(payload)exatamente como o PopinaFlow serializa. O verificador deve usar o corpo bruto da requisição — se vocêJSON.parsee re-stringify antes de validar, ordenação de chaves ou whitespace diferentes vão quebrar a assinatura. - Use um framework HTTP que exponha o raw body: Express
express.raw({ type: 'application/json' }), NestJSrawBody: truenoNestFactory.create, FastAPIawait request.body(), etc. - Sempre use comparação constante-tempo (
crypto.timingSafeEqualem Node,hmac.compare_digestem Python). Comparação naive (===/==) vaza o secret via timing attack.
Política de retry
O PopinaFlow tenta entregar cada delivery até 3 vezes:
| Outcome | Comportamento |
|---|---|
2xx | Entregue, sem retry. |
4xx | Conta como tentativa falha; sem backoff (sua App está rejeitando o shape, não adianta esperar) — passa para failed ou dead quando atinge cap. |
5xx, network error ou timeout | Conta como tentativa falha; agendada nova tentativa com backoff exponencial. |
Backoff exponencial: 60s × 5^(attempt - 1):
| Tentativa após falha # | Delay até próxima tentativa |
|---|---|
| 1 (primeira falha) | 60 segundos |
| 2 (segunda falha) | 5 minutos (300s) |
| 3 (terceira falha) | 25 minutos (1.500s) |
Após 3 tentativas a delivery é marcada dead e não é mais re-enfileirada. Rows dead permanecem visíveis no painel do publisher por 30 dias para debugging.
Mecânica interna: um cron
EVERY_MINUTEemWebhookDeliveryProcessor.runRetrySweepre-enfileira deliveriesfailedcujonextRetryAtjá passou. Skew máximo: ≤ 1 minuto.
Timeouts
Esperamos resposta em até 10 segundos. Respostas mais lentas viram falha e seguem a tabela de retry. Responda rápido e processe async se precisar fazer trabalho pesado — registre o evento em fila local (Redis/SQS/RabbitMQ) e retorne 202 Accepted imediatamente.
Idempotência
X-PopinaFlow-Delivery é único por tentativa. Retries automáticos reutilizam o mesmo deliveryId até a row ficar delivered ou dead. Recomendação:
- Guarde
deliveryIdem uma tabela local (ou Redis com TTL ≥ 25min) na primeira vez que vir. - Se chegar de novo com o mesmo ID antes do TTL expirar, responda
2xxsem reprocessar. - Para idempotência semântica de longo prazo (proteção contra retry após TTL), use também o ID lógico do evento (ex:
orderNumberparaorder.*).
Eventos disponíveis
Convenção de nome: <subject>.<verb> — subject singular, verb no passado. O scope necessário para receber é mapeado pelo subject:
| Evento | Scope necessário (qualquer um) |
|---|---|
order.created | orders.read ou webhooks.subscribe |
order.statusChanged | orders.read ou webhooks.subscribe |
menu.updated | menu.read ou webhooks.subscribe |
inventory.adjusted | inventory.read ou webhooks.subscribe |
fiscal.export.monthly | fiscal.read ou webhooks.subscribe |
O scope webhooks.subscribe é wildcard — recebe todos os eventos sem precisar do read scope correspondente. Use com critério; tenants devem entender que está concedendo acesso amplo.
Schemas de payload
order.created
Disparado quando um pedido novo é criado (guest checkout ou PDV).
{
"tenantId": "65a0b1c2d3e4f5a6b7c8d9e0",
"orderNumber": "20260523-A3F9C12E45",
"status": "pending",
"total": 87.50,
"currency": "BRL",
"branchId": "65a0b1c2d3e4f5a6b7c8d9e2",
"pdvId": "65a0b1c2d3e4f5a6b7c8d9e3",
"items": [
{ "name": "Pizza Margherita", "quantity": 1, "price": 52.00 },
{ "name": "Refrigerante 350ml", "quantity": 2, "price": 17.75 }
],
"customer": {
"name": "João Silva",
"phone": "+5511987654321"
},
"createdAt": "2026-05-23T14:18:42.000Z",
"timestamp": "2026-05-23T14:18:42.123Z"
}order.statusChanged
Disparado em qualquer transição de status: pending → preparing → ready → out_for_delivery → delivered → cancelled.
{
"tenantId": "65a0b1c2d3e4f5a6b7c8d9e0",
"orderNumber": "20260523-A3F9C12E45",
"previousStatus": "preparing",
"status": "ready",
"changedBy": "65a0b1c2d3e4f5a6b7c8d9f1",
"timestamp": "2026-05-23T14:31:08.000Z"
}menu.updated
Disparado quando categoria ou item de cardápio é criado/editado/removido. Granularidade hoje é "algo mudou" — pull via GET /public/v1/menu/items para o estado atualizado.
{
"tenantId": "65a0b1c2d3e4f5a6b7c8d9e0",
"changeType": "item_updated",
"itemId": "65a0b1c2d3e4f5a6b7c8d9f5",
"itemName": "Pizza Margherita",
"timestamp": "2026-05-23T15:02:11.000Z"
}inventory.adjusted
Disparado em movimento de estoque (compra, venda, ajuste manual, transferência).
{
"tenantId": "65a0b1c2d3e4f5a6b7c8d9e0",
"stockItemId": "65a0b1c2d3e4f5a6b7c8d9f8",
"stockItemName": "Mussarela 1kg",
"branchId": "65a0b1c2d3e4f5a6b7c8d9e2",
"delta": -2,
"newQuantity": 18,
"reason": "sale",
"referenceId": "20260523-A3F9C12E45",
"timestamp": "2026-05-23T14:18:50.000Z"
}fiscal.export.monthly
Disparado pelo job mensal de export fiscal (alvo: bridges para sistemas contábeis tipo Domínio Sistemas). Inclui ponteiros para os arquivos XML/SPED gerados — não o conteúdo inline.
{
"tenantId": "65a0b1c2d3e4f5a6b7c8d9e0",
"period": { "year": 2026, "month": 4 },
"exports": [
{ "kind": "nfe", "count": 1248, "downloadUrl": "https://popinaflow.alojaweb.online/fiscal/exports/abc123.zip" },
{ "kind": "sped-fiscal", "count": 1, "downloadUrl": "https://popinaflow.alojaweb.online/fiscal/exports/def456.txt" }
],
"generatedAt": "2026-05-01T03:15:00.000Z",
"timestamp": "2026-05-01T03:15:00.000Z"
}Os
downloadUrlexigemAuthorization: Bearer <accessToken>da App e expiram em 7 dias.
Debugging deliveries
Logs server-side de cada delivery ficam na collection marketplace_webhook_deliveries (visíveis ao publisher pelo painel /superadmin/marketplace/deliveries no MVP, e via endpoint de publisher futuro). Cada row tem:
status:pending | delivering | delivered | failed | deadattempts: contador (0..3)lastResponseStatus,lastResponseBody(truncado a 1KB)nextRetryAt(sestatus=failed)deliveredAt(sestatus=delivered)
Para inspecionar manualmente sua delivery durante desenvolvimento, use o X-PopinaFlow-Delivery retornado em qualquer header.