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.
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.
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.
- O cliente clica em Finalizar compra no seu site.
- O servidor do seu site chama
POST /api/v1/cartscom a chave da loja e os itens. - A Virtus responde com
checkoutUrl. Seu site redireciona o cliente para esse link. - 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
- No painel, abra a loja → Integrações → API de carrinho.
- Dê um nome (ex.: Site principal) e clique em Criar chave. Copie na hora: a chave (
vk_live_…) aparece uma única vez. - Guarde no servidor do site como variável de ambiente (ex.:
VIRTUS_API_KEY). - 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
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"
}
| Campo | Regra |
|---|---|
externalRef | Obrigatório, até 100 caracteres. Id do pedido/carrinho no seu site. |
items | 1 a 50 itens, sem SKU repetido (some as quantidades). |
items[].sku | Código do produto no seu site, até 100 caracteres. |
items[].unitPriceCents | Inteiro em centavos (R$ 49,90 = 4990), até R$ 1.000.000. |
items[].quantity | 1 a 99. |
items[].imageUrl | Opcional, https://. |
customer | Opcional. Só preenche o formulário; o cliente pode mudar. |
tracking | Opcional. Só os campos do exemplo; outros são recusados. O que o navegador do cliente trouxer no checkout vale por cima. |
returnUrl | Opcional, https://, num domínio liberado em Retorno ao site. |
checkoutModelId | Opcional. 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) eexternal_ref(o seu). Ex.:https://loja.exemplo.com/obrigado?order=f3a9…&external_ref=pedido-10293. SemreturnUrl, 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.
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" }.
| HTTP | Quando |
|---|---|
400 | JSON inválido |
401 | Sem chave, chave inválida ou revogada |
404 | Carrinho não existe ou é de outra loja |
409 | Loja desligada, sem domínio próprio, ou carrinho com Pix aguardando pagamento |
413 | Corpo acima de 64 KB |
415 | Faltou Content-Type: application/json |
422 | Campo inválido (a mensagem diz qual, ex.: items.0.unitPriceCents: …) ou produto desativado no painel |
429 | Limite atingido — respeite o header Retry-After |
500 / 503 | Falha 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:
| 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",
"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
| 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.external_ref, order.cart_id | Pedido que veio da API de carrinho: o seu externalRef e o id do carrinho. null em pedido de link de produto. |
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. 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.