Integrar com o Virtus Checkout

Tudo que uma plataforma externa precisa para mandar clientes ao checkout e receber, em tempo real, o que acontece com cada pedido.

A integração é de saída: a Virtus avisa o seu sistema quando algo acontece. Você não precisa de chave de API nem expor nada além de um endereço https:// que recebe POST.

Convenções. Valores em dinheiro vêm sempre em centavos (inteiro: 9990 = R$ 99,90). Datas em ISO 8601, UTC (2026-10-14T18:02:11.204Z). Corpo sempre em JSON UTF-8.

Início rápido

  1. No painel, abra a loja → Integrações → Novo webhook personalizado.
  2. Cole o endereço do seu sistema (precisa ser https:// e público) e escolha os eventos.
  3. Abra o webhook criado e copie a chave de assinatura (whsec_…). Guarde no seu servidor como variável de ambiente.
  4. No seu endpoint, verifique a assinatura e responda 200 rápido.
  5. Clique em Enviar teste. O resultado (status e resposta do seu servidor) aparece no histórico de envios.

Webhooks: a requisição

A cada evento assinado, a Virtus faz um POST no endereço cadastrado com estes headers:

HeaderValor
Content-Typeapplication/json
User-AgentVirtus-Webhooks/1.0
X-Virtus-EventNome do evento (ex.: pedido.pago)
X-Virtus-DeliveryId deste envio (muda a cada reenvio)
X-Virtus-Signaturet=<unix>,v1=<hex> — veja Verificar a assinatura
Não confie em nenhum header sozinho. X-Virtus-Event e o User-Agent qualquer um pode imitar. Só a assinatura prova a origem — e o nome do evento confiável é o campo event do corpo, depois de verificado.

Eventos

EventoQuando aconteceorder.status
pedido.criadoCliente finalizou e o pagamento foi gerado (ex.: Pix aguardando)pending
pedido.pagoPagamento aprovadopaid
pedido.recusadoPagamento recusado ou Pix expiradofailed
pedido.estornadoEstorno ou chargebackrefunded
comprovante.enviadoCliente enviou o comprovante do Pix na tela do pedido—
carrinho.abandonadoCliente começou o checkout e saiu sem fazer o pedidosem pedido
testeBotão Enviar teste do painel ("test": true, pedido fictício)paid

Corpo do evento

pedido.pago

{
  "id": "8d1f5a0e-6c55-4c1e-9a43-0f3b2f6a9e21",
  "event": "pedido.pago",
  "created_at": "2026-10-14T18:02:11.204Z",
  "test": false,
  "store": { "id": "b7c1…", "name": "Minha Loja" },
  "order": {
    "id": "f3a9…",
    "status": "paid",
    "url": "https://loja.exemplo.com/pedido/f3a9…",
    "payment_method": "pix",
    "subtotal_cents": 9990,
    "shipping_cents": 1500,
    "shipping_method": "PAC",
    "discount_cents": 500,
    "total_cents": 10990,
    "created_at": "2026-10-14T17:58:40.010Z",
    "customer": {
      "name": "Maria Silva",
      "email": "maria@exemplo.com",
      "phone": "11999999999",
      "cpf": "12345678909"
    },
    "shipping_address": {
      "cep": "01310100", "street": "Av. Paulista", "number": "1000",
      "complement": "", "district": "Bela Vista", "city": "São Paulo", "state": "SP"
    },
    "items": [
      { "product_id": "3f2a…", "name": "Camiseta Básica", "unit_price_cents": 9990, "quantity": 1 }
    ],
    "payment": {
      "status": "paid",
      "gateway_ref": "abc123",
      "pix_expires_at": "2026-10-14T18:28:40.000Z",
      "pix_code": "00020126…"
    },
    "receipt": null
  }
}

Campos

CampoDescrição
idId do acontecimento. Igual em retentativas e reenvios — use para não processar duas vezes.
eventNome do evento (tabela acima).
testtrue só no botão “Enviar teste”. Ignore esses no seu fluxo real.
storeLoja de origem. Útil se o mesmo endpoint atende várias lojas.
order.statuspending · paid · failed · canceled · refunded
order.urlPágina do pedido para o cliente. String vazia se a loja ainda não tem domínio.
order.payment_methodpix · credit_card · null
order.shipping_addressnull quando o checkout não pede entrega.
order.items[].product_idnull se o produto foi excluído depois da venda (nome e preço continuam).
order.paymentÚltima cobrança: status (created · paid · failed · refunded), referência no gateway, validade e código copia-e-cola do Pix. Pode ser null.
order.receiptSó em comprovante.enviado: { "file_name", "sent_at" }. O arquivo não é enviado.
Campos novos podem aparecer. Trate o corpo de forma tolerante: ignore chaves que você não conhece. Nenhum campo existente muda de significado dentro da v1.

carrinho.abandonado

Só é enviado quando o modelo de checkout do produto tem ligado “Avisar os webhooks de quem não terminou a compra”, depois do tempo sem atividade escolhido ali. Um aviso por visita; quem acabou comprando não gera o evento.

Não há pedido ainda: order vem null e os dados ficam em cart. Os dados do cliente são os que ele chegou a preencher (cada um pode ser null).

{
  "id": "2b6c…",
  "event": "carrinho.abandonado",
  "created_at": "2026-10-14T18:40:00.000Z",
  "test": false,
  "store": { "id": "b7c1…", "name": "Minha Loja" },
  "order": null,
  "cart": {
    "session_id": "9e0d…",
    "checkout_url": "https://loja.exemplo.com/camiseta-basica-3f2a…",
    "last_seen_at": "2026-10-14T18:21:57.000Z",
    "payment_methods": ["pix"],
    "customer": { "name": "Maria", "email": null, "phone": "11999999999" },
    "product": { "id": "3f2a…", "name": "Camiseta Básica", "price_cents": 9990 }
  }
}

Verificar a assinatura

Todo envio leva X-Virtus-Signature: t=<unix>,v1=<hex>, onde:

v1 = HMAC-SHA256( chave_do_webhook , "<t>." + corpo_bruto )   // em hexadecimal
  1. Leia o corpo bruto, exatamente como chegou — antes de qualquer JSON.parse. Reformatar o JSON muda os bytes e quebra a assinatura.
  2. Separe t e v1 do header.
  3. Recuse se t estiver a mais de 5 minutos do seu relógio (bloqueia reenvio de requisições capturadas).
  4. Calcule o HMAC com a chave inteira (incluindo o prefixo whsec_) e compare em tempo constante.

Node.js

import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300; // 5 minutos

export function verifyVirtusSignature(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
  if (typeof header !== "string") return false;
  const parts = {};
  for (const pair of header.split(",")) {
    const i = pair.indexOf("=");
    if (i > 0) parts[pair.slice(0, i).trim()] = pair.slice(i + 1).trim();
  }
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
  if (Math.abs(now - t) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
  return timingSafeEqual(expected, Buffer.from(parts.v1, "hex"));
}

// Express: o corpo precisa chegar cru nesta rota
app.post("/webhooks/virtus", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyVirtusSignature(req.body, req.get("X-Virtus-Signature"), process.env.VIRTUS_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(200);          // responde já…
  queue.add(event);              // …e processa fora da requisição
});

Python

import hashlib, hmac, re, time

TOLERANCE_SECONDS = 300  # 5 minutos

def verify_virtus_signature(raw_body: bytes, header, secret: str, now=None) -> bool:
    if not header:
        return False
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or not re.fullmatch(r"[0-9a-f]{64}", v1):
        return False
    now = int(time.time()) if now is None else now
    if abs(now - int(t)) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

# Flask: request.get_data() devolve o corpo cru
# ok = verify_virtus_signature(request.get_data(), request.headers.get("X-Virtus-Signature"), os.environ["VIRTUS_WEBHOOK_SECRET"])

PHP

<?php
function verifyVirtusSignature(string $rawBody, ?string $header, string $secret, ?int $now = null): bool
{
    if (!$header) return false;
    $parts = [];
    foreach (explode(',', $header) as $pair) {
        $kv = explode('=', trim($pair), 2);
        if (count($kv) === 2) $parts[$kv[0]] = $kv[1];
    }
    $t = $parts['t'] ?? '';
    $v1 = $parts['v1'] ?? '';
    if (!ctype_digit($t) || !preg_match('/^[0-9a-f]{64}$/', $v1)) return false;
    if (abs(($now ?? time()) - (int) $t) > 300) return false; // 5 minutos
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    return hash_equals($expected, $v1);
}

$raw = file_get_contents('php://input');
if (!verifyVirtusSignature($raw, $_SERVER['HTTP_X_VIRTUS_SIGNATURE'] ?? null, getenv('VIRTUS_WEBHOOK_SECRET'))) {
    http_response_code(401);
    exit;
}
$event = json_decode($raw, true);
Relógio do servidor. A janela de 5 minutos depende do seu relógio estar certo — mantenha NTP ligado.

Entrega, retentativas e duplicidade

Sua respostaO que a Virtus faz
Qualquer 2xxEntregue. Fim.
429, 5xx, sem resposta em 10 s ou erro de conexãoTenta de novo: até 3 tentativas no total, com espera de ~1 s e ~4 s.
Outros 4xx (ex.: 400, 401, 404)Não tenta de novo — é tratado como recusa definitiva.

Todo envio fica no histórico do webhook no painel, com status e o começo da sua resposta. Envios que falharam podem ser mandados de novo com Reenviar.

Boas práticas no seu lado

  • Responda rápido (bem abaixo de 10 s): grave numa fila e processe depois.
  • Deduplique pelo id do corpo. Retentativas e reenvios repetem o mesmo id — guarde os já processados.
  • Não dependa da ordem. Em casos raros pedido.pago pode chegar antes de pedido.criado. Use order.status como verdade e não volte um pedido pago para pendente.
  • Para eventos que você não usa, responda 200 e ignore.

Segurança

O endereço do webhook

  • Precisa ser https:// e acessível pela internet.
  • Endereços locais e de rede interna são recusados (localhost, .local, .internal, IPs privados), inclusive quando um nome público aponta para eles.
  • Não coloque usuário e senha dentro da URL — use a assinatura.

A chave de assinatura

  • Fica só no servidor (variável de ambiente ou cofre de segredos). Nunca em front-end, app, repositório ou log.
  • Vazou ou alguém saiu da equipe? Use Gerar nova chave na página do webhook. A troca é imediata: a chave antiga para de valer na hora, então atualize seu servidor logo em seguida.
  • Cada webhook tem a sua chave. Não reaproveite entre ambientes.

Dados pessoais (LGPD)

Os eventos de pedido trazem nome, e-mail, telefone, CPF e endereço do comprador. Trate como dado pessoal:

  • Assine só os eventos que você usa e guarde só os campos de que precisa.
  • Não grave o corpo completo em logs; mascare CPF e telefone.
  • order.payment.pix_code é um código de pagamento — não exiba para terceiros.

Checklist antes de ir para produção

  • Assinatura verificada com o corpo bruto e comparação em tempo constante.
  • Janela de 5 minutos no t.
  • Deduplicação pelo id.
  • Eventos com "test": true não disparam ação real (entrega, liberação de acesso).
  • Resposta 2xx em menos de 10 s.
  • Chave fora do código e fora dos logs.
Encontrou uma falha de segurança? Fale direto com a equipe Virtus pelo seu canal de suporte, sem publicar detalhes. Respondemos e corrigimos antes de qualquer divulgação.

Integrações prontas

Algumas plataformas já têm formato próprio no painel — não é preciso programar nada, só colar o endereço/token delas em Integrações:

PlataformaPara quêEventos
UTMifyAtribuição de vendas por campanhapedido criado, pago, recusado, estornado
SMS FunnelRecuperação por SMS/WhatsApptodos, menos comprovante
RePixRecuperação de Pix pago e não liberadopedido criado, pago, recusado, estornado
ntfyNotificação de venda no celularsó venda paga, sem dados do cliente

Essas usam o formato de cada plataforma, não o descrito nesta página. Para qualquer outro sistema, use o webhook personalizado (formato Padrão Virtus).

Versões

v1 — formato atual. Mudanças compatíveis (campos novos, eventos novos) entram sem aviso prévio; você só recebe eventos novos se marcá-los no webhook. Qualquer mudança incompatível vira uma v2, anunciada com antecedência.