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.
Eventos v1
order.status_changed— emitido a cada transição de status relevante do pedido.webhook.test— emitido pelo endpoint de teste.
Eventos adicionais (order.created, catalog.product_updated,
stock.changed) estão no roadmap e serão adicionados de forma aditiva.
Registrar endpoint
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.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:
Content-Type: application/json
X-Onmia-Event: order.status_changed
X-Onmia-Delivery-Id: <uuid>
X-Onmia-Timestamp: <epoch_seconds>
X-Onmia-Signature-256: sha256=<hmac_hex>Corpo (envelope fixo):
{
"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 validar