Direct Response / Infoproduto
Landing → PIX 1-step ou cartão com order bump → upsell 1-click via pm_* → downsell. UTM até a venda, cupom de escassez, affiliate revenue share.
Cenário: infoproduto / curso / mentoria com funil agressivo. A diferença pra e-commerce: você não tem inventário, não tem frete, e cada % de conversão a mais vale 5-10x mais que em loja física porque margem é alta.
O playbook clássico DR BR:
- Tráfego pago com UTM atribuído (FB Ads, Google Ads, TikTok)
- Landing page quente com VSL/copy → CTA único
- Checkout 1-step — sem revisão, vai direto: PIX (QR na tela) ou cartão (tokeniza inline)
- Order bump no checkout (R$ 9-97 add-on)
- One-click upsell pós-compra com
pm_*salvo (cartão) - Downsell se rejeitar upsell (versão mais barata)
- Email/SMS recovery se abandonar
- Affiliate que ganha % na venda (payout manual via PIX)
A Zhex te dá: tokenização hosted (@zhexio/zhex-js), QR PIX em next_action, webhook assinado, idempotência, e pm_* reutilizável pra upsell 1-click. O resto (UI, copy, recovery, affiliate ledger, NFe) é seu.
Modelo de dados
CREATE TABLE funnels (
id text PRIMARY KEY, -- 'curso-node-bf2026'
main_product text, -- prd_*
bump_product text, -- prd_* opcional
upsell_id text, -- upsell pós-checkout
downsell_id text,
active boolean DEFAULT true
);
CREATE TABLE upsells (
id text PRIMARY KEY,
product_id text, -- prd_*
headline text,
cta text,
redirect_to text -- next page
);
CREATE TABLE orders (
id text PRIMARY KEY, -- ord_<uuid>
funnel_id text,
customer_id text,
zhex_intent text, -- pi_* main + bump
zhex_pm text, -- pm_* salvo pra upsell 1-click
status text,
payment_method text, -- 'card' | 'pix'
main_amount int,
bump_amount int DEFAULT 0,
upsell_amount int DEFAULT 0,
total int,
utm_source text, utm_medium text, utm_campaign text, utm_content text, utm_term text,
affiliate_id text,
created_at timestamptz DEFAULT now(),
paid_at timestamptz
);
CREATE TABLE affiliates (
id text PRIMARY KEY, -- aff_<id>
name text,
email text,
pix_key text, -- chave para payout manual
commission_pct int -- 30, 40, 50
);Passo 1 — Landing → checkout 1-step (PIX e cartão)
Em DR BR, PIX 1-step costuma converter mais que cartão (ticket médio R$ 50-500, sem fricção de banco). Ofereça os dois.
<form id="checkout">
<h1>Curso Node em Produção · 12 módulos · R$ 497</h1>
<input id="email" type="email" placeholder="seu@email.com" required />
<input id="name" type="text" placeholder="Nome completo" required />
<input id="phone" type="tel" placeholder="WhatsApp" required />
<input id="document" type="text" placeholder="CPF" required />
<label class="bump">
<input type="checkbox" id="bump" />
<strong>+ Adicionar Mentoria 1:1 (1h) por R$ 97</strong>
<small>Apenas durante este checkout. Depois sai R$ 297.</small>
</label>
<div class="payment-methods">
<button type="button" id="pay-pix">Pagar com PIX · R$ 497</button>
<button type="button" id="pay-card-toggle">Pagar com cartão</button>
</div>
<div id="card-block" hidden>
<div id="card-element"></div>
<button id="pay-card" type="button">Liberar acesso · R$ 497</button>
</div>
<div id="pix-result" hidden>
<img id="qr" />
<code id="copy-paste"></code>
<p>Pagamento confirmado em segundos.</p>
</div>
</form>
<script type="module">
import { Zhex } from 'https://esm.sh/@zhexio/zhex-js';
const config = await fetch('/api/config').then((r) => r.json());
const zhex = Zhex(config.publishableKey, { apiBase: config.apiBase });
const card = zhex.elements().create('card');
card.mount('#card-element');
// Captura UTM + affiliate (30d)
const params = new URLSearchParams(window.location.search);
const ctx = {
utm_source: params.get('utm_source'),
utm_medium: params.get('utm_medium'),
utm_campaign: params.get('utm_campaign'),
utm_content: params.get('utm_content'),
utm_term: params.get('utm_term'),
affiliate: params.get('aff'),
};
document.cookie = `dr_ctx=${encodeURIComponent(JSON.stringify(ctx))}; max-age=2592000; path=/`;
function customerData() {
return {
email: document.getElementById('email').value,
name: document.getElementById('name').value,
phone: document.getElementById('phone').value,
document: document.getElementById('document').value,
};
}
// PIX 1-step
document.getElementById('pay-pix').addEventListener('click', async () => {
const r = await fetch('/api/dr/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
funnelId: 'curso-node-bf2026',
paymentMethod: 'pix',
bumpAccepted: document.getElementById('bump').checked,
customer: customerData(),
ctx,
}),
}).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;
}
});
// Cartão
document.getElementById('pay-card-toggle').addEventListener('click', () => {
document.getElementById('card-block').hidden = false;
});
document.getElementById('pay-card').addEventListener('click', async () => {
const result = await zhex.createToken(card);
if ('error' in result) return alert(result.error.message);
const charge = await fetch('/api/dr/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
funnelId: 'curso-node-bf2026',
paymentMethod: 'card',
token: result.token.id,
bumpAccepted: document.getElementById('bump').checked,
customer: customerData(),
ctx,
}),
}).then((r) => r.json());
if (charge.status === 'succeeded') {
window.location = `/upsell/${charge.upsellId}?order=${charge.orderId}`;
} else if (charge.nextAction?.type === 'redirect_to_url') {
window.location = charge.nextAction.redirect_to_url.url;
} else {
alert(charge.message ?? 'Falha');
}
});
</script>Passo 2 — Backend (main + bump em UMA cobrança)
import Zhex from '@zhexio/node';
const zhex = new Zhex(process.env.ZHEX_SECRET_KEY!);
app.post('/api/dr/checkout', async (req, res) => {
const {
funnelId,
paymentMethod, // 'card' | 'pix'
token, // tok_* (só cartão)
bumpAccepted,
customer: customerData,
ctx = {},
} = req.body;
const funnel = await db.funnels.findUnique({
where: { id: funnelId },
include: { mainProduct: true, bumpProduct: true },
});
if (!funnel?.active) return res.status(404).json({ error: 'funnel_not_found' });
// 1. Customer (idempotente por email)
const customer = await zhex.customers.create(
{ ...customerData },
{ idempotencyKey: `customer:${customerData.email}` },
);
// 2. Total = main + bump (se aceito)
const mainAmount = funnel.mainProduct.amount;
const bumpAmount = bumpAccepted && funnel.bumpProduct ? funnel.bumpProduct.amount : 0;
const total = mainAmount + bumpAmount;
// 3. Order local em pending
const orderId = `ord_${randomUUID()}`;
await db.orders.create({
data: {
id: orderId,
funnel_id: funnelId,
customer_id: customer.id,
status: 'pending',
payment_method: paymentMethod,
main_amount: mainAmount,
bump_amount: bumpAmount,
total,
...ctx,
affiliate_id: ctx.affiliate ?? null,
},
});
// 4. PaymentIntent
const intent = await zhex.paymentIntents.create(
{
amount: total,
currency: 'brl',
customer: customer.id,
payment_method_types: [paymentMethod],
description: `${funnel.mainProduct.name}${bumpAccepted ? ' + Bump' : ''}`,
},
{ idempotencyKey: `intent:${orderId}` },
);
await db.orders.update({
where: { id: orderId },
data: { zhex_intent: intent.id },
});
// 5. Cartão: cria pm_* (pra upsell 1-click) + confirma com pm_*
// PIX: retorna intent + next_action (front mostra QR)
if (paymentMethod === 'card' && token) {
const pm = await zhex.paymentMethods.create({
type: 'card',
token,
customer: customer.id,
});
const confirmed = await zhex.paymentIntents.confirm(intent.id, {
payment_method: pm.id,
});
await db.orders.update({
where: { id: orderId },
data: {
zhex_pm: pm.id,
status: confirmed.status === 'succeeded' ? 'paid' : 'pending',
paid_at: confirmed.status === 'succeeded' ? new Date() : null,
},
});
return res.json({
orderId,
status: confirmed.status,
nextAction: confirmed.next_action ?? null,
upsellId: funnel.upsell_id,
});
}
// PIX
return res.json({
orderId,
status: intent.status,
nextAction: intent.next_action, // pix_display_qr_code
});
});Por que pm_* desde o primeiro charge
Cria o pm_* antes do confirm e passa pm.id no confirm — isso deixa o pm_* válido pra upsell 1-click depois (ele só fica válido depois que o cartão é cobrado com sucesso uma vez). Em PIX, não há pm_* reutilizável hoje (PIX Auto está em desenvolvimento).
Passo 3 — Upsell 1-click (cartão)
A página /upsell/:upsellId?order=ord_* mostra a oferta extra. Sem novo cartão — usa o pm_* salvo:
app.post('/api/dr/upsell/accept', async (req, res) => {
const { orderId, upsellId } = req.body;
const order = await db.orders.findUnique({
where: { id: orderId },
include: { customer: true },
});
if (!order || order.status !== 'paid' || !order.zhex_pm) {
return res.status(400).json({ error: 'invalid_order' });
}
const upsell = await db.upsells.findUnique({
where: { id: upsellId },
include: { product: true },
});
const intent = await zhex.paymentIntents.create(
{
amount: upsell.product.amount,
currency: 'brl',
customer: order.customer_id,
payment_method_types: ['card'],
description: `Upsell: ${upsell.product.name}`,
},
{ idempotencyKey: `upsell:${orderId}:${upsellId}` },
);
const confirmed = await zhex.paymentIntents.confirm(intent.id, {
payment_method: order.zhex_pm, // pm_* salvo — cobra direto
off_session: true, // sinal pro emissor
});
if (confirmed.status === 'succeeded') {
await db.orders.update({
where: { id: orderId },
data: {
upsell_amount: upsell.product.amount,
total: order.total + upsell.product.amount,
},
});
return res.json({ ok: true, next: '/obrigado' });
}
if (confirmed.status === 'requires_action' && confirmed.next_action) {
// Issuer pediu 3DS off_session — manda pro challenge
return res.json({ ok: true, next: confirmed.next_action.redirect_to_url.url });
}
// Falhou — oferece downsell
return res.json({
ok: false,
declined: true,
next: `/downsell/${upsell.downsell_id ?? 'default'}?order=${orderId}`,
});
});Em PIX, upsell 1-click sem fricção ainda não é possível — você redireciona pra um novo checkout PIX (cliente paga de novo). PIX Auto resolve isso quando entregar.
Passo 4 — Downsell
Se o upsell foi recusado pelo banco (ou cliente clicou "não") oferece versão mais barata:
app.post('/api/dr/downsell/accept', async (req, res) => {
const { orderId, downsellId } = req.body;
const order = await db.orders.findUnique({ where: { id: orderId } });
const downsell = await db.upsells.findUnique({
where: { id: downsellId },
include: { product: true },
});
// Mesmo padrão do upsell, com produto mais barato
const intent = await zhex.paymentIntents.create(
{
amount: downsell.product.amount,
currency: 'brl',
customer: order.customer_id,
payment_method_types: ['card'],
description: `Downsell: ${downsell.product.name}`,
},
{ idempotencyKey: `downsell:${orderId}:${downsellId}` },
);
const confirmed = await zhex.paymentIntents.confirm(intent.id, {
payment_method: order.zhex_pm,
off_session: true,
});
return res.json({
ok: confirmed.status === 'succeeded',
next: '/obrigado',
});
});Passo 5 — Cupom de escassez
Cupom com usageLimit é o mecanismo de escassez real — não é fake countdown timer.
Configurar pelo dashboard ou via SQL direto (API V1 de coupon = roadmap):
INSERT INTO coupons (id, productId, code, discountType, discountValue, usageLimit, isActive, expiresAt)
VALUES ('cpn_bf2026', 'prd_curso_node', 'BF2026', 'PERCENTAGE', 30, 100, true, '2026-11-30T23:59:59Z');Validar no checkout:
async function applyCoupon(code, productId, baseAmount) {
const coupon = await db.coupons.findFirst({
where: { code, productId, isActive: true },
});
if (!coupon) throw new Error('coupon_not_found');
if (coupon.expiresAt && coupon.expiresAt < new Date()) throw new Error('coupon_expired');
if (coupon.usageLimit && coupon.usageCount >= coupon.usageLimit) {
throw new Error('coupon_exhausted');
}
const discount = coupon.discountType === 'PERCENTAGE'
? Math.round(baseAmount * coupon.discountValue / 100)
: coupon.discountValue;
// Lock otimista: incrementa usageCount agora; rollback no webhook se intent falhar
await db.coupons.update({
where: { id: coupon.id },
data: { usageCount: { increment: 1 } },
});
return { discount, couponId: coupon.id };
}UI mostra "42 vagas restantes" baseado em usageLimit - usageCount. Quando esgota, botão de aplicar fica desabilitado.
Passo 6 — Affiliate revenue share (payout manual)
Affiliate vê venda atribuída pelo cookie aff= do link. Connect (split automático) está em desenvolvimento — hoje você roda payout manual no fim do mês.
// Webhook payment_intent.succeeded
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() },
});
if (order.affiliate_id) {
const affiliate = await db.affiliates.findUnique({
where: { id: order.affiliate_id },
});
const commission = Math.round(order.main_amount * affiliate.commission_pct / 100);
await db.affiliateCommissions.create({
data: {
affiliate_id: affiliate.id,
order_id: order.id,
amount: commission,
status: 'pending',
},
});
}
}Payout no fim do mês:
export async function payoutAffiliates() {
const month = new Date().toISOString().slice(0, 7);
const grouped = await db.affiliateCommissions.groupBy({
by: ['affiliate_id'],
where: { status: 'pending', created_at: { gte: startOfMonth(month) } },
_sum: { amount: true },
});
for (const { affiliate_id, _sum } of grouped) {
const aff = await db.affiliates.findUnique({ where: { id: affiliate_id } });
// Transferência via banco do merchant (PIX manual ou batch)
await sendPixManually(aff.pix_key, _sum.amount);
await db.affiliateCommissions.updateMany({
where: { affiliate_id, status: 'pending' },
data: { status: 'paid', paid_at: new Date() },
});
}
}Passo 7 — Recovery agressivo
DR tem 3-4 touches em recovery (mais que e-commerce normal):
| Quando | Canal | Mensagem |
|---|---|---|
| +30min após abandono | "Esqueceu de finalizar?" | |
| +2h | SMS / WhatsApp | "Tá quase, tá indo?" (curto) |
| +24h | "10% off com cupom RECOVER10" | |
| +72h | "Última chance — campanha encerra em 24h" |
WhatsApp em BR converte 3-5x melhor que email — vale integrar via parceiro (Z-API, Wati, etc.).
Você dispara via webhook payment_intent.payment_failed ou cron que escaneia orders.status = 'pending' AND created_at < now() - 30min.
Compliance — janela de 7 dias (CDC)
Por lei BR, infoproduto vendido online tem 7 dias de arrependimento (CDC art. 49). Cliente pede refund total e você é obrigado a devolver. Refund é responsabilidade sua — Zhex só processa o estorno quando você chama:
app.post('/api/orders/:id/refund-cdc', 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: 'invalid' });
const days = (Date.now() - order.paid_at.getTime()) / (1000 * 60 * 60 * 24);
if (days > 7) return res.status(400).json({ error: 'window_expired' });
await zhex.refunds.create(
{ payment_intent: order.zhex_intent, reason: 'requested_by_customer' },
{ idempotencyKey: `cdc-refund:${order.id}` },
);
await db.orders.update({ where: { id: order.id }, data: { status: 'refunded' } });
await revokeAccess(order.customer_id);
res.json({ ok: true });
});Métricas DR
| Métrica | Saudável | Foco |
|---|---|---|
| CTR landing | 30-50% | Copy/headline |
| Conversão CTR→pago | 3-8% | Oferta + preço |
| Conversão PIX 1-step | 10-20% maior que cartão | TTL do QR ≥ 30min |
| Bump take rate | 30-45% | Ofertar add-on relevante |
| Upsell take rate | 8-15% | Headline + downsell pronto |
| Refund window 7d | < 5% | Qualidade do produto |
| Recovery rate (pending → pago) | 8-15% | Sequence agressivo |
| Chargeback | < 0,75% | 3DS em > R$ 200 |
Boas práticas
- PIX como default em DR. Conversão maior, sem fricção de banco. Cartão é fallback.
- 1-step checkout, sem revisão. Cada clique extra = -10% conversão.
- Order bump em todo checkout. Item complementar barato (R$ 7-97) tem take rate alto.
- Upsell 1-click via
pm_*(cartão). Não pedir cartão de novo é o que faz upsell DR existir. - Cupom com
usageLimitreal. Escassez fake (countdown sem nada limitado) destrói confiança longo prazo. - CAPI server-side. Bloqueador de pixel client perde 30% das atribuições; CAPI captura quase tudo.
- WhatsApp recovery. Converte 3-5x melhor que email em BR.
- Janela 7d sem fricção. Refund auto-servido evita chargeback (que custa muito mais).
Próximos passos
E-commerce
Order bump, PIX, webhook, refund
Webhook → email
Recovery + dunning pipeline
Cupons
usageLimit, expires_at, escopo
Atualizado em