SaaS: do cadastro ao dashboard
Roteiro com código para cobrar um usuário do seu SaaS pelo Vipter: criar a sessão de checkout com o ID do usuário, redirecionar, confirmar o pagamento pela API ou pelo webhook checkout.session.completed, tratar PIX, idempotência, cobrar o uso do mês no cartão salvo, cancelar e trocar de plano, o que guardar e como conviver com a Stripe.
Este roteiro liga o cadastro do seu produto a uma assinatura no Vipter, do clique em "assinar" até o usuário de volta ao seu dashboard com o plano ativo. Ele usa três peças: POST /v1/checkout/sessions, a página de obrigado do Vipter e o webhook checkout.session.completed. O código está em curl e Node.js, sem bibliotecas além do fetch e do node:crypto.
Antes de começar
- Uma chave de API com o escopo
write, guardada como variável de ambiente (VIPTER_API_KEY). - O
ofr_…de cada plano que você vende. Pegue emGET /v1/offers?type=recurringou na página da oferta no painel. - Um endpoint de webhook criado com a versão de eventos 2026-11-01 e o segredo
whsec_…dele (VIPTER_WEBHOOK_SECRET). Veja Receber eventos no seu sistema. - Uma página
https://no seu sistema para receber o usuário depois do pagamento.
O fluxo
- O usuário se cadastra no seu produto e clica em "assinar". Você já tem o ID dele (
user_8213) e o e-mail. - O seu servidor chama
POST /v1/checkout/sessionscom a oferta,client_reference_idigual ao ID do usuário,customer_emaile umasuccess_urlcom{CHECKOUT_SESSION_ID}. - Você redireciona o navegador do usuário para a
urlda resposta. Ele paga na página do Vipter. - O Vipter mostra a página de obrigado da loja e, com o pagamento confirmado, redireciona para a sua
success_url, com oidda sessão no lugar de{CHECKOUT_SESSION_ID}. - A sua página lê o
session_id, confirma comGET /v1/checkout/sessions/{id}e mostra o plano ativo. - Em paralelo, o webhook
checkout.session.completedchega ao seu servidor com a mesma sessão. É ele que libera o plano de verdade, mesmo que o usuário feche a aba antes do redirecionamento.
Os passos 5 e 6 são redundantes de propósito: a página dá a resposta rápida, o webhook dá a garantia.
Passo 1: criar a sessão
No seu servidor, quando o usuário escolhe o plano:
curl -X POST https://api.vipter.com/v1/checkout/sessions \
-H "Authorization: Bearer $VIPTER_API_KEY" \
-H "Idempotency-Key: user_8213:pro-mensal:$(date +%Y%m%d%H%M)" \
-H "Content-Type: application/json" \
-d '{
"offer": "ofr_6e2b8d4f1a9c3e7b",
"client_reference_id": "user_8213",
"customer_email": "ana@example.com",
"customer_name": "Ana Souza",
"metadata": { "user_id": "user_8213", "plan": "pro" },
"success_url": "https://app.example.com/billing/success?session_id={CHECKOUT_SESSION_ID}",
"cancel_url": "https://app.example.com/billing/plans"
}'O que cada campo faz aqui:
client_reference_idé o elo entre os dois sistemas. Ele volta na sessão, no pedido e na assinatura, e dá para listar as sessões de um usuário comGET /v1/checkout/sessions?client_reference_id=user_8213. Use o ID interno do usuário, não o e-mail: e-mails mudam.customer_emailtrava o campo de e-mail do checkout. O cliente que o Vipter criar terá esse e-mail, e o portal do cliente vai reconhecê-lo por ele.metadatafica na sessão e é copiado para o pedido. Para gravar algo na assinatura, usesubscription_data[metadata]; sem ele, a assinatura recebe o mesmometadata.success_urlcom{CHECKOUT_SESSION_ID}é o que permite à sua página saber qual sessão acabou de ser paga.cancel_urlvira o link de voltar no topo do checkout.
A sessão vale por 24 horas (ajuste com expires_at, entre 30 minutos e 24 horas). Guarde o id da sessão ligado ao usuário: se ele voltar à página de planos sem ter pago, você pode reaproveitar a url em vez de criar outra sessão, ou expirar a antiga quando ele escolher outro plano.
Se o usuário já é cliente da loja (uma assinatura anterior, por exemplo), passe customer com o cust_… dele em vez de customer_email: nome, telefone e documento vêm preenchidos.
Passo 2: redirecionar e deixar o comprador pagar
Responda ao navegador com um redirecionamento para session.url. A página é o checkout da loja, com a oferta, a moeda e o cupom fixos e o e-mail travado. O comprador escolhe o meio de pagamento e paga.
Um cartão recusado não fecha a sessão: o comprador tenta outro cartão na mesma página. Um PIX gerado completa a sessão com payment_status: "pending" até ser pago. Veja PIX e outros pagamentos assíncronos.
Passo 3: receber o usuário de volta
Depois do pagamento confirmado, a página de obrigado do Vipter espera redirect_delay segundos (padrão 5) e vai para a sua success_url:
https://app.example.com/billing/success?session_id=cs_4b7e2d9a1c3f5e8b0d2a6c4e1f3bNa sua página, confirme a sessão antes de mostrar qualquer coisa como paga:
export async function handleSuccess(sessionId, currentUser) {
const session = await vipter(`/checkout/sessions/${encodeURIComponent(sessionId)}`);
if (session.client_reference_id !== currentUser.id) throw new Error('sessão de outro usuário');
if (session.status === 'complete' && session.payment_status === 'paid') {
// Pago. Mostre o plano ativo. O webhook pode já ter liberado o acesso; se não, libere aqui também (idempotente).
return { state: 'paid', subscriptionId: session.subscription, orderId: session.order };
}
if (session.status === 'complete' && session.payment_status === 'pending') {
// PIX gerado e ainda não pago: mostre "aguardando pagamento" e espere o webhook.
return { state: 'pending' };
}
return { state: 'not_paid' }; // open, expired ou pagamento que falhou
}Três cuidados:
- Confira
client_reference_idcontra o usuário logado. Oidda sessão não é adivinhável, mas a URL pode ser copiada. - Não libere o plano só porque o navegador chegou à
success_url. A fonte da verdade é ostatuse opayment_statusda sessão, ou o webhook. subscriptionpode virnullpor alguns segundos depois do pagamento, até o aviso do provedor ser processado. Se precisar dele na hora, consulte de novo em seguida ou espere o webhook, que só sai com os dados que já existem.
Se o usuário fechar a aba antes do redirecionamento, nada se perde: o webhook do passo seguinte chega do mesmo jeito.
Passo 4: receber o webhook
Crie o endpoint com a versão de eventos 2026-11-01 e marque pelo menos checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, customer.subscription.updated e customer.subscription.deleted; se for cobrar uso, também invoice.paid, subscription_charge.succeeded e subscription_charge.failed. Dá para fazer isso no painel, em Receber eventos no seu sistema, ou pela API, que devolve o segredo de assinatura uma única vez:
curl -X POST https://api.vipter.com/v1/webhook_endpoints \
-H "Authorization: Bearer vk_live_…" \
-H "Content-Type: application/json" \
-d '{
"url": "https://app.example.com/webhooks/vipter",
"description": "Faturamento do SaaS",
"enabled_events": ["checkout.session.*", "customer.subscription.updated", "customer.subscription.deleted", "invoice.paid", "subscription_charge.*"]
}'Guarde o secret da resposta em VIPTER_WEBHOOK_SECRET. A versão 2026-11-01 é o padrão da API; os campos estão em Criar um endpoint. O data.object de cada evento é o mesmo JSON que a API devolve: a sessão nos eventos checkout.session.*, a assinatura nos customer.subscription.*.
O servidor abaixo verifica a assinatura com a função de Verificar a assinatura, descarta repetições pelo id do evento, responde 200 e só então processa:
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyVipterSignature(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return header.split(',').filter((p) => p.startsWith('v1=')).some((p) => {
const given = Buffer.from(p.slice(3), 'hex');
return given.length === expected.length / 2 && timingSafeEqual(given, Buffer.from(expected, 'hex'));
});
}
async function handleEvent(event) {
const obj = event.data.object;
switch (event.type) {
case 'checkout.session.completed': {
const userId = obj.client_reference_id;
await db.users.update(userId, { vipterCustomerId: obj.customer, vipterSubscriptionId: obj.subscription, lastOrderId: obj.order });
if (obj.payment_status === 'paid') await activatePlan(userId, obj.metadata.plan);
else await markAwaitingPayment(userId); // PIX gerado, ainda não pago
break;
}
case 'checkout.session.async_payment_succeeded':
await activatePlan(obj.client_reference_id, obj.metadata.plan);
break;
case 'checkout.session.async_payment_failed':
await markPaymentFailed(obj.client_reference_id);
break;
case 'customer.subscription.updated': {
// Renovação, troca de plano, recuperação de cobrança, pausa… Leia o status, não o nome do evento.
const user = await db.users.findBySubscription(obj.id);
if (user) await syncPlan(user.id, { status: obj.status, offerId: obj.offer?.id, periodEnd: obj.current_period_end });
break;
}
case 'customer.subscription.deleted': {
const user = await db.users.findBySubscription(obj.id);
if (user) await deactivatePlan(user.id);
break;
}
}
}
createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', async () => {
const rawBody = Buffer.concat(chunks).toString('utf8');
if (!verifyVipterSignature(rawBody, req.headers['vipter-signature'] ?? '', process.env.VIPTER_WEBHOOK_SECRET)) {
res.writeHead(400).end('assinatura inválida');
return;
}
const event = JSON.parse(rawBody);
const fresh = await db.events.insertIfNew({ provider: 'vipter', id: event.id, type: event.type }); // unique (provider, id)
res.writeHead(200).end('ok');
if (fresh) handleEvent(event).catch((err) => console.error('vipter webhook', event.id, err));
});
}).listen(Number(process.env.PORT ?? 8000));Pontos que o código acima assume, e que valem para o Vipter:
- O mesmo evento pode chegar mais de uma vez, com o mesmo
id. A tabela com restrição de unicidade resolve. Veja idempotência. - A ordem não é garantida: um
customer.subscription.updatedpode chegar antes docheckout.session.completed. Por issohandleEventlê o estado do objeto (payment_status,status) em vez de deduzir pelo nome do evento, e cada função (activatePlan,syncPlan) precisa poder rodar duas vezes. - Responder 200 em até 10 segundos é obrigatório; o processamento fica depois da resposta. Num servidor sem fila, o
handleEventdepois dores.endjá basta para começar. - Para conferir que o endpoint responde e a assinatura confere, chame
POST /v1/webhook_endpoints/{id}/testcom oidda resposta acima, ou clique em Enviar evento de teste no card do endpoint. Chega umcustomer.createdde teste no formato 2026-11-01, comlivemode: falseedata.object.test: true, que ohandleEventacima ignora porque não tem umcasepara ele. Para testar oscheckout.session.*de ponta a ponta, faça uma compra numa conexão de teste ou use ocurlde Testar sem esperar um evento.
PIX e outros pagamentos assíncronos
Quando o comprador escolhe PIX, o Vipter gera o código e a sessão completa na hora com payment_status: "pending": sai o checkout.session.completed com order preenchido e payment_status: "pending". O comprador paga no app do banco, e aí:
- Se pagou: a sessão passa a
paide saicheckout.session.async_payment_succeeded. A página de obrigado, se ainda estiver aberta, percebe e redireciona para a suasuccess_url. - Se o PIX expirou sem pagamento: a sessão passa a
payment_status: "unpaid"e saicheckout.session.async_payment_failed. A sessão não reabre; se o usuário quiser tentar de novo, crie outra.
Um cartão que fica em análise no provedor segue o mesmo caminho: pending na hora, async_payment_succeeded ou async_payment_failed quando a análise termina.
No seu sistema, trate completed com pending como "aguardando pagamento": mostre o estado para o usuário, mas não libere o plano. Libere em async_payment_succeeded, ou em completed quando payment_status já vier paid. O mesmo fato também sai no catálogo original como order.paid, para quem usa um endpoint 2026-09-01.
Idempotência
Dois lados pedem cuidado:
- Ao criar a sessão, envie uma
Idempotency-Keyúnica por tentativa do usuário. Se a rede cair depois de a sessão ser criada, repetir a chamada com a mesma chave devolve a mesma sessão em vez de criar uma segunda. A chave vale por 24 horas; as regras estão em Idempotência. - Ao receber eventos, descarte repetições pelo
ide escreva cada reação de forma que rodar duas vezes dê o mesmo resultado. "Ativar o plano" pode ser repetido; "mandar o e-mail de boas-vindas" precisa conferir se já foi mandado.
Cobrança adicional por uso
Um SaaS com franquia cobra a mensalidade pela assinatura e, no fim do mês, o que passou da franquia: chamadas, GB, assentos. No Vipter, a medição fica do seu lado; o que a API faz é cobrar o valor que você calculou no cartão salvo da assinatura, na hora, com POST /v1/subscriptions/{id}/charges. A renovação da assinatura não muda: a próxima cobrança recorrente continua na mesma data e com o mesmo valor.
Se você prefere não manter a contagem, use o uso medido: crie um medidor, mande um evento a cada consumo (POST /v1/billing/meter_events, com customer_id e value), e o Vipter soma, aplica a franquia e o preço definidos na oferta ou na assinatura, e cobra o total no cartão salvo quando o ciclo fecha, com os mesmos eventos invoice.paid e subscription_charge.*. O resto desta seção é o caminho em que o cálculo fica do seu lado.
O fluxo, uma vez por período:
- Feche o período no seu sistema e calcule o excedente de cada usuário, em centavos. Quem não passou da franquia não gera cobrança.
- Chame
POST /v1/subscriptions/{sub_…}/chargescomamount, aslinesque explicam o valor, e umaIdempotency-Keyque identifique o período, comousage:user_8213:2026-09. Essa chave é o que impede cobrar o mesmo mês duas vezes: um job que roda de novo, uma rede que caiu depois de cobrar, dois servidores concorrendo, todos recebem a mesma cobrança de volta. - Trate a resposta:
201comstatus: "succeeded"é cobrado;201comstatus: "pending"é cartão em análise, espere o webhook;402é cartão recusado. - Guarde o
sch_…da cobrança ligado ao usuário e ao período, e feche o período como cobrado quandosubscription_charge.succeededchegar.
// Usa fetch direto, e não o helper `vipter` do Passo 1, porque o 402 precisa do corpo inteiro: ele traz a cobrança recusada.
export async function chargeUsage(user, period) {
const usage = await computeUsage(user.id, period); // o seu lado: { amountMinor, lines: [{ description, quantity, unit_amount }] }
if (usage.amountMinor === 0) return { state: 'nothing_to_charge' };
const res = await fetch(`${API}/subscriptions/${encodeURIComponent(user.vipterSubscriptionId)}/charges`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VIPTER_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': `usage:${user.id}:${period}`, // uma por usuário e período, sempre a mesma
},
body: JSON.stringify({
amount: usage.amountMinor,
description: `Uso adicional de ${period}`,
lines: usage.lines,
metadata: { user_id: user.id, period },
}),
});
const json = await res.json();
if (res.status === 201 || res.status === 200) {
// 201: cobrada agora (succeeded) ou em análise (pending). 200: a chave já tinha sido usada; é a mesma cobrança.
await db.usageCharges.upsert({ userId: user.id, period, chargeId: json.id, status: json.status, orderId: json.order });
return { state: json.status };
}
if (res.status === 402) {
// Cartão recusado. A cobrança recusada vem em error.subscription_charge; o Vipter não tenta de novo sozinho.
const charge = json.error.subscription_charge;
await db.usageCharges.upsert({ userId: user.id, period, chargeId: charge.id, status: 'failed', failureCode: json.error.code });
await askForAnotherCard(user.id); // mande o usuário ao portal do cliente trocar o cartão
return { state: 'declined', code: json.error.code };
}
if (['subscription_not_chargeable', 'no_payment_method', 'payment_method_not_chargeable'].includes(json.error.code)) {
// Não adianta repetir com a mesma assinatura: ela está pausada ou cancelada, ou não tem cartão que aceite cobrança sem o cliente.
await db.usageCharges.upsert({ userId: user.id, period, status: 'blocked', failureCode: json.error.code });
return { state: 'blocked', code: json.error.code };
}
// 409 (a primeira chamada ainda está rodando), 5xx, rede: repita mais tarde com a MESMA chave.
throw Object.assign(new Error(json.error?.message ?? res.statusText), { status: res.status, code: json.error?.code, requestId: res.headers.get('Request-Id') });
}O que cada detalhe garante:
- A
Idempotency-Keyé o ID do período, não um valor aleatório. A chave de uma cobrança fica ligada a ela para sempre, sem o limite de 24 horas das outras chamadas: rodar o fechamento de setembro de novo em dezembro ainda devolve a cobrança de setembro, com200. Para cobrar o mesmo período outra vez de propósito, depois de uma recusa, mude a chave (usage:user_8213:2026-09:2). linessão o extrato do usuário. Elas viram os itens do pedido que ele vê na área do cliente e no e-mail de confirmação da loja;amountprecisa ser a soma delas.unit_amounté inteiro, em centavos: um preço de R$ 0,02 por chamada é2.metadatavolta nos eventos.subscription_charge.succeededesubscription_charge.failedtrazem a cobrança com o seuuser_ideperiod, então o webhook fecha o período sem consultar nada.- Um
402não é um erro do seu código. É a resposta normal para cartão recusado: registre, avise o usuário e decida quando repetir. O código de recusa (error.code) vem do provedor; não dependa de uma lista fixa.
No webhook, acrescente ao handleEvent do Passo 4:
case 'subscription_charge.succeeded': {
// obj é a cobrança: metadata.user_id e metadata.period são os seus.
await db.usageCharges.upsert({ userId: obj.metadata.user_id, period: obj.metadata.period, chargeId: obj.id, status: 'succeeded', orderId: obj.order });
break;
}
case 'subscription_charge.failed': {
await db.usageCharges.upsert({ userId: obj.metadata.user_id, period: obj.metadata.period, chargeId: obj.id, status: 'failed', failureCode: obj.failure_code });
await askForAnotherCard(obj.metadata.user_id);
break;
}
case 'invoice.paid': {
// Todo pedido pago: renovação (billing_reason "subscription_cycle"), cobrança de uso ("manual")… Use para o seu financeiro.
if (obj.billing_reason === 'manual') await db.receipts.insert({ orderId: obj.id, subscriptionId: obj.subscription, chargeId: obj.external_order_id, amount: obj.amount_total });
break;
}Os dois eventos da cobrança saem na hora numa cobrança aprovada ou recusada de imediato, e só depois da análise numa cobrança que ficou pending. invoice.paid traz o pedido (object: "order", billing_reason: "manual", as lines com kind: "charge"), com o sch_… em external_order_id; invoice.payment_failed sai numa recusa quando o provedor registrou um pedido para a tentativa.
O que o usuário vê: o pedido com as linhas na área do cliente, junto das renovações, e o e-mail de confirmação de compra da loja, quando ele está ativo. No painel, a cobrança aparece na página da assinatura com a origem API, e a equipe pode fazer a mesma cobrança à mão pelo botão Cobrar valor extra…. Cada cobrança aprovada conta como um pedido na cota do plano Vipter da loja.
Cancelar, pausar e trocar de plano
O botão "cancelar" ou "mudar de plano" do seu produto pode chamar a API em vez de mandar o usuário ao portal. Cada chamada devolve a assinatura já atualizada, e o evento customer.subscription.* chega em seguida, com o mesmo objeto; o syncPlan do Passo 4 trata os dois do mesmo jeito.
| No seu produto | Chamada | Depois |
|---|---|---|
| Cancelar no fim do período pago | POST /v1/subscriptions/{id} com cancel_at_period_end: true e, se quiser, cancellation_details[reason] e [comment] | status segue active até current_period_end; aí sai customer.subscription.deleted. Mantenha o acesso até lá. |
| Desfazer o cancelamento agendado | POST …/reactivate | cancel_at_period_end volta a false. |
| Cancelar agora | DELETE /v1/subscriptions/{id} | status: "canceled" e customer.subscription.deleted na hora. |
| Pausar e retomar | POST …/pause, POST …/resume | paused e active; customer.subscription.paused e .resumed. Uma assinatura pausada não aceita cobrança de uso. |
| Upgrade ou downgrade | POST …/change_offer com o ofr_… do outro plano | offer, amount e next_billing_at já vêm da oferta nova; customer.subscription.updated em seguida. |
Guarde o ofr_… de cada plano que você vende: change_offer só aceita o ID, e a oferta nova precisa ser do mesmo produto ou família de produtos. O usuário continua podendo fazer as mesmas coisas pela área do cliente; os eventos são os mesmos nos dois caminhos.
O que guardar no seu banco
| Guarde | De onde vem | Para quê |
|---|---|---|
cust_… do cliente | customer da sessão depois do pagamento, ou do evento | Abrir sessões futuras com customer, gerar o link do portal do cliente, listar pedidos. |
sub_… da assinatura | subscription da sessão, ou data.object.id dos eventos customer.subscription.* | Reconhecer renovações, cancelamentos e trocas de plano quando os eventos da assinatura chegarem: eles trazem client_reference_id, mas o sub_… é a chave estável. É também o ID que cobra o uso e cancela, pausa ou troca o plano. |
ord_… do pedido | order da sessão | Mostrar o recibo, conferir GET /v1/orders/{id}, cruzar com invoice.paid. |
sch_… de cada cobrança de uso | Resposta do POST …/charges, ou data.object.id dos eventos subscription_charge.* | Saber que período já foi cobrado e acompanhar uma cobrança pending em GET /v1/subscription_charges/{id}. |
cs_… da sessão | Resposta do POST | Ligar o retorno na success_url ao usuário e retomar um checkout não concluído. |
evt_… de cada evento | Envelope do webhook | Descartar repetições. |
Não guarde a url do portal do cliente: ela vale 15 minutos e serve para uma entrada. Gere uma nova a cada clique em "gerenciar assinatura".
Convivendo com a Stripe
Muitos SaaS usam a Stripe fora do Brasil e o Vipter para cobrar em reais, com PIX e cartão parcelado. Dá para manter os dois com pouco atrito:
- O mesmo ID de usuário em
client_reference_idnos dois sistemas. Os eventos de cada um dizem a qual usuário pertencem sem tabela de tradução. - Um endpoint para cada provedor, com a sua rota e o seu segredo:
/webhooks/stripecomStripe-Signature,/webhooks/viptercomVipter-Signature. O algoritmo da assinatura é o mesmo (HMAC SHA-256 sobret.corpo), mas o cabeçalho e o segredo são outros. - Descarte repetições por
(provedor, id do evento), não só peloid: os dois usam o prefixoevt_, e umiddo Vipter nunca colide com um da Stripe, mas uma restrição de unicidade só noidmistura as duas tabelas de eventos na sua cabeça. Deixe o provedor explícito na chave. - Os nomes dos eventos coincidem no catálogo 2026-11-01:
checkout.session.completed,invoice.paid,customer.subscription.updated. O que muda é o objeto:data.objectno Vipter é a sessão, o pedido (object: "order", nãoinvoice) e a assinatura no formato da referência, comofferno lugar depriceeorderno lugar deinvoice. Umswitchporevent.typeserve aos dois; o corpo de cadacaselê campos diferentes. - Guarde o provedor junto da assinatura (
provider: 'vipter' | 'stripe',subscription_id). O portal do cliente também é um por provedor: o botão "gerenciar assinatura" chamaPOST /v1/billing_portal/sessionspara um e a Billing Portal Session da Stripe para o outro.
Problemas comuns
-
400 offer_unavailableao criar a sessãoA oferta existe mas não está à venda: o link de checkout está desligado, a oferta foi arquivada ou não tem preço. A
messagediz o motivo. Confira a oferta no painel ou emGET /v1/offers/{id}(active,checkout_url,prices). -
403 selling_blockedA loja está impedida de vender porque a mensalidade do Vipter está em atraso. Veja Pagamento em atraso.
-
A
success_urlchegou com{CHECKOUT_SESSION_ID}sem trocarO texto precisa ser exatamente
{CHECKOUT_SESSION_ID}, com chaves e maiúsculas. Confira se o seu cliente HTTP não codificou as chaves como%7Bantes de enviar o corpo. -
O webhook não chega, mas o painel mostra a venda
Confira a versão do endpoint: os eventos
checkout.session.*só existem no catálogo 2026-11-01. Um endpoint na versão 2026-09-01 recebeorder.paid, e nãocheckout.session.completed. Veja Receber eventos no seu sistema. -
subscriptionveionullna sessão pagaO aviso do provedor sobre a assinatura ainda não foi processado. Consulte de novo em alguns segundos ou use o
customer.subscription.createddo webhook.
O que fazer a seguir
- Veja cada campo e cada erro em Sessões de checkout e em Cobranças na assinatura.
- Entenda quando cada evento sai no catálogo 2026-11-01.
- Ofereça "gerenciar assinatura" com uma sessão do portal do cliente.
Referência da API (v1)
Cada endpoint da API do Vipter com os parâmetros, um exemplo de chamada e de resposta, e a tabela de campos de cada objeto: conta, cliente, assinatura e suas ações, cobrança na assinatura, uso medido (medidores, eventos de uso, itens e períodos), pedido, oferta, produto, sessão de checkout, sessão do portal do cliente, eventos e endpoints de webhook.
Migrar da Stripe para o Vipter
Guia para quem já cobra com a Stripe e vai passar a cobrar pelo Vipter, no todo ou só no Brasil: o que corresponde a quê (Price → oferta, Invoice → pedido), o que não migra (cartões salvos e assinaturas em curso), as mudanças no código de checkout, webhooks, cobranças avulsas e uso medido, com diffs em Node.js, e um plano de corte em cinco etapas que mantém os dois lado a lado durante a transição.