VipterHelp Center

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.

Admin or OwnerAll plans

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 write scope, stored as an environment variable (VIPTER_API_KEY).
  • The ofr_… of each plan you sell. Get it from GET /v1/offers?type=recurring or 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

  1. The user signs up for your product and clicks "subscribe". You already have their ID (user_8213) and email.
  2. Your server calls POST /v1/checkout/sessions with the offer, client_reference_id equal to the user's ID, customer_email and a success_url with {CHECKOUT_SESSION_ID}.
  3. You redirect the user's browser to the response's url. They pay on the Vipter page.
  4. Vipter shows the store's thank-you page and, once the payment is confirmed, redirects to your success_url, with the session's id in place of {CHECKOUT_SESSION_ID}.
  5. Your page reads the session_id, confirms it with GET /v1/checkout/sessions/{id} and shows the plan as active.
  6. In parallel, the checkout.session.completed webhook 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_id is 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 with GET /v1/checkout/sessions?client_reference_id=user_8213. Use the user's internal ID, not the email: emails change.
  • customer_email locks the checkout's email field. The customer Vipter creates will have that email, and the customer portal will recognize them by it.
  • metadata stays on the session and is copied to the order. To store something on the subscription, use subscription_data[metadata]; without it, the subscription receives the same metadata.
  • success_url with {CHECKOUT_SESSION_ID} is what lets your page know which session was just paid.
  • cancel_url becomes 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_4b7e2d9a1c3f5e8b0d2a6c4e1f3b

On your page, confirm the session before showing anything as paid:

success.mjs
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_id against the signed-in user. The session's id is 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's status and payment_status, or the webhook.
  • subscription may come null for 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:

webhook.mjs
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.updated can arrive before the checkout.session.completed. That is why handleEvent reads 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 handleEvent after res.end is enough to start with.
  • To check that the endpoint answers and the signature matches, call POST /v1/webhook_endpoints/{id}/test with the id from the response above, or click Send test event on the endpoint's card. A test customer.created arrives in the 2026-11-01 format, with livemode: false and data.object.test: true, which the handleEvent above ignores because it has no case for it. To test the checkout.session.* events end to end, make a purchase through a test connection or use the curl from 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 paid and checkout.session.async_payment_succeeded goes out. The thank-you page, if still open, notices and redirects to your success_url.
  • If the PIX expired unpaid: the session becomes payment_status: "unpaid" and checkout.session.async_payment_failed goes 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-Key unique 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 id and 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:

  1. Close the period in your system and compute each user's overage, in cents. Whoever stayed within the allowance generates no charge.
  2. Call POST /v1/subscriptions/{sub_…}/charges with amount, the lines that explain the amount, and an Idempotency-Key that identifies the period, such as usage: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.
  3. Handle the response: 201 with status: "succeeded" is charged; 201 with status: "pending" is a card under review, wait for the webhook; 402 is a declined card.
  4. Store the charge's sch_… linked to the user and the period, and close the period as billed when subscription_charge.succeeded arrives.
usage-billing.mjs
// 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-Key is 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, with 200. To charge the same period again on purpose, after a decline, change the key (usage:user_8213:2026-09:2).
  • lines are 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; amount must be their sum. unit_amount is an integer, in cents: a price of R$ 0.02 per call is 2.
  • metadata comes back in the events. subscription_charge.succeeded and subscription_charge.failed carry the charge with your user_id and period, so the webhook closes the period without looking anything up.
  • A 402 is 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:

webhook.mjs (excerpt)
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 productCallAfterwards
Cancel at the end of the paid periodPOST /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 cancellationPOST …/reactivatecancel_at_period_end goes back to false.
Cancel nowDELETE /v1/subscriptions/{id}status: "canceled" and customer.subscription.deleted right away.
Pause and resumePOST …/pause, POST …/resumepaused and active; customer.subscription.paused and .resumed. A paused subscription does not accept usage charges.
Upgrade or downgradePOST …/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

StoreWhere it comes fromWhat for
The customer's cust_…The session's customer after payment, or the eventOpen 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.* eventsRecognize 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 orderShow 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.* eventsKnow which period has already been billed and follow a pending charge in GET /v1/subscription_charges/{id}.
The session's cs_…The POST responseLink the return on the success_url to the user and resume an unfinished checkout.
Each event's evt_…The webhook envelopeDiscard 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_id on 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/stripe with Stripe-Signature, /webhooks/vipter with Vipter-Signature. The signature algorithm is the same (HMAC SHA-256 over t.body), but the header and the secret are different.
  • Discard repeats by (provider, event id), not by id alone: both use the evt_ prefix, and a Vipter id never collides with a Stripe one, but a unique constraint on id alone 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.object in Vipter is the session, the order (object: "order", not invoice) and the subscription in the reference format, with offer in place of price and order in place of invoice. One switch on event.type serves both; the body of each case reads 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 calls POST /v1/billing_portal/sessions for one and Stripe's Billing Portal Session for the other.

Common problems

  • 400 offer_unavailable when creating the session

    The offer exists but is not on sale: the checkout link is turned off, the offer was archived or has no price. message says why. Check the offer in the dashboard or with GET /v1/offers/{id} (active, checkout_url, prices).

  • 403 selling_blocked

    The store cannot sell because its Vipter subscription is past due. See Past-due payment.

  • The success_url arrived with {CHECKOUT_SESSION_ID} unreplaced

    The text must be exactly {CHECKOUT_SESSION_ID}, with braces and capitals. Check that your HTTP client did not encode the braces as %7B before 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 receives order.paid, not checkout.session.completed. See Receive events in your system.

  • subscription came null on the paid session

    The 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

Was this page helpful?

On this page

Language