Webhooks

Webhooks são notificações push: em vez de você ficar consultando a API de tempos em tempos pra saber se um pedido foi pago ou um envio saiu pra entrega, a plataforma chama o seu sistema assim que o evento acontece. Você cadastra uma URL, escolhe quais eventos quer receber, e a UniSupri faz um POST nessa URL com os dados do evento.

É o jeito recomendado de manter um ERP ou hub sincronizado em tempo real: sem polling, sem estourar o limite de uso.


Como funciona

sequenceDiagram autonumber participant L as Sua loja (eventos) participant P as Plataforma participant S as Seu endpoint Note over L,P: Um pedido é pago, um envio despacha, etc. L->>P: Evento ocorre P->>P: Encontra webhooks ativos<br/>assinando esse evento P->>S: POST https://seu-sistema.com/webhooks<br/>{ event, data, timestamp, webhook_id } S-->>P: 200 OK Note over P,S: Falhou? Reentrega com backoff (1min → 5min → 30min)

Pontos-chave:

  • Escopo por loja. Um webhook só recebe eventos da sua loja. Você nunca recebe dados de outro vendedor.
  • Grupo de lojas não junta webhooks. Mesmo com o acesso store_orders_substores (que faz a matriz ler os pedidos das sublojas pela API), os eventos continuam saindo por loja: pedido de subloja dispara no webhook daquela subloja. Para receber tudo num endpoint só, cadastre um webhook em cada loja apontando para a mesma URL — o campo data.store_id do envelope diz de qual veio. Ver Matriz e sublojas.
  • Vínculo à credencial. Cada webhook pertence a uma credencial de integração (a mesma que seu ERP usa). Ele pode ter sido cadastrado pelo vendedor no Portal ou pela própria integração, na chamada de autenticação — no segundo caso, quem manda na configuração é o ERP e o Portal só lê. Apagar a credencial apaga os webhooks dela.
  • A entrega não usa o seu token. O webhook é autenticado pela assinatura, não pelo token de integração: mesmo que o token expire ou seja renovado, as entregas continuam.
  • Assíncrono. A entrega roda numa fila com retry. Seu endpoint pode ficar fora do ar por alguns minutos sem perder eventos.

Ativar um webhook

Há dois caminhos, e o certo depende de quem você é:

  • É o integrador? Configure pela API, na própria chamada de autenticação — ver Configurar o webhook no login. O vendedor não precisa fazer nada além de te passar username e password.
  • É o vendedor, cadastrando à mão? Siga pelo Portal:
  1. Acesse Configurações → API & Integrações.
  2. Abra a credencial que vai receber os eventos (a mesma do seu ERP serve).
  3. Vá na aba Webhooks e clique em Novo webhook.
  4. Preencha:
    • URL de destino: onde a plataforma vai fazer o POST (precisa ser HTTPS e público).
    • Eventos: marque os que interessam. Dá pra marcar um grupo inteiro (ex.: todos os de Pedidos) de uma vez.
    • Ativo: deixe ligado. Desative depois para pausar os envios sem apagar a configuração.
  5. Salve. A tela mostra o signing secret (whsec_...) uma única vez: copie e guarde. Ele é o que valida a assinatura de cada entrega.

⚠️ O secret aparece só na criação. Se perder, apague o webhook e crie de novo (um novo secret é gerado). Cada webhook tem o seu.

Você pode cadastrar vários webhooks na mesma credencial: por exemplo, uma URL para eventos de pedidos e outra para estoque.


Auto-configuração pela API

Se você é quem integra, não peça pro vendedor cadastrar webhook. Mande o bloco webhook na sua chamada de autenticação e receba o signing secret junto com o token:

{
  "username": "loja-xpto@12345678",
  "password": "...",
  "webhook": { "url": "https://erp.exemplo.com/unisupri/webhook", "events": ["*"] }
}

É idempotente pela credencial: mande em todo login que a configuração converge sozinha — cria na primeira vez, confirma nas demais. Trocar a URL atualiza a configuração no lugar, mantendo o mesmo secret, então você não precisa redistribuir segredo a cada mudança de endpoint.

Um webhook por credencial, por esse caminho. Precisa de duas URLs? Crie duas credenciais, ou cadastre a segunda pelo Portal.

Para validar a assinatura ponta a ponta antes de entrar em produção, use POST /api/integration/webhook/test.

Quem manda na configuração

Um webhook criado por esse caminho fica marcado como gerido pela integração (managed_by: "integration_auth" na listagem do Portal). Consequências:

  • O vendedor vê a configuração, dispara teste e consulta o histórico normalmente.
  • Editar ou apagar pelo Portal responde 409: a configuração vem do ERP, e deixar os dois lados escreverem só produziria disputa silenciosa.
  • Se a loja precisar cortar as entregas, o caminho é desativar ou revogar a credencial no Portal — isso interrompe a entrega imediatamente.

