Assinaturas e billing
Subscription (plano) vs CustomerSubscription (ativa), trial, dunning e o lifecycle real exposto pela API V1.
A Zhex separa dois conceitos que muita plataforma confunde:
Subscription— o plano (template). Define preço, intervalo, trial. Vive no produto.CustomerSubscription— a assinatura ativa de um cliente sobre aquele plano. Tem status, datas de billing, ciclo atual.
A analogia: Subscription é menu, CustomerSubscription é pedido em andamento. Você pode ter 1 menu (plano "Premium Mensal") com 10.000 pedidos ativos cobrando R$ 99 a cada 30 dias.
Confundir os dois é onde a maioria dos bugs de billing nasce — atualizar o plano não muda nenhum pedido em andamento, e cancelar um pedido não desativa o menu. Mantenha-os mentalmente separados.
Onde cada um vive
| Recurso | Configuração | Acesso |
|---|---|---|
Subscription (plano) | Dashboard → Produtos → Pagamento | Hoje só pelo dashboard. Endpoint público no roadmap. |
CustomerSubscription (instância) | API V1 — GET e cancel | /v1/customer_subscriptions/... |
A CustomerSubscription é criada automaticamente quando o cliente paga um produto recorrente — você não precisa de endpoint "criar assinatura". Basta cobrar o Product no PaymentIntent; a assinatura nasce no sucesso.
Lifecycle do CustomerSubscription
| Status | Significado | Acesso? |
|---|---|---|
INCOMPLETE | 1ª PaymentIntent ainda não confirmou | Não |
TRIALING | Em período de trial gratuito | Sim |
ACTIVE | Cobrando normalmente | Sim |
PAST_DUE | Última cobrança falhou; em retry | Sim (grace period 7 dias) |
UNPAID | Esgotou retries | Bloqueie |
CANCELED | Encerrada (final) | Não |
A diferença entre PAST_DUE e UNPAID é onde 30% do churn involuntário se decide. Trate ambos com email automático "atualize seu cartão" — em PAST_DUE, mantenha acesso; em UNPAID, bloqueie mas mantenha login para recuperar.
Plano (configurado no dashboard)
// representação interna
{
id: 'sub_abc',
productId: 'prd_xyz',
name: 'Premium Mensal',
billingInterval: 'MONTH', // DAY | WEEK | MONTH | YEAR
billingIntervalCount: 1,
trialEnabled: true,
trialPeriodDays: 7,
isActive: true,
}billingInterval × billingIntervalCount cobre todos os casos:
| Cobrança | billingInterval | billingIntervalCount |
|---|---|---|
| Diária | DAY | 1 |
| Semanal | WEEK | 1 |
| Quinzenal | WEEK | 2 |
| Mensal | MONTH | 1 |
| Bimestral | MONTH | 2 |
| Anual | YEAR | 1 |
Como nasce uma CustomerSubscription
Hoje, a partir de uma cobrança bem-sucedida em produto recorrente:
- Cliente clica em "Assinar Premium" — fluxo passa pelo checkout (hosted ou seu).
- Você cria
PaymentIntentapontando para oProductrecorrente. - Cliente confirma com
tok_*(cartão tokenizado). - Quando o intent vira
succeeded, a Zhex cria aCustomerSubscriptionautomaticamente, herdando intervalo e trial do plano. - Próximas cobranças acontecem nas datas calculadas pela Zhex.
Você não chama POST /v1/customer_subscriptions — esse endpoint não existe na API V1. A assinatura é side-effect da cobrança.
Trial
Trial é configurado no plano (dashboard). Quando o cliente assina um produto com trial:
CustomerSubscriptionnasce emTRIALINGcomtrial_start/trial_end.- Sem cobrança durante o trial.
- No
trial_end, a Zhex dispara o primeiroPaymentIntentautomaticamente. Sucesso →ACTIVE. Falha →INCOMPLETE→ retry →UNPAID.
trialPeriodDays: 7 é o sweet spot. Menor que 3 não converte; maior que 14 aumenta churn por "esqueci de cancelar" que vira chargeback.
Dunning (cobrança em PAST_DUE)
Quando uma cobrança recorrente falha, a Zhex faz retry automático nas próximas datas de tentativa configuradas para o adquirente. O detalhe exato (intervalos, número de tentativas) é gerenciado internamente; o que você vê é o CustomerSubscription transitando entre ACTIVE → PAST_DUE → ACTIVE (retry funcionou) ou PAST_DUE → UNPAID.
Seu trabalho é o lado humano da história: email para o cliente atualizar o cartão.
// no handler de webhook
if (event.type === 'payment_intent.payment_failed') {
const intent = event.data.object;
if (intent.customer) {
await sendEmail(intent.customer, 'update-card', {
update_url: `https://meusite.com/billing?intent=${intent.id}`,
});
}
}A update_url deve abrir uma tela com @zhexio/zhex-js para tokenizar novo cartão. Bom UX aqui economiza 30%+ de churn involuntário.
Cancelamento
Há três modos:
Cancel imediato
// SDK ainda não cobre customerSubscriptions; use fetch:
await zhexFetch(`/v1/customer_subscriptions/${id}/cancel`, {
method: 'POST',
body: JSON.stringify({ reason: 'requested_by_customer' }),
});Vira CANCELED na hora. Cliente perde acesso. Não há cobrança proporcional — se ele pagou R$ 99 e cancelou no dia 5, perdeu os outros 25 dias.
Cancel ao fim do ciclo
curl -X POST https://prometheus.zhex.io/v1/customer_subscriptions/csub_…/cancel \
-H "Authorization: Bearer $ZHEX_SECRET_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "cancel_at_period_end": true, "reason": "downsell" }'Mantém ACTIVE até o current_period_end, depois vira CANCELED. É o default mais ético — cliente usa o que pagou.
Cancel em data específica
curl -X POST https://prometheus.zhex.io/v1/customer_subscriptions/csub_…/cancel \
-H "Authorization: Bearer $ZHEX_SECRET_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "cancel_at": 1730000000, "reason": "promo_window" }'cancel_at em Unix timestamp. Mutuamente exclusivo com cancel_at_period_end.
Quem prefere "cancelei e quero refund" usa modo 1 + endpoint separado de refund.
Pause vs Cancel
Pause de assinatura (suspender cobrança por N ciclos sem cancelar) é roadmap — POST /v1/customer_subscriptions/:id/pause ainda não está disponível. Para "férias do cliente" hoje, o caminho é cancelar e recriar quando o cliente voltar.
Webhooks
Hoje a API V1 emite os events de PaymentIntent que são side-effect das cobranças recorrentes:
payment_intent.succeeded— cobrança do ciclo N foi bem-sucedida (assinatura entra/permanece emACTIVE)payment_intent.payment_failed— cobrança falhou (assinatura entra emPAST_DUE)payment_intent.canceled— primeira cobrança cancelada antes da assinatura virar ativa
Para reagir a transições próprias da CustomerSubscription (activated, trial_will_end, canceled, payment_succeeded no ciclo N), consulte o estado via GET /v1/customer_subscriptions/:id no momento que precisar — vocabulário de events específico (customer_subscription.*) está no roadmap.
Idempotência em billing recorrente
Worker de billing vai rodar duas vezes algum dia (deploy, reschedule, retry de fila). A Zhex já gerencia o billing recorrente internamente — você não precisa fazer POST /v1/payment_intents em loop manual.
Quando você fizer cobrança ad-hoc no contexto de uma assinatura (ex.: cobrança extra fora do ciclo), use idempotency key determinística:
const key = `mensalidade-${customerId}-${cycleYear}-${cycleMonth}`;
await zhex.paymentIntents.create(
{
amount: 9700,
currency: 'brl',
customer: customerId,
payment_method_types: ['card'],
},
{ idempotencyKey: key },
);Mesmo input + mesma key → resposta cacheada. Worker rodou 3x? Cliente cobrado 1x.
Boas práticas
payment_descriptioncurto e branded. Aparece todo mês no statement:MEUSITE*Premium>ZHX BR PAYMNT 8392.- Cancel auto-servido. Se cliente não consegue cancelar pelo seu painel, ele cancela via cartão = chargeback. Você perde a disputa e a fee.
PAST_DUEmantém acesso. Bloquear na 1ª falha é antipático — boa parte dosPAST_DUEresolvem na 2ª tentativa (saldo voltou, cartão atualizado). Bloqueie só emUNPAID.- Não delete
CustomerSubscription. Sempre cancele. Histórico é receita LTV, contábil, reconciliação.
Próximos passos
Customers
Identificação por email + document
Webhooks
Tratar payment_failed sem duplicar emails
Recipe — SaaS subscription
Fluxo end-to-end com signup + cobrança
Atualizado em