docs

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:

  1. Tráfego pago com UTM atribuído (FB Ads, Google Ads, TikTok)
  2. Landing page quente com VSL/copy → CTA único
  3. Checkout 1-step — sem revisão, vai direto: PIX (QR na tela) ou cartão (tokeniza inline)
  4. Order bump no checkout (R$ 9-97 add-on)
  5. One-click upsell pós-compra com pm_* salvo (cartão)
  6. Downsell se rejeitar upsell (versão mais barata)
  7. Email/SMS recovery se abandonar
  8. 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):

QuandoCanalMensagem
+30min após abandonoEmail"Esqueceu de finalizar?"
+2hSMS / WhatsApp"Tá quase, tá indo?" (curto)
+24hEmail"10% off com cupom RECOVER10"
+72hEmail"Ú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étricaSaudávelFoco
CTR landing30-50%Copy/headline
Conversão CTR→pago3-8%Oferta + preço
Conversão PIX 1-step10-20% maior que cartãoTTL do QR ≥ 30min
Bump take rate30-45%Ofertar add-on relevante
Upsell take rate8-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 usageLimit real. 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

Esta página foi útil?

Atualizado em

Nesta página