Skip to content

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:

http
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: 65f1c8a2e4b0a8d1e5c3b9a7

O corpo é o payload canônico do evento. Os headers:

HeaderSignificado
X-PopinaFlow-EventNome do evento (ex: order.created).
X-PopinaFlow-Signaturesha256=<hex> — HMAC do corpo bruto com o signingSecret da installation.
X-PopinaFlow-DeliveryUUID/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)

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

python
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-tempo

Regras 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.parse e 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' }), NestJS rawBody: true no NestFactory.create, FastAPI await request.body(), etc.
  • Sempre use comparação constante-tempo (crypto.timingSafeEqual em Node, hmac.compare_digest em Python). Comparação naive (===/==) vaza o secret via timing attack.

Política de retry

O PopinaFlow tenta entregar cada delivery até 3 vezes:

OutcomeComportamento
2xxEntregue, sem retry.
4xxConta 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 timeoutConta 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_MINUTE em WebhookDeliveryProcessor.runRetrySweep re-enfileira deliveries failed cujo nextRetryAt já 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:

  1. Guarde deliveryId em uma tabela local (ou Redis com TTL ≥ 25min) na primeira vez que vir.
  2. Se chegar de novo com o mesmo ID antes do TTL expirar, responda 2xx sem reprocessar.
  3. 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: orderNumber para order.*).

Eventos disponíveis

Convenção de nome: <subject>.<verb> — subject singular, verb no passado. O scope necessário para receber é mapeado pelo subject:

EventoScope necessário (qualquer um)
order.createdorders.read ou webhooks.subscribe
order.statusChangedorders.read ou webhooks.subscribe
menu.updatedmenu.read ou webhooks.subscribe
inventory.adjustedinventory.read ou webhooks.subscribe
fiscal.export.monthlyfiscal.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).

json
{
  "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.

json
{
  "tenantId": "65a0b1c2d3e4f5a6b7c8d9e0",
  "orderNumber": "20260523-A3F9C12E45",
  "previousStatus": "preparing",
  "status": "ready",
  "changedBy": "65a0b1c2d3e4f5a6b7c8d9f1",
  "timestamp": "2026-05-23T14:31:08.000Z"
}

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.

json
{
  "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).

json
{
  "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.

json
{
  "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 downloadUrl exigem Authorization: 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 | dead
  • attempts: contador (0..3)
  • lastResponseStatus, lastResponseBody (truncado a 1KB)
  • nextRetryAt (se status=failed)
  • deliveredAt (se status=delivered)

Para inspecionar manualmente sua delivery durante desenvolvimento, use o X-PopinaFlow-Delivery retornado em qualquer header.

Lançado sob a licença MIT.