OnmIAOnmIA API Docs

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:

  1. HTTPS obrigatório. http:// é recusado no registro (400 SSRF_BLOCKED).
  2. 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 (inclui 169.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.
  3. Sem redirecionamentos. Resposta 3xx conta como falha. Aponte direto para o destino final.
  4. Responda 2xx em até 10 segundos. Estourou o timeout, conta como falha. Sucesso = somente status 2xx.
  5. O corpo da sua resposta é ignorado (lido com cap de 256 KB e descartado).
  6. Responda 2xx somente depois de persistir o evento (fila/tabela local). Processamento pesado deve ser assíncrono.

Política de retry

TentativaQuando ocorre
1Imediata (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

  1. 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.
  2. Rejeite timestamp fora de uma janela curta (recomendado: 5 minutos).
  3. Use comparação em tempo constante (timingSafeEqual / hash_equals / compare_digest). ==/=== vaza timing.
  4. 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

On this page