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.

Os webhooks são de saída: a Virtus avisa o seu sistema quando algo acontece, sem chave de API — basta um endereço https:// que recebe POST. A API de carrinho é de entrada: o servidor do seu site chama a Virtus com a chave da loja.

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.

API de carrinho: como funciona

Para lojas com site próprio e carrinho (o cliente coloca vários produtos e finaliza). O seu site manda o carrinho para a Virtus e recebe o link de um checkout com todos os itens. Modelo de checkout, forma de pagamento, frete e pixels continuam configurados no painel da Virtus.

  1. O cliente clica em Finalizar compra no seu site.
  2. O servidor do seu site chama POST /api/v1/carts com a chave da loja e os itens.
  3. A Virtus responde com checkoutUrl. Seu site redireciona o cliente para esse link.
  4. O cliente paga no checkout da Virtus. Os webhooks avisam o seu sistema.

Não depende de UTM. A venda fica ligada ao externalRef — o id do pedido ou do carrinho no seu sistema. Se o seu site já sabe de onde veio o cliente (UTMs, ids de clique, cookies do Meta), mande em tracking: isso vai para o pedido e para os pixels mesmo que o link chegue sem parâmetros.

Produtos. Ninguém precisa cadastrar nada antes: SKU que ainda não existe na loja vira produto com o nome, o preço e a foto enviados. SKU que já existe não é alterado — o carrinho cobra o preço que você mandou, mas o produto e o link normal dele continuam como estão no painel.

Chave da loja

  1. No painel, abra a loja → Integrações → API de carrinho.
  2. Dê um nome (ex.: Site principal) e clique em Criar chave. Copie na hora: a chave (vk_live_…) aparece uma única vez.
  3. Guarde no servidor do site como variável de ambiente (ex.: VIRTUS_API_KEY).
  4. Em Retorno ao site, informe os domínios do seu site e para onde vai o cliente depois do Pix pago (sua página de obrigado ou as ofertas do checkout).

Toda chamada leva a chave neste header:

Authorization: Bearer vk_live_...
Content-Type: application/json
Nunca use a chave no navegador (JavaScript da página, app). Quem tem a chave cria carrinhos com qualquer preço na sua loja. Vazou? Clique em Revogar no painel — para de valer na hora — e crie outra.

Endereço da API: https://<domínio-da-loja>/api/v1/.... A loja precisa ter domínio próprio configurado.

Criar carrinho

POST /api/v1/carts

{
  "externalRef": "pedido-10293",
  "items": [
    { "sku": "CAM-P-AZUL", "name": "Camiseta azul P", "unitPriceCents": 4990, "quantity": 2,
      "imageUrl": "https://loja.exemplo.com/img/camiseta.jpg" },
    { "sku": "BONE-01", "name": "Boné", "unitPriceCents": 2990, "quantity": 1 }
  ],
  "customer": { "name": "Maria Silva", "email": "maria@exemplo.com", "phone": "11999998888", "cpf": "123.456.789-09" },
  "tracking": {
    "utm": { "utm_source": "facebook", "utm_campaign": "black-friday" },
    "fbclid": "…", "fbp": "fb.1.1700000000.123", "fbc": "fb.1.1700000000.abc",
    "gclid": "…", "ttclid": "…", "kwaiClickId": "…",
    "ip": "203.0.113.9", "userAgent": "Mozilla/5.0 …"
  },
  "returnUrl": "https://loja.exemplo.com/obrigado"
}
CampoRegra
externalRefObrigatório, até 100 caracteres. Id do pedido/carrinho no seu site.
items1 a 50 itens, sem SKU repetido (some as quantidades).
items[].skuCódigo do produto no seu site, até 100 caracteres.
items[].unitPriceCentsInteiro em centavos (R$ 49,90 = 4990), até R$ 1.000.000.
items[].quantity1 a 99.
items[].imageUrlOpcional, https://.
customerOpcional. Só preenche o formulário; o cliente pode mudar.
trackingOpcional. Só os campos do exemplo; outros são recusados. O que o navegador do cliente trouxer no checkout vale por cima.
returnUrlOpcional, https://, num domínio liberado em Retorno ao site.
checkoutModelIdOpcional. Modelo de checkout da loja; sem ele, vale o modelo padrão.

Resposta 201 (criado) ou 200 (atualizado):

