Documentação dos webhooks
Esta página é para quem vai escrever o código que recebe. Mande o endereço dela para o seu programador, ou para quem cuida da sua integração.
X-Vibx-Event-Id); a ordem de chegada não é garantida; e confira a assinatura sobre o corpo cru, antes de qualquer parse.Sobre a ordem dos eventos
Dois eventos do mesmo pedido podem chegar fora da ordem em que foram registrados. Ordenar por created_at do corpo dá a ordem em que nós soubemos das coisas — nunca use a ordem de chegada.
created_at idêntico. Aí ele não decide nada — e desempatar pela ordem de chegada é justamente o que produz o defeito: processar order.awaiting_payment depois de order.paid faz o seu estado regredir, e o pedido volta a "aguardando" depois de pago.Para o que depende de sequência, não deduza: leia. O corpo carrega o estado — data.order.approved_at, data.order.refunded_at, data.subscription.status — e ele é a resposta certa para "em que ponto este pedido está". Faça as suas transições monotônicas: um evento que descreve um estado anterior ao que você já registrou deve ser ignorado, não aplicado.
E, para o que precisa fechar, concilie contra o painel. O webhook conta o que aconteceu; ele não é o livro-razão.
Os headers de cada requisição
| Header | O que é |
|---|---|
X-Vibx-Event | O nome público do evento, ex. order.paid |
X-Vibx-Event-Id | O id do EVENTO — estável entre tentativas. É por ele que você deduplica |
X-Vibx-Delivery-Id | O id desta TENTATIVA. Serve para casar a requisição com o nosso log |
X-Vibx-Timestamp | O instante do envio, em segundos desde a época (UTC) |
X-Vibx-Version | v1 |
X-Vibx-Attempt | O contador de tentativas do nosso despacho — pode não começar em 1 |
X-Vibx-Signature | A assinatura, em hexadecimal minúsculo |
User-Agent | VibX-Webhooks/1.0 — estável, serve para filtrar no seu log |
Como conferir a assinatura
A assinatura não é do corpo direto: é de uma string canônica que inclui o hash do corpo. Monte esta string, exatamente assim, e calcule o HMAC-SHA256 dela com o segredo do endereço:
vibx-webhook-v1:<X-Vibx-Timestamp>:<X-Vibx-Event-Id>:<X-Vibx-Delivery-Id>:<sha256hex(corpo cru)>Três coisas derrubam a verificação, e as três produzem o mesmo sintoma — "assinatura inválida" — sem que nada esteja errado com a assinatura: o corpo tem de ser o cru, o hexadecimal é minúsculo, e a comparação precisa ser de tempo constante. Os exemplos abaixo fazem as três.
import crypto from 'node:crypto'
import express from 'express'
const app = express()
// O CORPO CRU. Sem isto o express entrega um objeto já parseado, e reserializar
// muda espaços e ordem de chaves — a assinatura deixa de bater.
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const ts = req.header('X-Vibx-Timestamp')
const eventId = req.header('X-Vibx-Event-Id')
const deliveryId = req.header('X-Vibx-Delivery-Id')
const assinatura = req.header('X-Vibx-Signature')
// OS HEADERS ANTES DE QUALQUER CONTA. Um POST sem assinatura — sonda de
// internet, requisição malformada — faria `assinatura.length` estourar num
// undefined e virar 500, em vez do 401 que era a intenção.
if (!ts || !eventId || !deliveryId || !assinatura) return res.sendStatus(401)
// FRESCOR, e não só assinatura. Assinar o timestamp prova que ele não foi
// alterado; NÃO impede que alguém reenvie uma requisição capturada inteira.
// Recuse o que for velho demais — e também o que vier do futuro além da
// folga de relógio, senão um carimbo adiantado vale para sempre.
const idade = Math.abs(Date.now() / 1000 - Number(ts))
if (!Number.isFinite(idade) || idade > 300) return res.sendStatus(401)
const hashDoCorpo = crypto.createHash('sha256').update(req.body).digest('hex')
const canonica = `vibx-webhook-v1:${ts}:${eventId}:${deliveryId}:${hashDoCorpo}`
const esperada = crypto
.createHmac('sha256', process.env.VIBX_WEBHOOK_SECRET)
.update(canonica)
.digest('hex')
// timingSafeEqual, e não ===. Comparar strings vaza o tamanho do prefixo
// correto pelo tempo da comparação.
const ok =
assinatura.length === esperada.length &&
crypto.timingSafeEqual(Buffer.from(assinatura), Buffer.from(esperada))
if (!ok) return res.sendStatus(401)
// GRAVE ANTES DE RESPONDER. O 200 diz "já é meu", e a Vibx não tenta de novo:
// responder antes de gravar troca o prazo de 15s pela perda definitiva do
// evento se o processo cair aqui — e num host serverless a execução pode ser
// congelada logo depois da resposta, então o "depois" nunca roda.
// RESERVAR E ENFILEIRAR NUM PASSO SÓ. Um `if (jaProcessei) ... else enfileira` é
// uma corrida: duas cópias do mesmo evento — a primeira enfileirou mas a resposta
// se perdeu no caminho — veem "ainda não processei" ao mesmo tempo e enfileiram
// as duas. Use chave única no banco, ou o id do evento como job id da fila, e
// deixe o conflito ser a resposta. Verificar antes não substitui a restrição.
const evento = JSON.parse(req.body)
await enfileirarSeNovo(evento) // ex.: INSERT ... ON CONFLICT DO NOTHING, ou jobId: evento.id
res.sendStatus(200)
// O trabalho pesado vem depois do 200, a partir da fila — não daqui.
})O que a assinatura prova, e o que não prova. Prova que o corpo veio da VibX e não foi alterado no caminho. Não prova que a requisição é nova: quem capturar uma requisição válida pode reenviá-la igual. Por isso o X-Vibx-Timestamp está na string assinada — recuse o que for velho demais para o seu gosto (cinco minutos é um valor comum) e deduplique pelo id do evento.
O que a sua resposta decide
Devolva 200 assim que tiver gravado o evento. Processar antes de responder gasta o prazo de 15 segundos, e não responder dentro dele conta como falha.
| A sua resposta | O que acontece |
|---|---|
2xx | Entregue. O corpo da sua resposta é ignorado |
408, 429, 5xx | Falha TEMPORÁRIA: retentamos dentro da janela |
timeout, conexão recusada, DNS | Falha TEMPORÁRIA: idem |
3xx | Falha PERMANENTE: não seguimos redirecionamento |
400, 401, 403, 404 e demais 4xx | Falha PERMANENTE |
Retentativa
São até 6 tentativas por janela de entrega, e o prazo de cada requisição é de 15 segundos. A próxima tentativa fica elegível imediatamente, e a fila roda de novo 30 segundos depois que a passada anterior termina — não a cada 30 segundos.
Com fila acumulada isso se espalha: num dia tranquilo as seis tentativas cabem em poucos minutos, num dia ruim a janela pode levar horas. Por isso não trate o webhook como livro-razão: se o dado importa, concilie contra o painel.
X-Vibx-Attempt pode não começar em 1 e pode passar de 6 — um reenvio manual abre uma janela nova sem zerar o contador. Não trate nenhum dos dois casos como erro.
Um endereço que passa 24 horas sem aceitar nenhuma entrega é desativado por nós. Isso acontece quando há uma entrega falhando há mais de 24 horas, outra falhou nas últimas 24 horas e nenhuma foi aceita nesse intervalo. O painel passa a mostrar Desativado pela VibX, com a data e o motivo, e as entregas pendentes são encerradas. Corrija o servidor, reative o endereço e envie um evento de teste; o que falhou no período volta pelo botão Reenviar, dentro dos 90 dias.
Os eventos
Você escolhe quais este endereço recebe. Só os escolhidos chegam.
checkout.started | Checkout iniciado |
order.awaiting_payment | Pedido aguardando pagamento |
order.paid | Venda paga |
purchase.tracked | Compra rastreada (com dados de origem) |
order.paid_reaffirmed | Pagamento reafirmado pelo gateway |
order.refund_requested | Reembolso solicitado |
order.refunded | Reembolso concluído |
order.chargeback_opened | Chargeback aberto |
subscription.renewed | Assinatura renovada |
subscription.delayed | Assinatura em atraso |
subscription.canceled | Assinatura cancelada |
subscription.canceled não é o fim do acesso — é o desligamento da renovação automática, e o acesso vale até data.subscription.end_access_date. E order.chargeback_opened cobre chargeback, MED e estorno feito no painel do gateway: ele diz que a cobrança foi desfeita, mas não diz qual dos três foi.O corpo do evento
Todo evento chega no mesmo envelope. O que muda é o type e o que vem dentro de data.
{
"id": "evt_8814",
"type": "order.paid",
"api_version": "v1",
"created_at": "2026-09-09T13:45:02Z",
"product": { "id": 29561 },
"data": {
"order": {
"id": "7194233",
"total_in_cents": 4990,
"currency": "BRL",
"payment_method": "PIX",
"created_at": "2026-09-09T13:44:10Z",
"approved_at": "2026-09-09T13:45:01Z",
"refunded_at": null
},
"products": [
{ "id": "p1", "name": "Curso", "price_in_cents": 4990, "quantity": 1, "main": true }
],
"buyer": {
"name": "Ana",
"email": "ana@example.com",
"phone": "5511999999999",
"document": "12345678900",
"ip": "200.1.2.3",
"external_id": "ext-1"
},
"tracking": {
"utm_source": "instagram",
"utm_medium": "cpc",
"utm_campaign": "black",
"utm_content": "ad1",
"utm_term": null,
"fbc": null,
"fbp": null,
"event_id": null,
"event_base_id": "invoice-7194233",
"ga_client_id": "invoice-7194233",
"ga_session_id": "7194233",
"origin": "BOT",
"user_agent": null,
"source_url": null
},
"commission": {
"total_in_cents": 4990,
"gateway_fee_in_cents": 300,
"seller_in_cents": 4690,
"currency": "BRL"
},
"subscription": null
}
}As cinco chaves de data chegam sempre — order, products, tracking, commission e subscription. Dessas, só subscription pode vir nula, e vem em todo evento que não trata de uma assinatura. As outras quatro nunca são nulas: products sempre traz ao menos um item, e order, tracking e commission são sempre objetos — o que pode ser nulo são campos dentro deles, como order.refunded_at numa venda que ninguém reembolsou.
buyer é a única chave que some, e some quando o endereço não está configurado para receber dado de contato. Ausência e nulidade são coisas diferentes aqui: não escreva o parser esperando "buyer": null.
buyer somem oito campos de tracking: fbc, fbp, event_id, event_base_id, ga_client_id, ga_session_id, user_agent e source_url. São identificadores de pessoa. As UTMs e a origin continuam vindo.Os campos que enganam
created_at (envelope) | Quando a VibX REGISTROU o evento, não quando o fato aconteceu. Uma assinatura que venceu há três dias e só foi notada hoje traz o carimbo de hoje. Resolução de um segundo: dois eventos do mesmo pedido podem empatar. |
payment_method | O token da constante, não o rótulo da tela: PIX, CREDIT_CARD, MERCADO_PAGO, HOTMART, EDUZZ, PIC_PAYA, CRIPTO, ITP, FREE — ou null. |
tracking.origin | BOT ou CHECKOUT, nunca nulo. Em evento de assinatura sai sempre BOT, porque não há sessão para consultar — não use para segmentar canal de compra. |
subscription.status | ACTIVE, DELAYED ou CANCELLED — com dois L. O evento chama-se subscription.canceled, com um. A diferença já custou comparações que nunca são verdadeiras. |
products[] | Sempre com ao menos um item, e exatamente um com main: true. Um order bump é uma LINHA PRÓPRIA, e não um acréscimo ao preço da principal. |
total_in_cents | Nem sempre é dinheiro que entrou: em checkout.started é o preço previsto, e em subscription.delayed/canceled o bloco de comissão vem zerado. |
Firewall e retenção
Se o seu servidor filtra quem entra, libere o nosso endereço de saída:
159.203.158.249É de lá que saem tanto as entregas reais quanto o Enviar teste — de propósito, para que o teste responda a mesma pergunta que uma venda responderia.
A retenção do seu lado é sua. A VibX apaga o conteúdo do evento 90 dias depois, mas o seu servidor não apaga nada sozinho. O que você guardar é responsabilidade sua.
Uma ressalva sobre os valores
data.commission.seller_in_cents é o total menos a taxa do gateway, e mais nada. Ele não desconta a taxa da VibX, nem comissão de afiliado, nem divisão com coprodutor. Numa venda com afiliado ou coprodução ele inclui dinheiro que não é seu — para receita líquida, o número que vale é o do extrato no painel.