docs

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

CamadaQuem entrega
Hosted fields PCI-SAQ-A (cartão)@zhexio/zhex-js — você só monta <div id="card-element">
Geração de QR PIXnext_action.pix_display_qr_code no PaymentIntent
Boleto com linha digitávelnext_action.boleto_display_details
Roteamento entre adquirentesZhex escolhe baseado em BIN/MID
Webhook assinadoVocê verifica HMAC + libera fulfillment
RefundPOST /v1/refunds

O que NÃO é responsabilidade da Zhex

CamadaOnde 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 fiscalNFe.io, Bling, Tiny, ou ERP próprio
Email transacional (recibo, recovery)SendGrid, Resend, Postmark, SES — ver recipe webhook → email
Tracking de envioCorreios, Loggi, parceiro logístico
Cálculo de cupomSua 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étricaSaudável (BR)Investigar se…
Aprovação cartão80-90%< 75% (BIN routing ou fraude)
Aprovação PIX95%+< 90% (TTL curto demais)
Aprovação boleto60-75%< 50% (cliente esquece)
Refund ratio< 3%> 8% (qualidade ou expectativa)
Chargeback ratio< 0,5%> 0,75% (fraude ou statement obscuro)
Bump take rate30-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

Esta página foi útil?

Atualizado em

Nesta página