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.
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
- No painel, abra a loja → Integrações → Novo webhook personalizado.
- Cole o endereço do seu sistema (precisa ser
https://e público) e escolha os eventos. - Abra o webhook criado e copie a chave de assinatura (
whsec_…). Guarde no seu servidor como variável de ambiente. - No seu endpoint, verifique a assinatura e responda
200rápido. - Clique em Enviar teste. O resultado (status e resposta do seu servidor) aparece no histórico de envios.
Links de checkout
Cada produto tem um link próprio no domínio da loja. Copie o link no painel, em Produtos:
https://<domínio-da-loja>/<nome-do-produto>-<id-do-produto>
Quem identifica o produto é o id no final. O nome é só para leitura: se o produto for renomeado, links antigos continuam funcionando e redirecionam para o novo.
Rastreando a origem (UTMs)
Os parâmetros abaixo, quando presentes na URL, são gravados no pedido e repassados às integrações de rastreamento:
| Parâmetro | Uso comum |
|---|---|
utm_source | Origem (ex.: facebook, kwai) |
utm_medium | Meio (ex.: cpc) |
utm_campaign | Campanha |
utm_content | Criativo / anúncio |
utm_term | Termo / público |
src, sck | Parâmetros usados por UTMify e Kwai |
https://loja.exemplo.com/camiseta-basica-3f2a…?utm_source=facebook&utm_campaign=outubro
Cada valor é limitado a 200 caracteres; caracteres de controle são descartados. Outros parâmetros são ignorados.
Webhooks: a requisição
A cada evento assinado, a Virtus faz um POST no endereço cadastrado com estes headers:
| Header | Valor |
|---|---|
Content-Type | application/json |
User-Agent | Virtus-Webhooks/1.0 |
X-Virtus-Event | Nome do evento (ex.: pedido.pago) |
X-Virtus-Delivery | Id deste envio (muda a cada reenvio) |
X-Virtus-Signature | t=<unix>,v1=<hex> — veja Verificar a assinatura |
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
| Evento | Quando acontece | order.status |
|---|---|---|
pedido.criado | Cliente finalizou e o pagamento foi gerado (ex.: Pix aguardando) | pending |
pedido.pago | Pagamento aprovado | paid |
pedido.recusado | Pagamento recusado ou Pix expirado | failed |
pedido.estornado | Estorno ou chargeback | refunded |
comprovante.enviado | Cliente enviou o comprovante do Pix na tela do pedido | — |
carrinho.abandonado | Cliente começou o checkout e saiu sem fazer o pedido | sem pedido |
teste | Botã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
| Campo | Descrição |
|---|---|
id | Id do acontecimento. Igual em retentativas e reenvios — use para não processar duas vezes. |
event | Nome do evento (tabela acima). |
test | true só no botão “Enviar teste”. Ignore esses no seu fluxo real. |
store | Loja de origem. Útil se o mesmo endpoint atende várias lojas. |
order.status | pending · paid · failed · canceled · refunded |
order.url | Página do pedido para o cliente. String vazia se a loja ainda não tem domínio. |
order.payment_method | pix · credit_card · null |
order.shipping_address | null quando o checkout não pede entrega. |
order.items[].product_id | null 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.receipt | Só em comprovante.enviado: { "file_name", "sent_at" }. O arquivo não é enviado. |
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
- Leia o corpo bruto, exatamente como chegou — antes de qualquer
JSON.parse. Reformatar o JSON muda os bytes e quebra a assinatura. - Separe
tev1do header. - Recuse se
testiver a mais de 5 minutos do seu relógio (bloqueia reenvio de requisições capturadas). - 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);
Entrega, retentativas e duplicidade
| Sua resposta | O que a Virtus faz |
|---|---|
Qualquer 2xx | Entregue. Fim. |
429, 5xx, sem resposta em 10 s ou erro de conexão | Tenta 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
iddo corpo. Retentativas e reenvios repetem o mesmoid— guarde os já processados. - Não dependa da ordem. Em casos raros
pedido.pagopode chegar antes depedido.criado. Useorder.statuscomo verdade e não volte um pedido pago para pendente. - Para eventos que você não usa, responda
200e 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": truenão disparam ação real (entrega, liberação de acesso). - Resposta
2xxem menos de 10 s. - Chave fora do código e fora dos logs.
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:
| Plataforma | Para quê | Eventos |
|---|---|---|
| UTMify | Atribuição de vendas por campanha | pedido criado, pago, recusado, estornado |
| SMS Funnel | Recuperação por SMS/WhatsApp | todos, menos comprovante |
| RePix | Recuperação de Pix pago e não liberado | pedido criado, pago, recusado, estornado |
| ntfy | Notificação de venda no celular | só 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.