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
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 campodata.store_iddo 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
usernameepassword. - É o vendedor, cadastrando à mão? Siga pelo Portal:
- Acesse Configurações → API & Integrações.
- Abra a credencial que vai receber os eventos (a mesma do seu ERP serve).
- Vá na aba Webhooks e clique em Novo webhook.
- 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.
- URL de destino: onde a plataforma vai fazer o
- 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"
}| Campo | O que é |
|---|---|
event | O tipo do evento (ex.: order.paid). Use-o para rotear o tratamento. |
data | O payload específico do evento. O formato varia por tipo: ver catálogo. |
timestamp | Quando a entrega foi montada (ISO-8601, UTC). |
webhook_id | Identificador (UUID) do webhook que originou a entrega. Útil para idempotência e log. É string, não número. |
Cabeçalhos
| Cabeçalho | Valor |
|---|---|
Content-Type | application/json |
User-Agent | SAM20-API-Webhook/1.0 |
X-Webhook-Signature | sha256=<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_AT1JfFiUI1xfZZb40rXw1K08XaWnn0lwArgIbHQNO 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=0bc4fafc6bf7a0fd49d74d5979db0ff88b3d3d6eab350eb4109232a0ab12f2c5Você 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)= 0bc4fafc6bf7a0fd49d74d5979db0ff88b3d3d6eab350eb4109232a0ab12f2c5Se 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).
| Grupo | Evento | Dispara quando |
|---|---|---|
| Pedidos | order.created | Um pedido é criado na sua loja. |
order.paid | O pagamento do pedido é confirmado. | |
order.status_changed | O status do pedido muda (qualquer transição). | |
order.cancelled | O pedido é cancelado. | |
order.invoiced | A NF-e (ou Declaração de Conteúdo) do pedido é emitida. | |
order.refunded | O pedido é reembolsado. | |
| Logística | shipment.created | Um envio é criado para um pedido. |
shipment.dispatched | O envio é despachado (postado). | |
shipment.out_for_delivery | O envio sai para entrega. | |
shipment.status_changed | O status do envio muda (qualquer transição). | |
shipment.cancelled | O envio é cancelado. | |
| Ocorrências | occurrence.created | Uma ocorrência visível para a loja é aberta. |
occurrence.resolved | A ocorrência é resolvida. | |
| Mensagens | conversation.opened | Uma conversa de atendimento é aberta. |
conversation.message | Uma nova mensagem chega (exceto as enviadas pela própria loja). | |
conversation.closed | A conversa é encerrada. | |
| Estoque | product.stock_updated | O saldo de um SKU muda. |
stock.depleted | Um SKU zera o estoque. | |
stock.restored | Um SKU volta a ter estoque. | |
product.price_updated | O preço de um SKU muda. | |
| Catálogo | publication.approved | Um anúncio é aprovado. |
publication.rejected | Um 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
}Catálogo
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
2xxe 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
2xxcedo, 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
timestampe 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.

