docs

SaaS subscription

Mensal/anual com cartão internacional + BR. Trial de 14 dias e dunning automático que a Zhex já cuida.

Cenário: cobrar mensal ou anual com cartão (BR e internacional). Trial de 14 dias. Dunning automático em falha de cobrança.

A Zhex já faz a parte recorrente — você integra o signup + tokenização, ela cuida do ciclo de cobrança, das retentativas e da CustomerSubscription.

1. Criar produto + plano (uma vez)

Hoje, plano de assinatura (Subscription template) é configurado pelo dashboard em Produtos → Pagamento → Tipo: Recorrente. Você define:

  • Nome (Premium Mensal)
  • Intervalo (MONTH, YEAR, etc.) e contagem
  • Trial habilitado + dias (ex: 14)
  • Preço base e moedas habilitadas
  • payment_description que aparece no statement (ex: MEUSAAS*Premium)

Endpoint público para criar plano via API está no roadmap. Por enquanto, faça pelo dashboard uma vez por plano e referencie o Product no fluxo de signup.

// Você pode criar o produto via API:
const product = await zhex.products.create({
  name: 'Premium',
  description: 'Plano Premium do MeuSaaS',
  payment: 'recurring',
  type: 'digital',
  category: 'software',
  landing_page: 'https://meusaas.com/pricing',
  price: 9900,                        // R$ 99,00 mensal
  base_currency: 'BRL',
  enabled_currencies: ['BRL', 'USD'],
  payment_description: 'MEUSAAS*Premium',
});
// product.id → "prd_…"
// O template de Subscription (intervalo, trial) você configura no dashboard.

Note: o resource products no SDK ainda está no roadmap. Use fetch direto até lá — endpoints documentados na referência da API.

2. Frontend: tokenizar cartão

<form id="signup">
  <input name="email" type="email" required />
  <input name="name" required />
  <div id="card-element"></div>
  <button type="submit">Iniciar trial de 14 dias</button>
</form>

<script type="module">
  import { Zhex } from 'https://esm.sh/@zhexio/zhex-js';

  const zhex = Zhex('zk_live_...');
  const card = zhex.elements().create('card');
  card.mount('#card-element');

  document.getElementById('signup').addEventListener('submit', async (e) => {
    e.preventDefault();
    const result = await zhex.createToken(card);
    if ('error' in result) {
      alert(result.error.message);
      return;
    }
    const res = await fetch('/api/subscribe', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        email: e.target.email.value,
        name: e.target.name.value,
        token: result.token.id,
      }),
    });
    const json = await res.json();
    window.location = `/dashboard?welcome=${json.customer_subscription_id}`;
  });
</script>

Para projetos com bundler real (Vite/Next/Webpack), prefira npm install @zhexio/zhex-js e o import direto — o snippet acima usa esm.sh para ficar autocontido.

3. Backend: customer + tokenizar PM + cobrar

import { zhex } from '@/lib/zhex';

app.post('/api/subscribe', async (req, res) => {
  const { email, name, token } = req.body;

  // 1) Customer (idempotente por email — a Zhex aceita duplicatas no mesmo modo
  // entre companies, mas no seu BD você quer 1:1)
  const existing = await db.users.findUnique({ where: { email } });
  let zhexCustomerId = existing?.zhexCustomerId;

  if (!zhexCustomerId) {
    const customer = await zhex.customers.create(
      { email, name },
      { idempotencyKey: `customer:${email}` },
    );
    zhexCustomerId = customer.id;
    await db.users.upsert({
      where: { email },
      create: { email, zhexCustomerId },
      update: { zhexCustomerId },
    });
  }

  // 2) PaymentMethod a partir do token (token → pm_*) — token é consumido aqui
  const pm = await zhex.paymentMethods.create({
    type: 'card',
    token,
    customer: zhexCustomerId,
  });

  // 3) Cobrar o produto recorrente — a Zhex cria a CustomerSubscription
  // automaticamente quando o intent confirma
  const intent = await zhex.paymentIntents.create(
    {
      amount: 9900,
      currency: 'brl',
      customer: zhexCustomerId,
      payment_method_types: ['card'],
      description: 'Premium Mensal · trial 14 dias',
    },
    { idempotencyKey: `signup:${email}` },
  );

  const confirmed = await zhex.paymentIntents.confirm(intent.id, {
    payment_method: pm.id,   // pm_* — token já foi consumido em paymentMethods.create
  });

  res.json({
    ok: true,
    payment_intent_id: confirmed.id,
    payment_intent_status: confirmed.status,
  });
});

