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_descriptionque 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
productsno SDK ainda está no roadmap. Usefetchdireto 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
| Camada | Onde 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-seat | Sua 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 comzhexCustomerId. Sempre cheque antes de criar. - Token reaproveitado.
tok_*é single-use. No fluxo acima, crie opm_*primeiro (token vai propaymentMethods.create, é consumido) e depois confirme o intent compayment_method: pm.id— não passe otokenoriginal no confirm. current_period_endno seu BD. Sincronize via webhookpayment_intent.succeeded(que renova o ciclo). Não confie em cron seu — a fonte da verdade é o estado daCustomerSubscriptionna 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
| Evento | Significado para você |
|---|---|
payment_intent.succeeded | Cobrança do ciclo N foi bem-sucedida — atualize period_end, libere acesso |
payment_intent.payment_failed | Falha em cobrança (entrou em retry); envie email de dunning |
payment_intent.canceled | Cobrança/intent cancelado antes de virar ativo |
charge.dispute.created | Chargeback 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.
Atualizado em