SaaS: from sign-up to dashboard
A guide with code to charge a user of your SaaS through Vipter: create the checkout session with the user's ID, redirect, confirm the payment through the API or the checkout.session.completed webhook, handle PIX, idempotency, bill the month's usage on the saved card, cancel and change plans, what to store and how to coexist with Stripe.
This guide connects your product's sign-up to a subscription in Vipter, from the click on "subscribe" to the user back on your dashboard with the plan active. It uses three pieces: POST /v1/checkout/sessions, Vipter's thank-you page and the checkout.session.completed webhook. The code is in curl and Node.js, with no libraries beyond fetch and node:crypto.
Before you start
- An API key with the
writescope, stored as an environment variable (VIPTER_API_KEY). - The
ofr_…of each plan you sell. Get it fromGET /v1/offers?type=recurringor from the offer's page in the dashboard. - A webhook endpoint created with event version 2026-11-01 and its
whsec_…secret (VIPTER_WEBHOOK_SECRET). See Receive events in your system. - An
https://page in your system to receive the user after payment.
The flow
- The user signs up for your product and clicks "subscribe". You already have their ID (
user_8213) and email. - Your server calls
POST /v1/checkout/sessionswith the offer,client_reference_idequal to the user's ID,customer_emailand asuccess_urlwith{CHECKOUT_SESSION_ID}. - You redirect the user's browser to the response's
url. They pay on the Vipter page. - Vipter shows the store's thank-you page and, once the payment is confirmed, redirects to your
success_url, with the session'sidin place of{CHECKOUT_SESSION_ID}. - Your page reads the
session_id, confirms it withGET /v1/checkout/sessions/{id}and shows the plan as active. - In parallel, the
checkout.session.completedwebhook reaches your server with the same session. It is what really unlocks the plan, even if the user closes the tab before the redirect.
Steps 5 and 6 are redundant on purpose: the page gives the fast answer, the webhook gives the guarantee.
Step 1: create the session
On your server, when the user picks the plan:
curl -X POST https://api.vipter.com/v1/checkout/sessions \
-H "Authorization: Bearer $VIPTER_API_KEY" \
-H "Idempotency-Key: user_8213:pro-monthly:$(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"
}'What each field does here:
client_reference_idis the link between the two systems. It comes back on the session, the order and the subscription, and you can list a user's sessions withGET /v1/checkout/sessions?client_reference_id=user_8213. Use the user's internal ID, not the email: emails change.customer_emaillocks the checkout's email field. The customer Vipter creates will have that email, and the customer portal will recognize them by it.metadatastays on the session and is copied to the order. To store something on the subscription, usesubscription_data[metadata]; without it, the subscription receives the samemetadata.success_urlwith{CHECKOUT_SESSION_ID}is what lets your page know which session was just paid.cancel_urlbecomes the back link at the top of the checkout.
The session is valid for 24 hours (adjust with expires_at, between 30 minutes and 24 hours). Store the session's id linked to the user: if they come back to the plans page without having paid, you can reuse the url instead of creating another session, or expire the old one when they pick another plan.
If the user is already a store customer (a previous subscription, for example), pass customer with their cust_… instead of customer_email: name, phone and document come filled in.
Step 2: redirect and let the buyer pay
Answer the browser with a redirect to session.url. The page is the store's checkout, with the offer, currency and coupon fixed and the email locked. The buyer picks the payment method and pays.
A declined card does not close the session: the buyer tries another card on the same page. A generated PIX completes the session with payment_status: "pending" until it is paid. See PIX and other asynchronous payments.
Step 3: receive the user back
After the payment is confirmed, Vipter's thank-you page waits redirect_delay seconds (default 5) and goes to your success_url:
https://app.example.com/billing/success?session_id=cs_4b7e2d9a1c3f5e8b0d2a6c4e1f3bOn your page, confirm the session before showing anything as paid:
export async function handleSuccess(sessionId, currentUser) {
const session = await vipter(`/checkout/sessions/${encodeURIComponent(sessionId)}`);
if (session.client_reference_id !== currentUser.id) throw new Error('session belongs to another user');
if (session.status === 'complete' && session.payment_status === 'paid') {
// Paid. Show the plan as active. The webhook may already have unlocked access; if not, unlock it here too (idempotent).
return { state: 'paid', subscriptionId: session.subscription, orderId: session.order };
}
if (session.status === 'complete' && session.payment_status === 'pending') {
// PIX generated and not paid yet: show "awaiting payment" and wait for the webhook.
return { state: 'pending' };
}
return { state: 'not_paid' }; // open, expired or a payment that failed
}Three precautions:
- Check
client_reference_idagainst the signed-in user. The session'sidis not guessable, but the URL can be copied. - Do not unlock the plan just because the browser reached the
success_url. The source of truth is the session'sstatusandpayment_status, or the webhook. subscriptionmay comenullfor a few seconds after payment, until the provider's notice is processed. If you need it right away, query again shortly after or wait for the webhook, which only goes out with the data that already exists.
If the user closes the tab before the redirect, nothing is lost: the webhook of the next step arrives all the same.
Step 4: receive the webhook
Create the endpoint with event version 2026-11-01 and tick at least checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, customer.subscription.updated and customer.subscription.deleted; if you will bill usage, also invoice.paid, subscription_charge.succeeded and subscription_charge.failed. You can do it in the dashboard, under Receive events in your system, or through the API, which returns the signing secret only once:
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": "SaaS billing",
"enabled_events": ["checkout.session.*", "customer.subscription.updated", "customer.subscription.deleted", "invoice.paid", "subscription_charge.*"]
}'Store the response's secret in VIPTER_WEBHOOK_SECRET. Version 2026-11-01 is the API default; the fields are in Create an endpoint. The data.object of each event is the same JSON the API returns: the session on checkout.session.* events, the subscription on customer.subscription.*.
The server below verifies the signature with the function from Verify the signature, discards repeats by the event's id, answers 200 and only then processes:
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 generated, not paid yet
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': {
// Renewal, plan change, dunning recovery, pause… Read the status, not the event name.
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('invalid signature');
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));Points the code above assumes, and that hold for Vipter:
- The same event can arrive more than once, with the same
id. The table with a unique constraint handles it. See idempotency. - Order is not guaranteed: a
customer.subscription.updatedcan arrive before thecheckout.session.completed. That is whyhandleEventreads the object's state (payment_status,status) instead of inferring from the event name, and each function (activatePlan,syncPlan) must be safe to run twice. - Answering 200 within 10 seconds is mandatory; processing comes after the response. On a server without a queue, the
handleEventafterres.endis enough to start with. - To check that the endpoint answers and the signature matches, call
POST /v1/webhook_endpoints/{id}/testwith theidfrom the response above, or click Send test event on the endpoint's card. A testcustomer.createdarrives in the 2026-11-01 format, withlivemode: falseanddata.object.test: true, which thehandleEventabove ignores because it has nocasefor it. To test thecheckout.session.*events end to end, make a purchase through a test connection or use thecurlfrom Test without waiting for an event.
PIX and other asynchronous payments
When the buyer picks PIX (Brazil's instant payment), Vipter generates the code and the session completes right away with payment_status: "pending": checkout.session.completed goes out with order filled in and payment_status: "pending". The buyer pays in their bank app, and then:
- If they paid: the session becomes
paidandcheckout.session.async_payment_succeededgoes out. The thank-you page, if still open, notices and redirects to yoursuccess_url. - If the PIX expired unpaid: the session becomes
payment_status: "unpaid"andcheckout.session.async_payment_failedgoes out. The session does not reopen; if the user wants to try again, create another one.
A card that goes under review at the provider follows the same path: pending right away, async_payment_succeeded or async_payment_failed when the review ends.
In your system, treat completed with pending as "awaiting payment": show the state to the user, but do not unlock the plan. Unlock it on async_payment_succeeded, or on completed when payment_status already comes paid. The same fact also goes out in the original catalog as order.paid, for whoever uses a 2026-09-01 endpoint.
Idempotency
Two sides need care:
- When creating the session, send an
Idempotency-Keyunique per user attempt. If the network drops after the session is created, repeating the call with the same key returns the same session instead of creating a second one. The key is valid for 24 hours; the rules are in Idempotency. - When receiving events, discard repeats by
idand write each reaction so that running it twice gives the same result. "Activate the plan" can be repeated; "send the welcome email" must check whether it was already sent.
Additional usage charges
A SaaS with an allowance bills the monthly fee through the subscription and, at the end of the month, whatever went over the allowance: calls, GB, seats. In Vipter, metering stays on your side; what the API does is charge the amount you computed on the subscription's saved card, right away, with POST /v1/subscriptions/{id}/charges. The subscription's renewal does not change: the next recurring charge stays on the same date and for the same amount.
If you would rather not keep the count, use metered usage: create a meter, send an event on every consumption (POST /v1/billing/meter_events, with customer_id and value), and Vipter adds it up, applies the allowance and the price set on the offer or on the subscription, and charges the total on the saved card when the cycle closes, with the same invoice.paid and subscription_charge.* events. The rest of this section is the path where the calculation stays on your side.
The flow, once per period:
- Close the period in your system and compute each user's overage, in cents. Whoever stayed within the allowance generates no charge.
- Call
POST /v1/subscriptions/{sub_…}/chargeswithamount, thelinesthat explain the amount, and anIdempotency-Keythat identifies the period, such asusage:user_8213:2026-09. That key is what prevents charging the same month twice: a job that runs again, a network that dropped after charging, two servers racing, all of them get the same charge back. - Handle the response:
201withstatus: "succeeded"is charged;201withstatus: "pending"is a card under review, wait for the webhook;402is a declined card. - Store the charge's
sch_…linked to the user and the period, and close the period as billed whensubscription_charge.succeededarrives.
// Uses fetch directly, not the `vipter` helper from Step 1, because the 402 needs the whole body: it carries the declined charge.
export async function chargeUsage(user, period) {
const usage = await computeUsage(user.id, period); // your side: { 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}`, // one per user and period, always the same
},
body: JSON.stringify({
amount: usage.amountMinor,
description: `Additional usage for ${period}`,
lines: usage.lines,
metadata: { user_id: user.id, period },
}),
});
const json = await res.json();
if (res.status === 201 || res.status === 200) {
// 201: charged now (succeeded) or under review (pending). 200: the key had already been used; it is the same charge.
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) {
// Card declined. The declined charge comes in error.subscription_charge; Vipter does not retry on its own.
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); // send the user to the customer portal to change the card
return { state: 'declined', code: json.error.code };
}
if (['subscription_not_chargeable', 'no_payment_method', 'payment_method_not_chargeable'].includes(json.error.code)) {
// No point retrying with the same subscription: it is paused or canceled, or has no card that accepts charges without the customer.
await db.usageCharges.upsert({ userId: user.id, period, status: 'blocked', failureCode: json.error.code });
return { state: 'blocked', code: json.error.code };
}
// 409 (the first call is still running), 5xx, network: retry later with the SAME key.
throw Object.assign(new Error(json.error?.message ?? res.statusText), { status: res.status, code: json.error?.code, requestId: res.headers.get('Request-Id') });
}What each detail guarantees:
- The
Idempotency-Keyis the period's ID, not a random value. A charge's key stays tied to it forever, without the 24-hour limit of the other calls: running September's close again in December still returns September's charge, with200. To charge the same period again on purpose, after a decline, change the key (usage:user_8213:2026-09:2). linesare the user's statement. They become the items of the order the user sees in the customer portal and in the store's confirmation e-mail;amountmust be their sum.unit_amountis an integer, in cents: a price of R$ 0.02 per call is2.metadatacomes back in the events.subscription_charge.succeededandsubscription_charge.failedcarry the charge with youruser_idandperiod, so the webhook closes the period without looking anything up.- A
402is not a bug in your code. It is the normal response for a declined card: record it, tell the user and decide when to retry. The decline code (error.code) comes from the provider; do not depend on a fixed list.
In the webhook, add to Step 4's handleEvent:
case 'subscription_charge.succeeded': {
// obj is the charge: metadata.user_id and metadata.period are yours.
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': {
// Every paid order: renewal (billing_reason "subscription_cycle"), usage charge ("manual")… Use it for your accounting.
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;
}The charge's two events go out right away for a charge approved or declined on the spot, and only after the review for a charge that stayed pending. invoice.paid carries the order (object: "order", billing_reason: "manual", the lines with kind: "charge"), with the sch_… in external_order_id; invoice.payment_failed goes out on a decline when the provider recorded an order for the attempt.
What the user sees: the order with its lines in the customer portal, next to the renewals, and the store's purchase confirmation e-mail, when it is active. In the dashboard, the charge shows up on the subscription page with the origin API, and the team can make the same charge by hand through the Charge extra amount… button. Every approved charge counts as one order in the store's Vipter plan quota.
Cancel, pause and change plan
The "cancel" or "change plan" button of your product can call the API instead of sending the user to the portal. Each call returns the already updated subscription, and the customer.subscription.* event arrives next, with the same object; Step 4's syncPlan handles both the same way.
| In your product | Call | Afterwards |
|---|---|---|
| Cancel at the end of the paid period | POST /v1/subscriptions/{id} with cancel_at_period_end: true and, optionally, cancellation_details[reason] and [comment] | status stays active until current_period_end; then customer.subscription.deleted goes out. Keep the access until then. |
| Undo the scheduled cancellation | POST …/reactivate | cancel_at_period_end goes back to false. |
| Cancel now | DELETE /v1/subscriptions/{id} | status: "canceled" and customer.subscription.deleted right away. |
| Pause and resume | POST …/pause, POST …/resume | paused and active; customer.subscription.paused and .resumed. A paused subscription does not accept usage charges. |
| Upgrade or downgrade | POST …/change_offer with the other plan's ofr_… | offer, amount and next_billing_at already come from the new offer; customer.subscription.updated follows. |
Store the ofr_… of each plan you sell: change_offer only accepts the ID, and the new offer must belong to the same product or product family. The user can still do the same things through the customer portal; the events are the same on both paths.
What to store in your database
| Store | Where it comes from | What for |
|---|---|---|
The customer's cust_… | The session's customer after payment, or the event | Open future sessions with customer, generate the customer portal link, list orders. |
The subscription's sub_… | The session's subscription, or data.object.id of the customer.subscription.* events | Recognize renewals, cancellations and plan changes when the subscription events arrive: they carry client_reference_id, but the sub_… is the stable key. It is also the ID that bills usage and cancels, pauses or changes the plan. |
The order's ord_… | The session's order | Show the receipt, check GET /v1/orders/{id}, cross with invoice.paid. |
Each usage charge's sch_… | The POST …/charges response, or data.object.id of the subscription_charge.* events | Know which period has already been billed and follow a pending charge in GET /v1/subscription_charges/{id}. |
The session's cs_… | The POST response | Link the return on the success_url to the user and resume an unfinished checkout. |
Each event's evt_… | The webhook envelope | Discard repeats. |
Do not store the customer portal url: it is valid for 15 minutes and serves one sign-in. Generate a new one on every click on "manage subscription".
Coexisting with Stripe
Many SaaS use Stripe outside Brazil and Vipter to charge in reais, with PIX and card installments. You can keep both with little friction:
- The same user ID in
client_reference_idon both systems. Each one's events say which user they belong to without a translation table. - One endpoint per provider, with its own route and secret:
/webhooks/stripewithStripe-Signature,/webhooks/vipterwithVipter-Signature. The signature algorithm is the same (HMAC SHA-256 overt.body), but the header and the secret are different. - Discard repeats by
(provider, event id), not byidalone: both use theevt_prefix, and a Vipteridnever collides with a Stripe one, but a unique constraint onidalone mixes the two event tables in your head. Make the provider explicit in the key. - The event names coincide in the 2026-11-01 catalog:
checkout.session.completed,invoice.paid,customer.subscription.updated. What changes is the object:data.objectin Vipter is the session, the order (object: "order", notinvoice) and the subscription in the reference format, withofferin place ofpriceandorderin place ofinvoice. Oneswitchonevent.typeserves both; the body of eachcasereads different fields. - Store the provider next to the subscription (
provider: 'vipter' | 'stripe',subscription_id). The customer portal is also one per provider: the "manage subscription" button callsPOST /v1/billing_portal/sessionsfor one and Stripe's Billing Portal Session for the other.
Common problems
-
400 offer_unavailablewhen creating the sessionThe offer exists but is not on sale: the checkout link is turned off, the offer was archived or has no price.
messagesays why. Check the offer in the dashboard or withGET /v1/offers/{id}(active,checkout_url,prices). -
403 selling_blockedThe store cannot sell because its Vipter subscription is past due. See Past-due payment.
-
The
success_urlarrived with{CHECKOUT_SESSION_ID}unreplacedThe text must be exactly
{CHECKOUT_SESSION_ID}, with braces and capitals. Check that your HTTP client did not encode the braces as%7Bbefore sending the body. -
The webhook does not arrive, but the dashboard shows the sale
Check the endpoint's version: the
checkout.session.*events only exist in the 2026-11-01 catalog. An endpoint on version 2026-09-01 receivesorder.paid, notcheckout.session.completed. See Receive events in your system. -
subscriptioncamenullon the paid sessionThe provider's notice about the subscription has not been processed yet. Query again in a few seconds or use the webhook's
customer.subscription.created.
What to do next
- See every field and every error in Checkout sessions and in Subscription charges.
- Understand when each event goes out in the 2026-11-01 catalog.
- Offer "manage subscription" with a customer portal session.
API reference (v1)
Every endpoint of the Vipter API with its parameters, an example call and response, and the field table of each object: account, customer, subscription and its actions, subscription charge, metered usage (meters, usage events, items and periods), order, offer, product, checkout session, customer portal session, events and webhook endpoints.
Metered usage (Meters)
How to bill by consumption while Vipter keeps the count, in the shape of Stripe's Billing Meters: create a meter, price the usage on the offer or on the subscription, send usage events, read the open period and what happens when the cycle closes, with curl and Node.js examples, allowances, graduated tiers, billing thresholds and the differences from Stripe.