Webhooks cadastrados pelo vendedor no Portal continuam 100% editáveis por ele; a restrição vale só para a linha que a integração gerencia.


Anatomia de uma entrega

Toda entrega é um POST com Content-Type: application/json e este corpo:

{
  "event": "order.paid",
  "data": {
    "order_id": "01H81AV32307PVBSV4RXF15EK9",
    "order_number": "ORD-000123",
    "status": "paid",
    "total_order": "259.90",
    "customer_id": "01K8PB2RCUST00001A2B3C4D5E",
    "created_at": "2026-06-24T18:29:55-03:00"
  },
  "timestamp": "2026-06-24T18:30:01-03:00",
  "webhook_id": "9f1c8a2e-4b7d-4c31-9f0a-2e6d8b1c5a44"
}
CampoO que é
eventO tipo do evento (ex.: order.paid). Use-o para rotear o tratamento.
dataO payload específico do evento. O formato varia por tipo: ver catálogo.
timestampQuando a entrega foi montada (ISO-8601, UTC).
webhook_idIdentificador (UUID) do webhook que originou a entrega. Útil para idempotência e log. É string, não número.

Cabeçalhos

CabeçalhoValor
Content-Typeapplication/json
User-AgentSAM20-API-Webhook/1.0
X-Webhook-Signaturesha256=<hmac>: assinatura do corpo (ver abaixo).

Se você configurar cabeçalhos customizados no webhook (ex.: um Authorization próprio do seu endpoint), eles são enviados junto — e, em caso de nome repetido, o valor customizado substitui o padrão.


Validar a assinatura

Como sua URL é pública, qualquer um poderia tentar forjar uma chamada. Por isso cada entrega vem assinada: o cabeçalho X-Webhook-Signature é o HMAC-SHA256 do corpo cru da requisição, usando como chave o signing secret que você recebeu uma única vez ao criar o webhook: algo como:

whsec_AT1JfFiUI1xfZZb40rXw1K08XaWnn0lwArgIbHQN

O cabeçalho vem no formato sha256=<hex>. Para validar: recalcule o HMAC sobre o corpo exato recebido (bytes crus, antes de qualquer parse) e compare com o cabeçalho usando comparação de tempo constante.

Exemplo passo a passo

Suponha que chegou esta entrega. O corpo cru, exatamente como vem no fio (uma linha, sem espaços): é sobre estes bytes que a assinatura é calculada:

{"event":"order.paid","data":{"order_id":"01H81AV32307PVBSV4RXF15EK9","order_number":"ORD-000123","status":"paid","total_order":"259.90","customer_id":"01K8PB2RCUST00001A2B3C4D5E","created_at":"2026-06-24T18:29:55-03:00"},"timestamp":"2026-06-24T18:30:01-03:00","webhook_id":"9f1c8a2e-4b7d-4c31-9f0a-2e6d8b1c5a44"}

Calculando o HMAC-SHA256 desse corpo com o secret acima como chave, o resultado é o valor que chega no cabeçalho:

X-Webhook-Signature: sha256=0bc4fafc6bf7a0fd49d74d5979db0ff88b3d3d6eab350eb4109232a0ab12f2c5

Você pode reproduzir no terminal e conferir que bate:

printf '%s' '{"event":"order.paid","data":{"order_id":"01H81AV32307PVBSV4RXF15EK9","order_number":"ORD-000123","status":"paid","total_order":"259.90","customer_id":"01K8PB2RCUST00001A2B3C4D5E","created_at":"2026-06-24T18:29:55-03:00"},"timestamp":"2026-06-24T18:30:01-03:00","webhook_id":"9f1c8a2e-4b7d-4c31-9f0a-2e6d8b1c5a44"}' \
  | openssl dgst -sha256 -hmac 'whsec_AT1JfFiUI1xfZZb40rXw1K08XaWnn0lwArgIbHQN'
# SHA2-256(stdin)= 0bc4fafc6bf7a0fd49d74d5979db0ff88b3d3d6eab350eb4109232a0ab12f2c5

Se o valor que você calcular for idêntico ao do cabeçalho (sem o prefixo sha256=), a entrega é autêntica e veio da UniSupri. Qualquer diferença → rejeite com 401.

Mude um único byte do corpo (ou recalcule sobre um JSON re-serializado, com chaves reordenadas ou espaços) e o hash muda por completo. Por isso assine sempre o corpo cru recebido, nunca uma versão reconstruída.

No seu servidor

Node.js (Express):

const crypto = require('crypto');

