SaaS: del registro al dashboard
Guía con código para cobrarle a un usuario de tu SaaS por Vipter: crear la sesión de checkout con el ID del usuario, redirigir, confirmar el pago por la API o por el webhook checkout.session.completed, tratar el PIX, la idempotencia, cobrar el uso del mes en la tarjeta guardada, cancelar y cambiar de plan, qué guardar y cómo convivir con Stripe.
Esta guía conecta el registro de tu producto con una suscripción en Vipter, desde el clic en "suscribirse" hasta el usuario de vuelta en tu dashboard con el plan activo. Usa tres piezas: POST /v1/checkout/sessions, la página de gracias de Vipter y el webhook checkout.session.completed. El código está en curl y Node.js, sin bibliotecas más allá de fetch y node:crypto.
Antes de empezar
- Una clave de API con el alcance
write, guardada como variable de entorno (VIPTER_API_KEY). - El
ofr_…de cada plan que vendes. Tómalo deGET /v1/offers?type=recurringo de la página de la oferta en el panel. - Un endpoint de webhook creado con la versión de eventos 2026-11-01 y su secreto
whsec_…(VIPTER_WEBHOOK_SECRET). Consulta Recibir eventos en tu sistema. - Una página
https://en tu sistema para recibir al usuario después del pago.
El flujo
- El usuario se registra en tu producto y hace clic en "suscribirse". Ya tienes su ID (
user_8213) y su correo. - Tu servidor llama a
POST /v1/checkout/sessionscon la oferta,client_reference_idigual al ID del usuario,customer_emaily unasuccess_urlcon{CHECKOUT_SESSION_ID}. - Rediriges el navegador del usuario a la
urlde la respuesta. Paga en la página de Vipter. - Vipter muestra la página de gracias de la tienda y, con el pago confirmado, redirige a tu
success_url, con elidde la sesión en lugar de{CHECKOUT_SESSION_ID}. - Tu página lee el
session_id, lo confirma conGET /v1/checkout/sessions/{id}y muestra el plan activo. - En paralelo, el webhook
checkout.session.completedllega a tu servidor con la misma sesión. Es el que libera el plan de verdad, aunque el usuario cierre la pestaña antes de la redirección.
Los pasos 5 y 6 son redundantes a propósito: la página da la respuesta rápida, el webhook da la garantía.
Paso 1: crear la sesión
En tu servidor, cuando el usuario elige el plan:
curl -X POST https://api.vipter.com/v1/checkout/sessions \
-H "Authorization: Bearer $VIPTER_API_KEY" \
-H "Idempotency-Key: user_8213:pro-mensual:$(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"
}'Qué hace cada campo aquí:
client_reference_ides el vínculo entre los dos sistemas. Vuelve en la sesión, en el pedido y en la suscripción, y permite listar las sesiones de un usuario conGET /v1/checkout/sessions?client_reference_id=user_8213. Usa el ID interno del usuario, no el correo: los correos cambian.customer_emailbloquea el campo de correo del checkout. El cliente que Vipter cree tendrá ese correo, y el portal del cliente lo reconocerá por él.metadataqueda en la sesión y se copia al pedido. Para guardar algo en la suscripción, usasubscription_data[metadata]; sin él, la suscripción recibe el mismometadata.success_urlcon{CHECKOUT_SESSION_ID}es lo que le permite a tu página saber qué sesión se acaba de pagar.cancel_urlse convierte en el enlace de volver arriba del checkout.
La sesión vale por 24 horas (ajústalo con expires_at, entre 30 minutos y 24 horas). Guarda el id de la sesión vinculado al usuario: si vuelve a la página de planes sin haber pagado, puedes reutilizar la url en vez de crear otra sesión, o expirar la anterior cuando elija otro plan.
Si el usuario ya es cliente de la tienda (una suscripción anterior, por ejemplo), pasa customer con su cust_… en lugar de customer_email: nombre, teléfono y documento vienen completados.
Paso 2: redirigir y dejar que el comprador pague
Responde al navegador con una redirección a session.url. La página es el checkout de la tienda, con la oferta, la moneda y el cupón fijos y el correo bloqueado. El comprador elige el medio de pago y paga.
Una tarjeta rechazada no cierra la sesión: el comprador prueba otra tarjeta en la misma página. Un PIX generado completa la sesión con payment_status: "pending" hasta que se pague. Consulta PIX y otros pagos asíncronos.
Paso 3: recibir al usuario de vuelta
Después del pago confirmado, la página de gracias de Vipter espera redirect_delay segundos (predeterminado 5) y va a tu success_url:
https://app.example.com/billing/success?session_id=cs_4b7e2d9a1c3f5e8b0d2a6c4e1f3bEn tu página, confirma la sesión antes de mostrar cualquier cosa como pagada:
export async function handleSuccess(sessionId, currentUser) {
const session = await vipter(`/checkout/sessions/${encodeURIComponent(sessionId)}`);
if (session.client_reference_id !== currentUser.id) throw new Error('sesión de otro usuario');
if (session.status === 'complete' && session.payment_status === 'paid') {
// Pagado. Muestra el plan activo. El webhook puede haber liberado ya el acceso; si no, libéralo aquí también (idempotente).
return { state: 'paid', subscriptionId: session.subscription, orderId: session.order };
}
if (session.status === 'complete' && session.payment_status === 'pending') {
// PIX generado y todavía no pagado: muestra "esperando el pago" y espera el webhook.
return { state: 'pending' };
}
return { state: 'not_paid' }; // open, expired o un pago que falló
}Tres cuidados:
- Compara
client_reference_idcon el usuario que inició sesión. Elidde la sesión no es adivinable, pero la URL se puede copiar. - No liberes el plan solo porque el navegador llegó a la
success_url. La fuente de la verdad es elstatusy elpayment_statusde la sesión, o el webhook. subscriptionpuede venirnullpor unos segundos después del pago, hasta que se procese el aviso del proveedor. Si lo necesitas en el momento, consulta de nuevo enseguida o espera el webhook, que solo sale con los datos que ya existen.
Si el usuario cierra la pestaña antes de la redirección, no se pierde nada: el webhook del paso siguiente llega igual.
Paso 4: recibir el webhook
Crea el endpoint con la versión de eventos 2026-11-01 y marca al menos checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, customer.subscription.updated y customer.subscription.deleted; si vas a cobrar uso, también invoice.paid, subscription_charge.succeeded y subscription_charge.failed. Puedes hacerlo en el panel, en Recibir eventos en tu sistema, o por la API, que devuelve el secreto de firma una sola 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": "Facturación del SaaS",
"enabled_events": ["checkout.session.*", "customer.subscription.updated", "customer.subscription.deleted", "invoice.paid", "subscription_charge.*"]
}'Guarda el secret de la respuesta en VIPTER_WEBHOOK_SECRET. La versión 2026-11-01 es la predeterminada de la API; los campos están en Crear un endpoint. El data.object de cada evento es el mismo JSON que devuelve la API: la sesión en los eventos checkout.session.*, la suscripción en los customer.subscription.*.
El servidor de abajo verifica la firma con la función de Verificar la firma, descarta repeticiones por el id del evento, responde 200 y recién entonces procesa:
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 generado, todavía no pagado
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': {
// Renovación, cambio de plan, recuperación de cobro, pausa… Lee el estado, no el nombre del 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('firma 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));Puntos que el código de arriba asume, y que valen para Vipter:
- El mismo evento puede llegar más de una vez, con el mismo
id. La tabla con restricción de unicidad lo resuelve. Consulta idempotencia. - El orden no está garantizado: un
customer.subscription.updatedpuede llegar antes delcheckout.session.completed. Por esohandleEventlee el estado del objeto (payment_status,status) en vez de deducirlo por el nombre del evento, y cada función (activatePlan,syncPlan) tiene que poder ejecutarse dos veces. - Responder 200 en hasta 10 segundos es obligatorio; el procesamiento va después de la respuesta. En un servidor sin cola, el
handleEventdespués delres.endya alcanza para empezar. - Para comprobar que el endpoint responde y la firma coincide, llama a
POST /v1/webhook_endpoints/{id}/testcon elidde la respuesta de arriba, o haz clic en Enviar evento de prueba en la tarjeta del endpoint. Llega uncustomer.createdde prueba en el formato 2026-11-01, conlivemode: falseydata.object.test: true, que elhandleEventde arriba ignora porque no tiene uncasepara él. Para probar loscheckout.session.*de punta a punta, haz una compra en una conexión de prueba o usa elcurlde Probar sin esperar un evento.
PIX y otros pagos asíncronos
Cuando el comprador elige PIX (el pago instantáneo de Brasil), Vipter genera el código y la sesión se completa en el momento con payment_status: "pending": sale el checkout.session.completed con order completado y payment_status: "pending". El comprador paga en la app del banco, y entonces:
- Si pagó: la sesión pasa a
paidy salecheckout.session.async_payment_succeeded. La página de gracias, si sigue abierta, lo nota y redirige a tusuccess_url. - Si el PIX venció sin pago: la sesión pasa a
payment_status: "unpaid"y salecheckout.session.async_payment_failed. La sesión no se reabre; si el usuario quiere intentar de nuevo, crea otra.
Una tarjeta que queda en análisis en el proveedor sigue el mismo camino: pending en el momento, async_payment_succeeded o async_payment_failed cuando termina el análisis.
En tu sistema, trata completed con pending como "esperando el pago": muestra el estado al usuario, pero no liberes el plan. Libéralo en async_payment_succeeded, o en completed cuando payment_status ya venga paid. El mismo hecho también sale en el catálogo original como order.paid, para quien usa un endpoint 2026-09-01.
Idempotencia
Dos lados piden cuidado:
- Al crear la sesión, envía una
Idempotency-Keyúnica por intento del usuario. Si la red se cae después de crear la sesión, repetir la llamada con la misma clave devuelve la misma sesión en vez de crear una segunda. La clave vale por 24 horas; las reglas están en Idempotencia. - Al recibir eventos, descarta repeticiones por el
idy escribe cada reacción de forma que ejecutarla dos veces dé el mismo resultado. "Activar el plan" se puede repetir; "enviar el correo de bienvenida" tiene que comprobar si ya se envió.
Cobro adicional por uso
Un SaaS con franquicia cobra la mensualidad por la suscripción y, a fin de mes, lo que superó la franquicia: llamadas, GB, asientos. En Vipter, la medición queda de tu lado; lo que hace la API es cobrar el monto que calculaste en la tarjeta guardada de la suscripción, en el momento, con POST /v1/subscriptions/{id}/charges. La renovación de la suscripción no cambia: el próximo cobro recurrente sigue en la misma fecha y con el mismo monto.
Si prefieres no mantener la cuenta, usa el uso medido: crea un medidor, manda un evento en cada consumo (POST /v1/billing/meter_events, con customer_id y value), y Vipter suma, aplica la franquicia y el precio definidos en la oferta o en la suscripción, y cobra el total en la tarjeta guardada cuando cierra el ciclo, con los mismos eventos invoice.paid y subscription_charge.*. El resto de esta sección es el camino en el que el cálculo queda de tu lado.
El flujo, una vez por período:
- Cierra el período en tu sistema y calcula el excedente de cada usuario, en centavos. Quien no superó la franquicia no genera cobro.
- Llama a
POST /v1/subscriptions/{sub_…}/chargesconamount, laslinesque explican el monto, y unaIdempotency-Keyque identifique el período, comousage:user_8213:2026-09. Esa clave es lo que impide cobrar el mismo mes dos veces: un job que vuelve a correr, una red que se cayó después de cobrar, dos servidores compitiendo, todos reciben el mismo cobro de vuelta. - Trata la respuesta:
201constatus: "succeeded"está cobrado;201constatus: "pending"es tarjeta en análisis, espera el webhook;402es tarjeta rechazada. - Guarda el
sch_…del cobro vinculado al usuario y al período, y cierra el período como cobrado cuando lleguesubscription_charge.succeeded.
// Usa fetch directo, y no el helper `vipter` del Paso 1, porque el 402 necesita el cuerpo entero: trae el cobro rechazado.
export async function chargeUsage(user, period) {
const usage = await computeUsage(user.id, period); // tu 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}`, // una por usuario y período, siempre la misma
},
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: cobrado ahora (succeeded) o en análisis (pending). 200: la clave ya se había usado; es el mismo cobro.
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) {
// Tarjeta rechazada. El cobro rechazado viene en error.subscription_charge; Vipter no reintenta por su cuenta.
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); // manda al usuario al portal del cliente a cambiar la tarjeta
return { state: 'declined', code: json.error.code };
}
if (['subscription_not_chargeable', 'no_payment_method', 'payment_method_not_chargeable'].includes(json.error.code)) {
// No sirve repetir con la misma suscripción: está pausada o cancelada, o no tiene tarjeta que acepte cobros sin el cliente.
await db.usageCharges.upsert({ userId: user.id, period, status: 'blocked', failureCode: json.error.code });
return { state: 'blocked', code: json.error.code };
}
// 409 (la primera llamada todavía está corriendo), 5xx, red: repite más tarde con la MISMA clave.
throw Object.assign(new Error(json.error?.message ?? res.statusText), { status: res.status, code: json.error?.code, requestId: res.headers.get('Request-Id') });
}Lo que garantiza cada detalle:
- La
Idempotency-Keyes el ID del período, no un valor aleatorio. La clave de un cobro queda ligada a él para siempre, sin el límite de 24 horas de las otras llamadas: volver a correr el cierre de septiembre en diciembre sigue devolviendo el cobro de septiembre, con200. Para cobrar el mismo período otra vez a propósito, después de un rechazo, cambia la clave (usage:user_8213:2026-09:2). - Las
linesson el extracto del usuario. Se convierten en los ítems del pedido que él ve en el portal del cliente y en el correo de confirmación de la tienda;amounttiene que ser su suma.unit_amountes entero, en centavos: un precio de R$ 0,02 por llamada es2. - El
metadatavuelve en los eventos.subscription_charge.succeededysubscription_charge.failedtraen el cobro con tuuser_idyperiod, así que el webhook cierra el período sin consultar nada. - Un
402no es un error de tu código. Es la respuesta normal para tarjeta rechazada: regístralo, avisa al usuario y decide cuándo repetir. El código de rechazo (error.code) viene del proveedor; no dependas de una lista fija.
En el webhook, agrega al handleEvent del Paso 4:
case 'subscription_charge.succeeded': {
// obj es el cobro: metadata.user_id y metadata.period son los tuyos.
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 pagado: renovación (billing_reason "subscription_cycle"), cobro de uso ("manual")… Úsalo para tu contabilidad.
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;
}Los dos eventos del cobro salen en el momento en un cobro aprobado o rechazado de inmediato, y solo después del análisis en un cobro que quedó pending. invoice.paid trae el pedido (object: "order", billing_reason: "manual", las lines con kind: "charge"), con el sch_… en external_order_id; invoice.payment_failed sale en un rechazo cuando el proveedor registró un pedido para el intento.
Lo que ve el usuario: el pedido con las líneas en el portal del cliente, junto a las renovaciones, y el correo de confirmación de compra de la tienda, cuando está activo. En el panel, el cobro aparece en la página de la suscripción con el origen API, y el equipo puede hacer el mismo cobro a mano con el botón Cobrar importe extra…. Cada cobro aprobado cuenta como un pedido en la cuota del plan Vipter de la tienda.
Cancelar, pausar y cambiar de plan
El botón "cancelar" o "cambiar de plan" de tu producto puede llamar a la API en vez de mandar al usuario al portal. Cada llamada devuelve la suscripción ya actualizada, y el evento customer.subscription.* llega a continuación, con el mismo objeto; el syncPlan del Paso 4 trata los dos de la misma forma.
| En tu producto | Llamada | Después |
|---|---|---|
| Cancelar al fin del período pagado | POST /v1/subscriptions/{id} con cancel_at_period_end: true y, si quieres, cancellation_details[reason] y [comment] | status sigue active hasta current_period_end; entonces sale customer.subscription.deleted. Mantén el acceso hasta ahí. |
| Deshacer la cancelación programada | POST …/reactivate | cancel_at_period_end vuelve a false. |
| Cancelar ahora | DELETE /v1/subscriptions/{id} | status: "canceled" y customer.subscription.deleted en el momento. |
| Pausar y reanudar | POST …/pause, POST …/resume | paused y active; customer.subscription.paused y .resumed. Una suscripción pausada no acepta cobros de uso. |
| Upgrade o downgrade | POST …/change_offer con el ofr_… del otro plan | offer, amount y next_billing_at ya vienen de la oferta nueva; customer.subscription.updated a continuación. |
Guarda el ofr_… de cada plan que vendes: change_offer solo acepta el ID, y la oferta nueva tiene que ser del mismo producto o familia de productos. El usuario sigue pudiendo hacer lo mismo por el portal del cliente; los eventos son los mismos en los dos caminos.
Qué guardar en tu base de datos
| Guarda | De dónde viene | Para qué |
|---|---|---|
cust_… del cliente | customer de la sesión después del pago, o del evento | Abrir sesiones futuras con customer, generar el enlace del portal del cliente, listar pedidos. |
sub_… de la suscripción | subscription de la sesión, o data.object.id de los eventos customer.subscription.* | Reconocer renovaciones, cancelaciones y cambios de plan cuando lleguen los eventos de la suscripción: traen client_reference_id, pero el sub_… es la clave estable. Es también el ID que cobra el uso y cancela, pausa o cambia el plan. |
ord_… del pedido | order de la sesión | Mostrar el recibo, revisar GET /v1/orders/{id}, cruzar con invoice.paid. |
sch_… de cada cobro de uso | Respuesta del POST …/charges, o data.object.id de los eventos subscription_charge.* | Saber qué período ya se cobró y seguir un cobro pending en GET /v1/subscription_charges/{id}. |
cs_… de la sesión | Respuesta del POST | Vincular el regreso en la success_url al usuario y retomar un checkout no concluido. |
evt_… de cada evento | Sobre del webhook | Descartar repeticiones. |
No guardes la url del portal del cliente: vale 15 minutos y sirve para una entrada. Genera una nueva en cada clic en "administrar suscripción".
Convivir con Stripe
Muchos SaaS usan Stripe fuera de Brasil y Vipter para cobrar en reales, con PIX y tarjeta en cuotas. Se pueden mantener los dos con poca fricción:
- El mismo ID de usuario en
client_reference_iden los dos sistemas. Los eventos de cada uno dicen a qué usuario pertenecen sin tabla de traducción. - Un endpoint por proveedor, con su ruta y su secreto:
/webhooks/stripeconStripe-Signature,/webhooks/vipterconVipter-Signature. El algoritmo de la firma es el mismo (HMAC SHA-256 sobret.cuerpo), pero el encabezado y el secreto son otros. - Descarta repeticiones por
(proveedor, id del evento), no solo por elid: los dos usan el prefijoevt_, y unidde Vipter nunca choca con uno de Stripe, pero una restricción de unicidad solo en elidmezcla las dos tablas de eventos en tu cabeza. Deja el proveedor explícito en la clave. - Los nombres de los eventos coinciden en el catálogo 2026-11-01:
checkout.session.completed,invoice.paid,customer.subscription.updated. Lo que cambia es el objeto:data.objecten Vipter es la sesión, el pedido (object: "order", noinvoice) y la suscripción en el formato de la referencia, conofferen lugar depriceyorderen lugar deinvoice. Unswitchporevent.typesirve para los dos; el cuerpo de cadacaselee campos distintos. - Guarda el proveedor junto a la suscripción (
provider: 'vipter' | 'stripe',subscription_id). El portal del cliente también es uno por proveedor: el botón "administrar suscripción" llama aPOST /v1/billing_portal/sessionspara uno y a la Billing Portal Session de Stripe para el otro.
Problemas comunes
-
400 offer_unavailableal crear la sesiónLa oferta existe pero no está a la venta: el enlace de checkout está desactivado, la oferta se archivó o no tiene precio. El
messagedice el motivo. Revisa la oferta en el panel o enGET /v1/offers/{id}(active,checkout_url,prices). -
403 selling_blockedLa tienda no puede vender porque la mensualidad de Vipter está atrasada. Consulta Pago atrasado.
-
La
success_urlllegó con{CHECKOUT_SESSION_ID}sin reemplazarEl texto tiene que ser exactamente
{CHECKOUT_SESSION_ID}, con llaves y mayúsculas. Revisa que tu cliente HTTP no haya codificado las llaves como%7Bantes de enviar el cuerpo. -
El webhook no llega, pero el panel muestra la venta
Revisa la versión del endpoint: los eventos
checkout.session.*solo existen en el catálogo 2026-11-01. Un endpoint en la versión 2026-09-01 recibeorder.paid, y nocheckout.session.completed. Consulta Recibir eventos en tu sistema. -
subscriptionvinonullen la sesión pagadaEl aviso del proveedor sobre la suscripción todavía no se procesó. Consulta de nuevo en unos segundos o usa el
customer.subscription.createddel webhook.
Qué hacer después
- Consulta cada campo y cada error en Sesiones de checkout y en Cobros en la suscripción.
- Entiende cuándo sale cada evento en el catálogo 2026-11-01.
- Ofrece "administrar suscripción" con una sesión del portal del cliente.
Referencia de la API (v1)
Cada endpoint de la API de Vipter con sus parámetros, un ejemplo de llamada y de respuesta, y la tabla de campos de cada objeto: cuenta, cliente, suscripción y sus acciones, cobro en la suscripción, uso medido (medidores, eventos de uso, ítems y períodos), pedido, oferta, producto, sesión de checkout, sesión del portal del cliente, eventos y endpoints de webhook.
Uso medido (Meters)
Cómo cobrar por consumo dejando la cuenta con Vipter, en el formato de los Billing Meters de Stripe: crear un medidor, ponerle precio al uso en la oferta o en la suscripción, enviar eventos de uso, leer el período abierto y qué pasa cuando cierra el ciclo, con ejemplos en curl y Node.js, franquicia, tramos de precio, umbral de cobro y las diferencias con Stripe.