A CustomerSubscription nasce automaticamente quando o PaymentIntent no produto recorrente confirma com sucesso (com trial → status TRIALING; sem trial → ACTIVE). Você não chama POST /v1/customer_subscriptions.

4. Webhook handler

import Zhex, { ZhexWebhookSignatureError } from '@zhexio/node';
import express from 'express';

const app = express();

app.post(
  '/webhooks/zhex',
  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 no consumer (webhook delivery é at-least-once)
    const inserted = await db.processedEvents.insert({ id: event.id }).onConflict().ignore();
    if (!inserted) return res.json({ received: true });

    switch (event.type) {
      case 'payment_intent.succeeded': {
        const intent = event.data.object as { id: string; customer: string | null };
        // Cobrança ok — em primeiro pagamento (após trial) ou ciclo recorrente.
        // Liberar acesso, atualizar period_end no seu BD.
        if (intent.customer) await grantAccess(intent.customer);
        break;
      }
      case 'payment_intent.payment_failed': {
        const intent = event.data.object as { id: string; customer: string | null };
        // Dunning: enviar email "atualize seu cartão" com link para tokenização nova.
        // NÃO revogue acesso aqui — espere o customer_subscription.canceled.
        if (intent.customer) await sendDunningEmail(intent.customer, intent.id);
        break;
      }
    }
    res.json({ received: true });
  },
);

Não revogue acesso em payment_failed

A Zhex tenta a cobrança recorrente nos próximos ciclos do plano (não é retry imediato no mesmo dia — é a próxima janela de billing). Mas o email de dunning é seu: o webhook payment_intent.payment_failed chega na primeira falha e nas seguintes, e quem manda "atualize seu cartão" é seu app. Revogar acesso já na primeira falha gera fricção desnecessária — boa parte dos clientes resolvem na próxima tentativa (saldo voltou, banco aprovou). Mantenha acesso durante PAST_DUE (grace period) e revogue só quando o CustomerSubscription virar UNPAID ou CANCELED (consulte via GET /v1/customer_subscriptions/:id no momento de agir).

O que NÃO é responsabilidade da Zhex

CamadaOnde resolver
Customer portal (atualizar cartão, ver invoices, cancelar)Sua UI — chama paymentMethods.create no update e customer_subscriptions.cancel no cancelamento
Plan switch com proração (mensal → anual)Sua lógica — cancel a assinatura atual + cria nova com diff de proração calculada por você
Email transacional (boas-vindas, recibo, dunning)SendGrid, Resend, Postmark, SES — ver recipe webhook → email
Métricas SaaS (MRR, churn, LTV)Seu BD — agregue do estado de CustomerSubscription
Convites de team / multi-seatSua aplicação — uma CustomerSubscription por workspace, você gerencia membros

A Zhex te dá o ciclo de cobrança (criar intent recorrente, retentativas em falha, status da assinatura). Tudo que é UI / lógica de produto fica do seu lado.

Pitfalls comuns

  • Email no signup duplicado. A Zhex aceita o mesmo email em test e live (composto por (companyId, email, livemode)), mas no seu BD você quer 1:1 com zhexCustomerId. Sempre cheque antes de criar.
  • Token reaproveitado. tok_* é single-use. No fluxo acima, crie o pm_* primeiro (token vai pro paymentMethods.create, é consumido) e depois confirme o intent com payment_method: pm.id — não passe o token original no confirm.
  • current_period_end no seu BD. Sincronize via webhook payment_intent.succeeded (que renova o ciclo). Não confie em cron seu — a fonte da verdade é o estado da CustomerSubscription na Zhex.
  • Plan switch. Não existe endpoint de "swap" — você cancela a assinatura atual e cria nova. A proração (créditos do tempo restante) é cálculo seu, não da Zhex.

Eventos disponíveis hoje

EventoSignificado para você
payment_intent.succeededCobrança do ciclo N foi bem-sucedida — atualize period_end, libere acesso
payment_intent.payment_failedFalha em cobrança (entrou em retry); envie email de dunning
payment_intent.canceledCobrança/intent cancelado antes de virar ativo
charge.dispute.createdChargeback recebido — registre baixa contábil + revogue acesso

Para o estado da própria CustomerSubscription (activated, trial_will_end, canceled), consulte via GET /v1/customer_subscriptions/:id quando precisar reagir. Vocabulário de events customer_subscription.* é roadmap.

Esta página foi útil?

Atualizado em

Nesta página