{
  "success": true,
  "data": {
    "id": "6b0c…",
    "externalRef": "pedido-10293",
    "checkoutUrl": "https://loja.exemplo.com/c/Xk3…",
    "subtotalCents": 12970,
    "expiresAt": "2026-11-02T20:00:00.000Z"
  },
  "error": null
}

O link vale 24 horas. Frete e desconto do Pix são calculados no checkout, pelo modelo.

Depois do pagamento

Conforme a escolha da loja em Retorno ao site:

  • Voltar ao site: o cliente vai para o seu returnUrl, com dois parâmetros a mais — order (id do pedido na Virtus) e external_ref (o seu). Ex.: https://loja.exemplo.com/obrigado?order=f3a9…&external_ref=pedido-10293. Sem returnUrl, ou num domínio que não está mais liberado, ele fica na página do pedido da Virtus.
  • Seguir as ofertas do checkout: vai para os upsells do modelo de checkout, como num link de produto.
Não libere o pedido pelo retorno. O cliente pode abrir o returnUrl sem pagar. Confirme pelo webhook pedido.pago (ou pelo GET do carrinho) antes de entregar.

Mandar de novo o mesmo externalRef

  • Carrinho aberto: atualiza o mesmo carrinho (mesmo link, validade renovada). Use quando o cliente volta ao site e muda o carrinho.
  • Cliente já gerou o Pix (aguardando pagamento): 409, o carrinho não muda mais.
  • Carrinho vencido: nasce outro, com link novo.
  • Já pago: não mande de novo. Confira antes com Consultar carrinho ou pelo webhook.

Consultar carrinho

GET /api/v1/carts/<id> — situação do carrinho e do pedido. Útil se um webhook não chegou.

{
  "success": true,
  "data": {
    "id": "6b0c…", "externalRef": "pedido-10293",
    "status": "open", "expired": false,
    "orderId": null, "orderStatus": null,
    "totalCents": 12970, "expiresAt": "2026-11-02T20:00:00.000Z"
  },
  "error": null
}

status: open · converted · expired. orderStatus: pending · paid · failed · canceled · refunded ou null.

Erros e limites

Erro sempre no mesmo formato: { "success": false, "data": null, "error": "mensagem" }.

HTTPQuando
400JSON inválido
401Sem chave, chave inválida ou revogada
404Carrinho não existe ou é de outra loja
409Loja desligada, sem domínio próprio, ou carrinho com Pix aguardando pagamento
413Corpo acima de 64 KB
415Faltou Content-Type: application/json
422Campo inválido (a mensagem diz qual, ex.: items.0.unitPriceCents: …) ou produto desativado no painel
429Limite atingido — respeite o header Retry-After
500 / 503Falha do nosso lado: tente de novo com o mesmo externalRef (não duplica)

Limites por loja: 120 carrinhos por minuto e 600 chamadas por minuto. Chave errada repetidas vezes do mesmo IP também é bloqueada por alguns minutos.

Exemplos

Node.js

const res = await fetch("https://loja.exemplo.com/api/v1/carts", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.VIRTUS_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    externalRef: pedido.id,
    items: pedido.itens.map((i) => ({ sku: i.sku, name: i.nome, unitPriceCents: i.precoCentavos, quantity: i.quantidade })),
    returnUrl: "https://loja.exemplo.com/obrigado",
  }),
});
const { success, data, error } = await res.json();
if (!success) throw new Error(error);
return redirect(data.checkoutUrl);

PHP

<?php
$ch = curl_init('https://loja.exemplo.com/api/v1/carts');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('VIRTUS_API_KEY'), 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS => json_encode([
        'externalRef' => $pedido['id'],
        'items' => [['sku' => 'BONE-01', 'name' => 'Boné', 'unitPriceCents' => 2990, 'quantity' => 1]],
    ]),
]);
$resposta = json_decode(curl_exec($ch), true);
if (!$resposta['success']) throw new Exception($resposta['error']);
header('Location: ' . $resposta['data']['checkoutUrl']);

curl

curl -X POST https://loja.exemplo.com/api/v1/carts \
  -H "Authorization: Bearer $VIRTUS_API_KEY" -H "Content-Type: application/json" \
  -d '{"externalRef":"teste-1","items":[{"sku":"A1","name":"Teste","unitPriceCents":500,"quantity":1}]}'

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",
    "external_ref": null,
    "cart_id": null,
    "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.external_ref, order.cart_idPedido que veio da API de carrinho: o seu externalRef e o id do carrinho. null em pedido de link de produto.
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. Inclui a API de carrinho (/api/v1/carts). 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.