// Use o corpo CRU (raw). No Express: express.raw({ type: 'application/json' })
function verificarAssinatura(rawBody, signatureHeader, secret) {
  const esperado = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  const a = Buffer.from(signatureHeader || '');
  const b = Buffer.from(esperado);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verificarAssinatura(req.body, req.get('X-Webhook-Signature'), process.env.WEBHOOK_SECRET)) {
    return res.status(401).end();
  }
  const evento = JSON.parse(req.body.toString('utf8'));
  // ... trate evento.event / evento.data
  res.status(200).end();
});

PHP:

$rawBody = file_get_contents('php://input');
$assinatura = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$esperado = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);

if (! hash_equals($esperado, $assinatura)) {
    http_response_code(401);
    exit;
}

$evento = json_decode($rawBody, true);
// ... trate $evento['event'] / $evento['data']
http_response_code(200);

Calcule o HMAC sobre o corpo cru, não sobre um JSON re-serializado. Reserializar reordena chaves e muda espaços, o que quebra a assinatura.


Catálogo de eventos

Os eventos são agrupados por área. Você assina cada um individualmente (ou o grupo inteiro pela UI).

GrupoEventoDispara quando
Pedidosorder.createdUm pedido é criado na sua loja.
order.paidO pagamento do pedido é confirmado.
order.status_changedO status do pedido muda (qualquer transição).
order.cancelledO pedido é cancelado.
order.invoicedA NF-e (ou Declaração de Conteúdo) do pedido é emitida.
order.refundedO pedido é reembolsado.
Logísticashipment.createdUm envio é criado para um pedido.
shipment.dispatchedO envio é despachado (postado).
shipment.out_for_deliveryO envio sai para entrega.
shipment.status_changedO status do envio muda (qualquer transição).
shipment.cancelledO envio é cancelado.
Ocorrênciasoccurrence.createdUma ocorrência visível para a loja é aberta.
occurrence.resolvedA ocorrência é resolvida.
Mensagensconversation.openedUma conversa de atendimento é aberta.
conversation.messageUma nova mensagem chega (exceto as enviadas pela própria loja).
conversation.closedA conversa é encerrada.
Estoqueproduct.stock_updatedO saldo de um SKU muda.
stock.depletedUm SKU zera o estoque.
stock.restoredUm SKU volta a ter estoque.
product.price_updatedO preço de um SKU muda.
Catálogopublication.approvedUm anúncio é aprovado.
publication.rejectedUm anúncio é reprovado.

Exemplos de payload

Abaixo, o conteúdo de data para cada evento. Lembre que ele sempre chega embrulhado no envelope ({ event, data, timestamp, webhook_id }).

Pedidos

order.created, order.paid e order.refunded compartilham o mesmo formato:

{
  "order_id": "01H81AV32307PVBSV4RXF15EK9",
  "order_number": "ORD-000123",
  "status": "paid",
  "total_order": "259.90",
  "customer_id": "01K8PB2RCUST00001A2B3C4D5E",
  "created_at": "2026-06-24T18:29:55-03:00"
}

order.status_changed: traz o status anterior e o novo:

{
  "order_id": "01H81AV32307PVBSV4RXF15EK9",
  "old_status": "paid",
  "new_status": "preparing"
}

order.cancelled:

{
  "order_id": "01H81AV32307PVBSV4RXF15EK9",
  "previous_status": "preparing",
  "reason": "Solicitado pelo cliente",
  "was_paid": true
}

order.invoiced:

{
  "order_id": "01H81AV32307PVBSV4RXF15EK9",
  "invoice_id": "01K8PBINV0001A2B3C4D5E6F7G",
  "invoice_type": "nfe"
}

Logística

shipment.created:

{
  "shipment_id": "shp_a1b2c3",
  "order_id": "01H81AV32307PVBSV4RXF15EK9",
  "tracking_code": "BR123456789BR"
}

shipment.dispatched:

{
  "shipment_id": "shp_a1b2c3",
  "tracking_code": "BR123456789BR"
}

shipment.out_for_delivery:

{
  "shipment_id": "shp_a1b2c3",
  "order_id": "01H81AV32307PVBSV4RXF15EK9",
  "tracking_code": "BR123456789BR",
  "status": "in_transit"
}

shipment.status_changed:

{
  "shipment_id": "shp_a1b2c3",
  "order_id": "01H81AV32307PVBSV4RXF15EK9",
  "from": "ready_to_ship",
  "to": "in_transit"
}

shipment.cancelled:

{
  "shipment_id": "shp_a1b2c3",
  "reason": "Endereço inválido"
}

Ocorrências

occurrence.created e occurrence.resolved compartilham o formato:

{
  "occurrence_id": "01K8PBOCC0001A2B3C4D5E6F7G",
  "title": "Divergência no endereço de entrega",
  "severity": "high",
  "status": "open",
  "trigger_key": "shipping.address_mismatch"
}

severity: low, medium, high ou critical. status: open, resolved, cancelled ou auto_closed.

Mensagens

conversation.opened:

