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.

As três frases que evitam a maioria dos problemas: o mesmo evento pode chegar mais de uma vez (deduplique por 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.

Mas essa ordenação é PARCIAL. O carimbo tem resolução de um segundo, e dois eventos do mesmo pedido registrados no mesmo segundo chegam com 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

HeaderO que é
X-Vibx-EventO nome público do evento, ex. order.paid
X-Vibx-Event-IdO id do EVENTO — estável entre tentativas. É por ele que você deduplica
X-Vibx-Delivery-IdO id desta TENTATIVA. Serve para casar a requisição com o nosso log
X-Vibx-TimestampO instante do envio, em segundos desde a época (UTC)
X-Vibx-Versionv1
X-Vibx-AttemptO contador de tentativas do nosso despacho — pode não começar em 1
X-Vibx-SignatureA assinatura, em hexadecimal minúsculo
User-AgentVibX-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)>
Nosso hexadecimal é minúsculo, nos dois lugares. Se o seu codificador emitir maiúsculo, comparar as duas strings direto recusa toda requisição válida — e o sintoma parece assinatura inválida, não diferença de formato.

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 respostaO que acontece
2xxEntregue. O corpo da sua resposta é ignorado
408, 429, 5xxFalha TEMPORÁRIA: retentamos dentro da janela
timeout, conexão recusada, DNSFalha TEMPORÁRIA: idem
3xxFalha PERMANENTE: não seguimos redirecionamento
400, 401, 403, 404 e demais 4xxFalha PERMANENTE
O 4xx é o que costuma surpreender. Um 401 momentâneo durante uma troca de credencial do seu lado encerra a entrega. Corrigir o seu servidor depois não faz o evento voltar sozinho — use o botão Reenviar, na tela de Entregas do endereço.

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.startedCheckout iniciado
order.awaiting_paymentPedido aguardando pagamento
order.paidVenda paga
purchase.trackedCompra rastreada (com dados de origem)
order.paid_reaffirmedPagamento reafirmado pelo gateway
order.refund_requestedReembolso solicitado
order.refundedReembolso concluído
order.chargeback_openedChargeback aberto
subscription.renewedAssinatura renovada
subscription.delayedAssinatura em atraso
subscription.canceledAssinatura cancelada
Duas leituras que enganam. 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.

Com "enviar dados do comprador" desligado, além de 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_methodO 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.originBOT 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.statusACTIVE, 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_centsNem 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.