VipterCentro de Ayuda

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.

Admin o PropietarioTodos los planes

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 de GET /v1/offers?type=recurring o 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

  1. El usuario se registra en tu producto y hace clic en "suscribirse". Ya tienes su ID (user_8213) y su correo.
  2. Tu servidor llama a POST /v1/checkout/sessions con la oferta, client_reference_id igual al ID del usuario, customer_email y una success_url con {CHECKOUT_SESSION_ID}.
  3. Rediriges el navegador del usuario a la url de la respuesta. Paga en la página de Vipter.
  4. Vipter muestra la página de gracias de la tienda y, con el pago confirmado, redirige a tu success_url, con el id de la sesión en lugar de {CHECKOUT_SESSION_ID}.
  5. Tu página lee el session_id, lo confirma con GET /v1/checkout/sessions/{id} y muestra el plan activo.
  6. En paralelo, el webhook checkout.session.completed llega 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_id es 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 con GET /v1/checkout/sessions?client_reference_id=user_8213. Usa el ID interno del usuario, no el correo: los correos cambian.
  • customer_email bloquea el campo de correo del checkout. El cliente que Vipter cree tendrá ese correo, y el portal del cliente lo reconocerá por él.
  • metadata queda en la sesión y se copia al pedido. Para guardar algo en la suscripción, usa subscription_data[metadata]; sin él, la suscripción recibe el mismo metadata.
  • success_url con {CHECKOUT_SESSION_ID} es lo que le permite a tu página saber qué sesión se acaba de pagar.
  • cancel_url se 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_4b7e2d9a1c3f5e8b0d2a6c4e1f3b

En tu página, confirma la sesión antes de mostrar cualquier cosa como pagada:

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('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_id con el usuario que inició sesión. El id de 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 el status y el payment_status de la sesión, o el webhook.
  • subscription puede venir null por 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:

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 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.updated puede llegar antes del checkout.session.completed. Por eso handleEvent lee 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 handleEvent después del res.end ya alcanza para empezar.
  • Para comprobar que el endpoint responde y la firma coincide, llama a POST /v1/webhook_endpoints/{id}/test con el id de la respuesta de arriba, o haz clic en Enviar evento de prueba en la tarjeta del endpoint. Llega un customer.created de prueba en el formato 2026-11-01, con livemode: false y data.object.test: true, que el handleEvent de arriba ignora porque no tiene un case para él. Para probar los checkout.session.* de punta a punta, haz una compra en una conexión de prueba o usa el curl de 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 paid y sale checkout.session.async_payment_succeeded. La página de gracias, si sigue abierta, lo nota y redirige a tu success_url.
  • Si el PIX venció sin pago: la sesión pasa a payment_status: "unpaid" y sale checkout.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 id y 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:

  1. Cierra el período en tu sistema y calcula el excedente de cada usuario, en centavos. Quien no superó la franquicia no genera cobro.
  2. Llama a POST /v1/subscriptions/{sub_…}/charges con amount, las lines que explican el monto, y una Idempotency-Key que identifique el período, como usage: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.
  3. Trata la respuesta: 201 con status: "succeeded" está cobrado; 201 con status: "pending" es tarjeta en análisis, espera el webhook; 402 es tarjeta rechazada.
  4. Guarda el sch_… del cobro vinculado al usuario y al período, y cierra el período como cobrado cuando llegue subscription_charge.succeeded.
usage-billing.mjs
// 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-Key es 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, con 200. 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 lines son 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; amount tiene que ser su suma. unit_amount es entero, en centavos: un precio de R$ 0,02 por llamada es 2.
  • El metadata vuelve en los eventos. subscription_charge.succeeded y subscription_charge.failed traen el cobro con tu user_id y period, así que el webhook cierra el período sin consultar nada.
  • Un 402 no 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:

webhook.mjs (fragmento)
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 productoLlamadaDespués
Cancelar al fin del período pagadoPOST /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 programadaPOST …/reactivatecancel_at_period_end vuelve a false.
Cancelar ahoraDELETE /v1/subscriptions/{id}status: "canceled" y customer.subscription.deleted en el momento.
Pausar y reanudarPOST …/pause, POST …/resumepaused y active; customer.subscription.paused y .resumed. Una suscripción pausada no acepta cobros de uso.
Upgrade o downgradePOST …/change_offer con el ofr_… del otro planoffer, 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

GuardaDe dónde vienePara qué
cust_… del clientecustomer de la sesión después del pago, o del eventoAbrir sesiones futuras con customer, generar el enlace del portal del cliente, listar pedidos.
sub_… de la suscripciónsubscription 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 pedidoorder de la sesiónMostrar el recibo, revisar GET /v1/orders/{id}, cruzar con invoice.paid.
sch_… de cada cobro de usoRespuesta 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ónRespuesta del POSTVincular el regreso en la success_url al usuario y retomar un checkout no concluido.
evt_… de cada eventoSobre del webhookDescartar 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_id en 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/stripe con Stripe-Signature, /webhooks/vipter con Vipter-Signature. El algoritmo de la firma es el mismo (HMAC SHA-256 sobre t.cuerpo), pero el encabezado y el secreto son otros.
  • Descarta repeticiones por (proveedor, id del evento), no solo por el id: los dos usan el prefijo evt_, y un id de Vipter nunca choca con uno de Stripe, pero una restricción de unicidad solo en el id mezcla 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.object en Vipter es la sesión, el pedido (object: "order", no invoice) y la suscripción en el formato de la referencia, con offer en lugar de price y order en lugar de invoice. Un switch por event.type sirve para los dos; el cuerpo de cada case lee 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 a POST /v1/billing_portal/sessions para uno y a la Billing Portal Session de Stripe para el otro.

Problemas comunes

  • 400 offer_unavailable al crear la sesión

    La oferta existe pero no está a la venta: el enlace de checkout está desactivado, la oferta se archivó o no tiene precio. El message dice el motivo. Revisa la oferta en el panel o en GET /v1/offers/{id} (active, checkout_url, prices).

  • 403 selling_blocked

    La tienda no puede vender porque la mensualidad de Vipter está atrasada. Consulta Pago atrasado.

  • La success_url llegó con {CHECKOUT_SESSION_ID} sin reemplazar

    El texto tiene que ser exactamente {CHECKOUT_SESSION_ID}, con llaves y mayúsculas. Revisa que tu cliente HTTP no haya codificado las llaves como %7B antes 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 recibe order.paid, y no checkout.session.completed. Consulta Recibir eventos en tu sistema.

  • subscription vino null en la sesión pagada

    El aviso del proveedor sobre la suscripción todavía no se procesó. Consulta de nuevo en unos segundos o usa el customer.subscription.created del webhook.

Qué hacer después

¿Te ayudó esta página?

En esta página

Idioma