E-commerce (físico + digital)
Order bump, PIX 1-step, webhook → fulfillment, UTM tracking server-side e refund. Os blocos onde a Zhex entra de verdade — o resto é seu.
Cenário: loja online vendendo produto físico (com endereço) ou digital (acesso instantâneo). PIX como default, cartão com 3DS no ticket alto, boleto pra audiência sem cartão.
A intenção desse recipe é deixar claro o que é responsabilidade da Zhex e o que é responsabilidade sua. A Zhex te dá a camada de cobrança — os elementos do checkout que tokenizam cartão, geram QR PIX e exibem boleto. Tudo que não é cobrar (frete, inventário, multi-step UI, NF-e, email, tracking de envio) você implementa do seu jeito porque cada loja tem necessidades diferentes.
O que a Zhex faz
| Camada | Quem entrega |
|---|---|
| Hosted fields PCI-SAQ-A (cartão) | @zhexio/zhex-js — você só monta <div id="card-element"> |
| Geração de QR PIX | next_action.pix_display_qr_code no PaymentIntent |
| Boleto com linha digitável | next_action.boleto_display_details |
| Roteamento entre adquirentes | Zhex escolhe baseado em BIN/MID |
| Webhook assinado | Você verifica HMAC + libera fulfillment |
| Refund | POST /v1/refunds |
O que NÃO é responsabilidade da Zhex
| Camada | Onde resolver |
|---|---|
| Cálculo de frete (PAC, SEDEX, Mini Envios) | Frenet, Melhor Envio, Correios direto, ou tabela própria |
| Multi-step checkout (carrinho → dados → pagamento) | Sua UI — Zhex roda em qualquer step |
| Inventário (estoque, decremento, reposição) | Seu BD |
| NF-e e fiscal | NFe.io, Bling, Tiny, ou ERP próprio |
| Email transacional (recibo, recovery) | SendGrid, Resend, Postmark, SES — ver recipe webhook → email |
| Tracking de envio | Correios, Loggi, parceiro logístico |
| Cálculo de cupom | Sua lógica — você passa o amount final |
A Zhex é "rail de cobrança", não "plataforma e-commerce". Você plugga ela onde quer cobrar.
Arquitetura mínima
Cliente
↓ checkout (do seu jeito — 1-step, multi-step, hosted, headless)
↓ tokeniza cartão no @zhexio/zhex-js OU pede PIX/boleto
seu backend
↓ /api/checkout cria Customer + PaymentIntent + confirma
↓ webhook /api/webhook libera fulfillment quando succeeded
Zhex
↓ MockPaymentAdapter (test) ou adquirente real (live)Modelo enxuto no seu BD:
CREATE TABLE orders (
id text PRIMARY KEY, -- ord_<uuid>
customer_id text,
zhex_intent text, -- pi_*
status text, -- 'pending' | 'paid' | 'failed' | 'refunded'
amount int,
description text, -- usa pra correlacionar UTM
utm_source text, utm_medium text, utm_campaign text,
created_at timestamptz DEFAULT now(),
paid_at timestamptz
);
CREATE TABLE order_items (
order_id text REFERENCES orders(id),
sku text,
qty int,
unit_price int
);Passo 1 — Order bump como matemática
Order bump não é "feature da Zhex" — é um item a mais no amount do PaymentIntent. Você calcula no backend e cobra em uma cobrança só.
import Zhex from '@zhexio/node';
const zhex = new Zhex(process.env.ZHEX_SECRET_KEY!);
app.post('/api/checkout', async (req, res) => {
const {
items, // [{ sku, qty }]
bumpAccepted, // boolean — checkbox no checkout
paymentMethod, // 'card' | 'pix' | 'boleto'
token, // tok_* (só cartão)
customer: customerData,
utm = {},
} = req.body;
// Calcula total no backend (não confiar no front)
const products = await db.products.findMany({
where: { sku: { in: items.map((i) => i.sku) } },
});
let amount = items.reduce((sum, i) => {
const p = products.find((p) => p.sku === i.sku)!;
return sum + p.price_cents * i.qty;
}, 0);
if (bumpAccepted) {
const bump = await db.products.findFirst({ where: { sku: 'BUMP_OFFER' } });
amount += bump!.price_cents;
}
// 1. Customer (idempotente por email)
const customer = await zhex.customers.create(
customerData,
{ idempotencyKey: `customer:${customerData.email}` },
);
// 2. Order local em pending
const orderId = `ord_${randomUUID()}`;
await db.orders.create({
data: {
id: orderId,
customer_id: customer.id,
status: 'pending',
amount,
description: `${items.map((i) => i.sku).join(',')}${bumpAccepted ? '+bump' : ''}`,
...utm,
},
});
// 3. PaymentIntent
const intent = await zhex.paymentIntents.create(
{
amount,
currency: 'brl',
customer: customer.id,
payment_method_types: [paymentMethod],
description: `Pedido ${orderId}`,
},
{ idempotencyKey: `intent:${orderId}` },
);
await db.orders.update({
where: { id: orderId },
data: { zhex_intent: intent.id },
});
// 4. Cartão: confirma agora. PIX/boleto: retorna next_action
if (paymentMethod === 'card' && token) {
const confirmed = await zhex.paymentIntents.confirm(intent.id, {
payment_method: token,
});
return res.json({
orderId,
status: confirmed.status,
nextAction: confirmed.next_action ?? null,
});
}
return res.json({
orderId,
status: intent.status,
nextAction: intent.next_action ?? null, // PIX QR ou boleto code
});
});Passo 2 — PIX 1-step (funciona)
Cliente clica "Pagar com PIX", você cria o intent com payment_method_types: ['pix'], a resposta vem com next_action.pix_display_qr_code. Mostra o QR/copia-e-cola no front. Webhook entrega payment_intent.succeeded quando o cliente paga.
<button id="pix-btn">Pagar com PIX · R$ 49,90</button>
<div id="pix-result" hidden>
<img id="qr" />
<code id="copy-paste"></code>
</div>
<script type="module">
document.getElementById('pix-btn').addEventListener('click', async () => {
const r = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: [{ sku: 'tenis-42', qty: 1 }],
paymentMethod: 'pix',
customer: { email: 'cliente@x.com', name: 'João', document: '12345678900' },
}),
}).then((r) => r.json());
if (r.nextAction?.type === 'pix_display_qr_code') {
document.getElementById('qr').src = r.nextAction.pix_display_qr_code.image_url;
document.getElementById('copy-paste').textContent = r.nextAction.pix_display_qr_code.copy_paste;
document.getElementById('pix-result').hidden = false;
}
});
</script>Sem redirecionamento. Sem 3-step. Mostra o QR e espera webhook.
PIX Automático (recorrente)
PIX Auto está em desenvolvimento. Estará disponível como payment_method_types: ['pix_auto'] quando entregar — o fluxo de mandado de débito (autorização única, débitos posteriores sem aprovação) usa o mesmo padrão de next_action. Acompanhe via changelog.
Passo 3 — Webhook → fulfillment
import Zhex, { ZhexWebhookSignatureError } from '@zhexio/node';
import express from 'express';
app.post(
'/api/webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
let event;
try {
event = Zhex.webhooks.constructEvent(
req.body,
req.headers['zhex-signature'] as string,
process.env.ZHEX_WEBHOOK_SECRET!,
);
} catch (err) {
if (err instanceof ZhexWebhookSignatureError) {
return res.status(400).send(`Invalid: ${err.message}`);
}
throw err;
}
// Dedup at-least-once
const inserted = await db.processedEvents
.insert({ id: event.id })
.onConflict()
.ignore();
if (!inserted) return res.json({ received: true });
if (event.type === 'payment_intent.succeeded') {
const intent = event.data.object;
const order = await db.orders.findFirst({ where: { zhex_intent: intent.id } });
if (!order) return res.json({ received: true });
await db.orders.update({
where: { id: order.id },
data: { status: 'paid', paid_at: new Date() },
});
// Fulfillment (físico ou digital — sua decisão)
await fulfillmentQueue.add('order_paid', { orderId: order.id });
// Email (recipe webhook-email)
await emailQueue.add('order_paid', { orderId: order.id });
// Server-side tracking pixel (Meta CAPI, GA4 MP)
await reportPurchase(order);
}
if (event.type === 'payment_intent.payment_failed') {
const intent = event.data.object;
const order = await db.orders.findFirst({ where: { zhex_intent: intent.id } });
if (order) {
await db.orders.update({
where: { id: order.id },
data: { status: 'failed' },
});
}
}
res.json({ received: true });
},
);Passo 4 — UTM tracking server-side
A Zhex hoje não tem campo metadata arbitrário. Para correlacionar UTM com a venda, salve o UTM no seu BD (na sua orders.utm_*) e use o description do PaymentIntent como ponte se precisar buscar pelo lado da Zhex.
// Captura UTM no entrypoint da landing
app.get('/produto/:slug', async (req, res) => {
const utm = {
utm_source: req.query.utm_source,
utm_medium: req.query.utm_medium,
utm_campaign: req.query.utm_campaign,
};
res.cookie('utm', JSON.stringify(utm), {
maxAge: 30 * 24 * 60 * 60 * 1000,
});
// ... renderiza produto
});No /api/checkout, leia o cookie e passe utm no body — a db.orders.create salva.
Tracking pixel server-side
Pixel client perde 30%+ por bloqueador. Server-side captura quase tudo. Chame em payment_intent.succeeded:
async function reportPurchase(order) {
// Meta CAPI
await fetch(
`https://graph.facebook.com/v18.0/${process.env.FB_PIXEL_ID}/events`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
access_token: process.env.FB_CAPI_TOKEN,
data: [{
event_name: 'Purchase',
event_time: Math.floor(Date.now() / 1000),
action_source: 'website',
user_data: {
em: [hashSha256(order.customer_email)],
ph: order.customer_phone ? [hashSha256(order.customer_phone)] : [],
},
custom_data: {
currency: 'BRL',
value: order.amount / 100,
order_id: order.id,
},
}],
}),
},
);
// GA4 Measurement Protocol — análogo
}Passo 5 — Refund
Cliente desistiu, problema com produto, ou política de devolução:
app.post('/api/orders/:id/refund', async (req, res) => {
const order = await db.orders.findUnique({ where: { id: req.params.id } });
if (!order || order.status !== 'paid') {
return res.status(400).json({ error: 'order_not_refundable' });
}
await zhex.refunds.create(
{
payment_intent: order.zhex_intent,
reason: 'requested_by_customer',
},
{ idempotencyKey: `refund:${order.id}` },
);
await db.orders.update({
where: { id: order.id },
data: { status: 'refunded' },
});
res.json({ ok: true });
});Refund parcial não é suportado hoje — é tudo-ou-nada por PaymentIntent. Para devolução parcial, planeje o split em PaymentIntents separados na cobrança original (1 PI por item, por exemplo).
Métricas que importam
| Métrica | Saudável (BR) | Investigar se… |
|---|---|---|
| Aprovação cartão | 80-90% | < 75% (BIN routing ou fraude) |
| Aprovação PIX | 95%+ | < 90% (TTL curto demais) |
| Aprovação boleto | 60-75% | < 50% (cliente esquece) |
| Refund ratio | < 3% | > 8% (qualidade ou expectativa) |
| Chargeback ratio | < 0,5% | > 0,75% (fraude ou statement obscuro) |
| Bump take rate | 30-45% | < 20% (oferta irrelevante) |
Boas práticas
- PIX em primeiro na ordem de métodos. Conversão > cartão em ticket baixo.
- Cartão com 3DS automático em > R$ 200 ou customer novo. Liability shift previne ~80% de chargebacks de fraude.
- Statement claro.
MEUSITE*Tenis42>ZHX BR PAYMNT. Reduz chargeback "não reconheço" em 40%. - Server-side tracking pixel. Pixel client só captura ~70% por bloqueadores; CAPI/MP captura 95%+.
- Idempotency-Key em tudo.
customer:<email>,intent:<orderId>,refund:<orderId>— retry seguro.
Próximos passos
Webhook → email
Recibo + dunning + chargeback notice
Direct Response
Order bump + upsell 1-click + UTM
Produtos
Lifecycle, prices
Atualizado em