{
  "conversation_id": "01K8PBATT00001VWXYZ12345678",
  "order_id": "01H81AV32307PVBSV4RXF15EK9",
  "type": "post_delivery"
}

conversation.message: só dispara para mensagens que não foram enviadas pela sua loja (cliente ou operador):

{
  "conversation_id": "01K8PBATT00001VWXYZ12345678",
  "message_id": "01K8PBMSG00004VWXYZ12345678",
  "sender_type": "customer",
  "body": "Bom dia, meu pedido já foi enviado?"
}

conversation.closed:

{
  "conversation_id": "01K8PBATT00001VWXYZ12345678",
  "reason": "order_delivered"
}

Estoque

product.stock_updated:

{
  "product_sku_id": "01H81AV32307PVBSV4RXF15LOC",
  "old_quantity": 12,
  "new_quantity": 9,
  "location_id": 3
}

stock.depleted:

{
  "product_sku_id": "01H81AV32307PVBSV4RXF15LOC",
  "previous_quantity": 2,
  "location_id": 3
}

stock.restored:

{
  "product_sku_id": "01H81AV32307PVBSV4RXF15LOC",
  "new_quantity": 50,
  "location_id": 3
}

product.price_updated:

{
  "product_sku_id": "01H81AV32307PVBSV4RXF15LOC",
  "old_price": 199.90,
  "new_price": 179.90,
  "min_quantity": 1
}

publication.approved:

{
  "item_id": "ITM-0551",
  "title": "Furadeira de Impacto 650W"
}

publication.rejected:

{
  "item_id": "ITM-0551",
  "reason": "Imagem principal com marca d'água"
}

Evento de teste

No Portal, o botão Disparar teste enfileira uma entrega real (com assinatura e tudo) para você validar a recepção ponta a ponta. Ela usa o evento webhook.test:

{
  "event": "webhook.test",
  "data": {
    "source": "seller.webhook-test",
    "webhook_id": "9f1c8a2e-4b7d-4c31-9f0a-2e6d8b1c5a44",
    "message": "Evento de teste disparado pelo painel da loja.",
    "sent_at": "2026-06-24T18:30:00-03:00"
  },
  "timestamp": "2026-06-24T18:30:00-03:00",
  "webhook_id": "9f1c8a2e-4b7d-4c31-9f0a-2e6d8b1c5a44"
}

O resultado aparece em Histórico de entregas em alguns instantes. (O teste só dispara se o webhook estiver ativo.)

O mesmo evento é disparado pela API, em POST /api/integration/webhook/test. O campo data.source distingue a origem: seller.webhook-test (botão do Portal) ou integration.webhook-test (API). Trate os dois do mesmo jeito — o event é webhook.test nos dois casos.


Reentrega e confiabilidade

A entrega é considerada bem-sucedida quando seu endpoint responde com um status 2xx. Qualquer outra coisa (4xx, 5xx, timeout, conexão recusada) conta como falha.

  • Timeout: a plataforma espera no máximo 30 segundos pela sua resposta.
  • Tentativas: até 3, com backoff progressivo: 1 min → 5 min → 30 min.
  • Pausa segura: se o webhook for desativado entre a tentativa e o reenvio, a entrega é descartada silenciosamente (não vira falha acumulada).

Cada tentativa é registrada no Histórico de entregas (aba Webhooks no Portal), com evento, número da tentativa, status HTTP, duração e a mensagem de erro quando falha. Use-o para diagnosticar entregas vermelhas.

Responda rápido com 2xx e processe o trabalho pesado depois (fila própria). Se você fizer todo o processamento dentro do request e estourar os 30s, a plataforma considera falha e reenvia: você acaba processando o mesmo evento de novo.


Boas práticas

  • Valide a assinatura em toda entrega. Rejeite (401) o que não bater. Ver Validar a assinatura.
  • Seja idempotente. Por causa dos retries, o mesmo evento pode chegar mais de uma vez. Trate com base numa chave de negócio (ex.: order_id + event) e ignore repetições já processadas.
  • Responda 2xx cedo, processe depois. Aceite a entrega, devolva 200 e jogue o trabalho pesado pra uma fila sua. Não segure o request.
  • Não confie só na ordem. Eventos podem chegar fora de ordem (um retry tardio depois de um evento mais novo). Use o timestamp e o estado atual do recurso para decidir, em vez de assumir sequência.
  • Assine só o que usa. Menos eventos = menos ruído e menos carga no seu endpoint.
  • Use HTTPS e mantenha o endpoint público e estável. Para pausar manutenção, desative o webhook no Portal em vez de derrubar a URL.
  • Não dependa só de webhook para dados críticos. Para conciliação, combine com uma leitura periódica via API (ex.: listar pedidos do dia): webhook é o caminho rápido, o polling esparso é a rede de segurança.