Webhooks
Eventos outbound assinados HMAC-SHA256, política de retry 0s→12h, requisitos anti-SSRF do receptor e verificação de assinatura em Node, PHP e Python.
Todas as rotas de webhook exigem o scope webhooks:manage. A OnmIA entrega
eventos via POST assinado para o seu endpoint. É assim que o ERP recebe os
pedidos da OnmIA — em especial o evento order.created.
Eventos v1
order.created— emitido uma vez por pedido, no momento da criação, com o pedido completo (cliente, itens, totais). Dispara em todos os canais (catálogo PWA, atendimento WhatsApp/IA, lançamento manual no dashboard, iFood). É o jeito de o ERP receber pedidos.order.status_changed— emitido a cada transição de status relevante do pedido (resumo do pedido, sem itens).webhook.test— emitido pelo endpoint de teste.
Eventos adicionais (catalog.product_updated, stock.changed) estão no roadmap
e serão adicionados de forma aditiva: ao chegarem, não quebram sua
integração — desde que seu receptor responda 2xx e descarte eventos que ainda
não reconhece.
Configurar o webhook do ERP
Há dois caminhos para registrar o endpoint do seu ERP:
- Via dashboard — o administrador da loja registra o endpoint em
app.onmia.com.br→ Configurações → Integrações / API → Webhooks do ERP: informa a URL HTTPS, escolhe os eventos e recebe o secret copy-once. Pode disparar um teste de entrega pela própria tela. - Via API — usando uma chave com scope
webhooks:manage(abaixo).
Registrar endpoint (via API)
curl -sS https://api.onmia.com.br/integration/v1/webhooks \
-H "X-API-Key: $ONMIA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://erp.example.com/onmia/webhooks",
"events": ["order.created", "order.status_changed"]
}'Resposta (201):
{
"id": "3f2a1b0c-9d8e-4f7a-b6c5-d4e3f2a1b0c9",
"url": "https://erp.example.com/onmia/webhooks",
"events": ["order.status_changed"],
"secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"warning": "O secret é exibido uma única vez — armazene com segurança."
}O secret aparece uma vez só
O secret (whsec_...) é exibido uma única vez e não é recuperável depois
(fica cifrado em repouso). Guarde no cofre do ERP. Perdeu? Delete o endpoint e
registre de novo.
- A URL passa pela validação anti-SSRF já no registro. URL bloqueada:
400 SSRF_BLOCKED. - Máximo de 5 endpoints por integração:
409 WEBHOOK_LIMIT_REACHED.
Formato da entrega
Headers enviados pela OnmIA em cada POST (o mesmo transporte e a mesma
assinatura para os três eventos):
Content-Type: application/json
X-Onmia-Event: order.created
X-Onmia-Delivery-Id: <uuid>
X-Onmia-Timestamp: <epoch_seconds>
X-Onmia-Signature-256: sha256=<hmac_hex>Payload de order.created (pedido completo)
O data deste evento traz o pedido inteiro. items[].external_id é a chave
do produto no seu catálogo (o external_id enviado em products/bulk) — use
para mapear cada linha de volta ao ERP.
{
"event": "order.created",
"delivery_id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
"ts": 1781234567,
"data": {
"order_id": "9a1f0e2d-3c4b-4a5e-8f60-71a2b3c4d5e6",
"display_number": "#2026-000594",
"external_order_id": null,
"source_channel": "catalog",
"status": "confirmed",
"store_id": "c5745b49-3883-45f9-8dc4-63cc7d9f2611",
"created_at": "2026-06-13T14:02:10.880Z",
"customer": {
"name": "Jader Costa",
"phone": "+5537991129034",
"email": "jader@example.com",
"address": {
"cep": "35570-000", "street": "Rua X", "number": "100",
"complement": "ap 2", "neighborhood": "Centro", "city": "Formiga", "state": "MG"
}
},
"items": [
{
"external_id": "SKU-4821",
"product_name": "Pizza Calabresa",
"quantity": 1,
"unit_price": 55.00,
"variations": [{ "name": "Grande", "price_modifier": 10 }],
"addons": [{ "name": "Bacon", "quantity": 3, "price": 5 }],
"notes": "sem cebola",
"line_total": 80.00
}
],
"delivery": {
"type": "delivery",
"freight_price": 8.0,
"address": {
"cep": "35570-000", "street": "Rua X", "number": "100",
"neighborhood": "Centro", "city": "Formiga", "state": "MG"
}
},
"payment": { "method": "pix", "status": "paid", "change_for": null },
"totals": { "subtotal": 80.0, "freight": 8.0, "discount": 0, "total": 88.0 },
"metadata": {}
}
}source_channelindica a origem (catalog,whatsapp,manual,ifood, …).external_order_idsó vem preenchido quando o pedido nasce em um canal externo que carrega um id próprio (ex.: iFood); para pedidos nativos OnmIA énull.- O payload contém PII (nome, telefone, email, endereço). É persistido para
entrega/retry, mas nunca aparece em logs nem no histórico de entregas
(
GET /deliveries). order.createdé dedupado na origem: mesmo que dois caminhos internos disparem o mesmo pedido, você recebe no máximo uma entrega por endpoint.
Payload de order.status_changed (resumo)
{
"event": "order.status_changed",
"delivery_id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
"ts": 1781190000,
"data": {
"order_id": "949e4706-c108-4a6c-b9cd-4e89c043935d",
"display_number": "#2026-000123",
"external_order_id": "ERP-PED-2026-4412",
"previous_status": "confirmed",
"status": "cancelled",
"store_id": "c5745b49-3883-45f9-8dc4-63cc7d9f2611"
}
}Use X-Onmia-Delivery-Id como chave de dedup interna: retries de uma mesma
entrega reúsam o mesmo delivery id (com timestamp e assinatura novos a cada
tentativa).
Requisitos do endpoint receptor
A OnmIA aplica um guard anti-SSRF no registro e em cada entrega. Seu receptor precisa atender a todos os itens:
- HTTPS obrigatório.
http://é recusado no registro (400 SSRF_BLOCKED). - Host público. O hostname é resolvido via DNS no registro e a cada
entrega. Se qualquer endereço resolvido (ou um IP literal na URL) cair em
faixas privadas/reservadas —
10.0.0.0/8,127.0.0.0/8,169.254.0.0/16(inclui169.254.169.254),172.16.0.0/12,192.168.0.0/16,::1,fc00::/7, entre outras — a URL é bloqueada. Hostname que não resolve também é bloqueado. - Sem redirecionamentos. Resposta
3xxconta como falha. Aponte direto para o destino final. - Responda
2xxem até 10 segundos. Estourou o timeout, conta como falha. Sucesso = somente status2xx. - O corpo da sua resposta é ignorado (lido com cap de 256 KB e descartado).
- Responda
2xxsomente depois de persistir o evento (fila/tabela local). Processamento pesado deve ser assíncrono.
Política de retry
| Tentativa | Quando ocorre |
|---|---|
| 1 | Imediata (na emissão do evento). |
| 2 | +60 s após a falha da tentativa 1. |
| 3 | +300 s (5 min). |
| 4 | +1800 s (30 min). |
| 5 | +7200 s (2 h). |
| 6 | +43200 s (12 h). |
Máximo de 6 tentativas (janela total de ~14h35min). Esgotou: a entrega vira
dead — estado terminal, sem re-entrega automática. Reconcilie o estado
perdido por polling em GET /orders/:id ou GET /orders?external_order_id=.
Verificação de assinatura
Cada entrega traz X-Onmia-Signature-256: sha256=<hex>, onde <hex> é
HMAC-SHA256(secret, "<timestamp>.<corpo_bruto>").
Erros clássicos que quebram a validação
- Verifique sobre os BYTES BRUTOS do request body. Nunca faça parse do JSON e re-serialize — qualquer diferença de espaços/ordem de chaves muda o HMAC. Capture o raw body antes de qualquer middleware de JSON.
- Rejeite timestamp fora de uma janela curta (recomendado: 5 minutos).
- Use comparação em tempo constante (
timingSafeEqual/hash_equals/compare_digest).==/===vaza timing. - Valide a assinatura antes de processar qualquer coisa do payload.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyOnmiaWebhook({ secret, timestamp, rawBody, signature }) {
// rawBody: Buffer/string com os bytes crus do corpo (não re-serializar!)
const expected = 'sha256=' + createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(signature)
return a.length === b.length && timingSafeEqual(a, b)
}
// Janela anti-replay (5 min):
export function timestampFresh(timestamp, windowSeconds = 300) {
const now = Math.floor(Date.now() / 1000)
return Math.abs(now - Number(timestamp)) <= windowSeconds
}PHP
<?php
// Raw body ANTES de qualquer parse de JSON:
$rawBody = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_ONMIA_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_ONMIA_SIGNATURE_256'] ?? '';
$secret = getenv('ONMIA_WEBHOOK_SECRET'); // whsec_...
// Janela anti-replay (5 min):
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit('timestamp fora da janela');
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('assinatura inválida');
}
// OK: persistir o evento e responder 2xx rápido.
http_response_code(200);Python (Flask)
import hmac
import hashlib
import time
from flask import request, abort
WINDOW_SECONDS = 300 # 5 min
def verify_onmia_webhook(secret: str) -> dict:
raw_body = request.get_data() # bytes crus — NÃO usar request.json antes de validar
timestamp = request.headers.get('X-Onmia-Timestamp', '')
signature = request.headers.get('X-Onmia-Signature-256', '')
if abs(time.time() - int(timestamp or 0)) > WINDOW_SECONDS:
abort(401) # timestamp fora da janela
message = timestamp.encode() + b'.' + raw_body
expected = 'sha256=' + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401) # assinatura inválida
return request.get_json() # parse SOMENTE depois de validarIdempotência
idempotency_key em escritas de fidelidade, dedup de cupom por (cupom, order_id), e dedup de entregas de webhook por delivery_id e order.created na origem.
Fidelidade
Consultar e operar o programa de fidelidade de um cliente — saldo, extrato, cupons, crédito, débito livre e resgate. Idempotência e resolução por phone/external_id.