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 (presente em todo payload) 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 pelo Portal responde 409: a URL e os eventos vêm do ERP, e deixar os dois lados escreverem só produziria disputa silenciosa.
  • Apagar, por outro lado, é permitido. A loja precisa conseguir cortar o canal sem depender do integrador responder. Se o seu sistema mandar o bloco webhook no login seguinte, a linha é recriada, com um secret novo, porque o segredo só é preservado quando a linha já existe. Guarde sempre o secret que vem na resposta de cada POST /api/integration/auth e você se recupera sozinho. O histórico de entregas antigo não volta.
  • Se a loja precisar cortar as entregas para valer, o caminho é desativar ou revogar a credencial no Portal: isso interrompe a entrega imediatamente e impede a recriação.

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": {
    "store_id": "01K8PBSTR00001A2B3C4D5E6F7",
    "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. Todo data traz store_id, a loja de origem (ver Identificadores).
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":{"store_id":"01K8PBSTR00001A2B3C4D5E6F7","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=596051d939dc63032ea2a9e3e8ac96b4684c41883192e9cfea73933541c593bb

Você pode reproduzir no terminal e conferir que bate:

printf '%s' '{"event":"order.paid","data":{"store_id":"01K8PBSTR00001A2B3C4D5E6F7","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)= 596051d939dc63032ea2a9e3e8ac96b4684c41883192e9cfea73933541c593bb

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. Repete a cada documento que valida no mesmo pedido — reprocessamento e troca de NF-e inclusive.
order.refundedO pedido é reembolsado.
order.item_stock_status_changedUma linha do pedido passa a travar o despacho por falta de mercadoria, ou deixa de travar.
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.

Identificadores

Todo data chega com identificadores que você consegue usar direto nas rotas da API, sem precisar de nenhum índice de tradução do seu lado.

CampoO que éOnde usar
store_idIdentificador da loja que originou o evento. Presente em todos os payloads (a única exceção é o evento de teste).Separar as lojas quando vários webhooks apontam para a mesma URL. Ver Matriz e sublojas.
order_numberNúmero do pedido, ex.: ORD-000123. É a identidade pública do pedido.GET /orders/{order_number} e todas as demais rotas de pedido.
shipment_idIdentificador do envio.GET /orders/{order_number}/shipments devolve o mesmo valor em id.
conversation_id / message_idIdentificadores da conversa e da mensagem.Rotas de comunicação.
occurrence_idIdentificador da ocorrência.GET /seller/occurrences/{occurrence_id}. Ver ocorrências.
product_sku_idIdentificador do SKU.Rotas de produtos e estoque.
item_idCódigo público do anúncio, ex.: ITM-0551.Rotas de anúncios.

Pedido é sempre order_number. É o que as rotas de pedido resolvem, e é o mesmo valor que aparece na listagem GET /orders e no painel do vendedor, então o número que chega no webhook é o mesmo que o vendedor lê na tela.


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:

{
  "store_id": "01K8PBSTR00001A2B3C4D5E6F7",
  "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:

{
  "store_id": "01K8PBSTR00001A2B3C4D5E6F7",
  "order_number": "ORD-000123",
  "old_status": "paid",
  "new_status": "preparing"
}

order.cancelled:

{
  "store_id": "01K8PBSTR00001A2B3C4D5E6F7",
  "order_number": "ORD-000123",
  "previous_status": "preparing",
  "reason": "Solicitado pelo cliente",
  "was_paid": true
}

order.invoiced:

{
  "store_id": "01K8PBSTR00001A2B3C4D5E6F7",
  "order_number": "ORD-000123",
  "invoice_id": "01K8PBINV0001A2B3C4D5E6F7G",
  "invoice_type": "nfe"
}

Não é um evento por pedido, é um por documento. Ele sai de novo quando um documento é reprocessado e quando a NF-e do pedido é trocada — nesse caso o invoice_id é o da nota nova, e a anterior passa a ter replaced_at em GET /orders/{id}/fiscal. Trate o par order_number + invoice_id como identidade, e não assuma que o primeiro order.invoiced do pedido é o definitivo.

order.item_stock_status_changed: uma linha do pedido começou (ou parou) de travar o despacho. Veja Estoque por linha:

{
  "store_id": "01K8PBSTR00001A2B3C4D5E6F7",
  "order_number": "ORD-000791",
  "order_status": "awaiting_restock",
  "order_item_id": "01K8ITEM00000000000000002",
  "product_id": "01K8PROD0003CABOHDMI",
  "sku": "CAB-HDMI-21",
  "quantity": 4,
  "old_stock_status": "awaiting_restock",
  "new_stock_status": "sold_reserved",
  "is_blocking_stock": false
}

Só a mudança de bloqueio dispara. O evento sai quando a linha entra em awaiting_restock/in_production ou sai deles. Ele não sai nas transições que valem para todas as linhas de uma vez — a reserva virando venda no pagamento (reserved → sold_reserved) e a venda virando baixa no despacho (sold_reserved → committed) já são contadas por order.paid e order.status_changed. Num pedido de 40 itens, mandar um evento por linha nesses momentos daria 40 entregas para reconstruir o que você já recebeu em uma.

Logística

Todos os eventos de logística trazem o par shipment_id + order_number: o envio para consultar o próprio envio, o pedido para chegar em GET /orders/{order_number}/shipments, que é o caminho que enxerga envio de qualquer transportadora.

shipment.created:

{
  "store_id": "01K8PBSTR00001A2B3C4D5E6F7",
  "shipment_id": "01K8PBSHP0001A2B3C4D5E6F7G",
  "order_number": "ORD-000123",
  "tracking_code": "BR123456789BR"
}

shipment.dispatched:

{
  "store_id": "01K8PBSTR00001A2B3C4D5E6F7",
  "shipment_id": "01K8PBSHP0001A2B3C4D5E6F7G",
  "order_number": "ORD-000123",
  "tracking_code": "BR123456789BR"
}

shipment.out_for_delivery:

{
  "store_id": "01K8PBSTR00001A2B3C4D5E6F7",
  "shipment_id": "01K8PBSHP0001A2B3C4D5E6F7G",
  "order_number": "ORD-000123",
  "tracking_code": "BR123456789BR",
  "status": "in_transit"
}

shipment.status_changed:

{
  "store_id": "01K8PBSTR00001A2B3C4D5E6F7",
  "shipment_id": "01K8PBSHP0001A2B3C4D5E6F7G",
  "order_number": "ORD-000123",
  "from": "ready_to_ship",
  "to": "in_transit"
}

shipment.cancelled:

{
  "store_id": "01K8PBSTR00001A2B3C4D5E6F7",
  "shipment_id": "01K8PBSHP0001A2B3C4D5E6F7G",
  "order_number": "ORD-000123",
  "reason": "Endereço inválido"
}

Ocorrências

occurrence.created e occurrence.resolved compartilham o formato:

{
  "store_id": "01K8PBSTR00001A2B3C4D5E6F7",
  "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:

{
  "store_id": "01K8PBSTR00001A2B3C4D5E6F7",
  "conversation_id": "01K8PBATT00001VWXYZ12345678",
  "order_number": "ORD-000123",
  "type": "post_delivery"
}

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

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

conversation.closed:

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

Estoque

product.stock_updated:

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

stock.depleted:

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

stock.restored:

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

product.price_updated:

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

publication.approved:

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

publication.rejected:

{
  "store_id": "01K8PBSTR00001A2B3C4D5E6F7",
  "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 único payload que não traz store_id: ele não nasce de um evento de negócio, e sim do próprio webhook que você está testando.

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_number + 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.