Cobrar suscripciones
Suscripciones con la librería landapay: suscribe con un plan existente o definido en la misma llamada, activa el acceso con webhooks, cambia de plan con prorrateo y gestiona cancelaciones.
Cómo funciona
- Llamas a
landapay.subscriptions.createcon el plan, que puede ser un id o definirse en la misma llamada, y opcionalmente el cliente. - Envías al cliente a
checkoutUrl, donde registra su tarjeta, Nequi o cuenta Bancolombia. - Se crea la suscripción y te llega el webhook
subscription.created. - Landa Pay cobra cada periodo y te avisa de cada cambio con webhooks.
Con el plan en la llamada
No necesitas crear el plan antes. Pasa sus datos en plan y la librería lo crea por ti, ideal cuando el precio depende de cada cliente, como una licencia por número de usuarios.
import { landapay } from "landapay"; const { checkoutUrl, plan, customer } = await landapay.subscriptions.create({ plan: { name: "Licencia Pro - 5 usuarios", interval: "monthly", amount: 4_990_000, // $49.900 COP al mes metadata: { seats: 5 }, }, customer: { name: "María Gómez", email: "[email protected]" }, externalId: "license_8812", metadata: { workspace: "acme" }, redirectUrl: "https://tuapp.com/suscripcion",}); res.redirect(checkoutUrl);El resultado trae checkoutUrl, el checkout completo, el plan que se creó y el id del customer. Si no envías name, el plan se llama según su frecuencia, por ejemplo “Suscripción mensual”.
| interval | Frecuencia de cobro |
|---|---|
weekly | Cada semana |
quincenal | Cada 15 días |
monthly | Cada mes |
quarterly | Cada 3 meses |
semiannual | Cada 6 meses |
annual | Cada año |
Con un plan existente
Si vendes los mismos planes a todos tus clientes, créalos una vez con landapay.plans.create o en el panel, y usa su id:
const { checkoutUrl } = await landapay.subscriptions.create({ plan: "plan_PLANCODE23456789", customer: "cus_ABCDEFGHJKLMNPQR", externalId: "user_42",});Prueba gratis y precios por etapas
Con amount y trialDays defines un precio simple con prueba gratis. Con phases defines precios por etapas: cada fase lleva su duration salvo la última, que se repite hasta el final. Usa termCycleCount para terminar la suscripción tras un número de cobros.
// 14 días gratis y luego $49.900 al mesawait landapay.subscriptions.create({ plan: { interval: "monthly", amount: 4_990_000, trialDays: 14 }, customer: "cus_ABCDEFGHJKLMNPQR",}); // 3 meses a $19.900, luego $49.900, durante 12 cobros en totalawait landapay.subscriptions.create({ plan: { name: "Promo lanzamiento", interval: "monthly", termCycleCount: 12, phases: [ { type: "priced", amount: 1_990_000, duration: { unit: "month", count: 3 } }, { type: "priced", amount: 4_990_000 }, ], }, customer: "cus_ABCDEFGHJKLMNPQR",});El cliente
- Con un id (
cus_...) se usa ese cliente. - Con sus datos se crea el cliente, o se reutiliza el que ya tenga ese email o
externalId. - Sin cliente, el checkout le pide sus datos.
Con un cliente, su email queda fijo en el checkout y solo se piden los datos que le falten.
Después del checkout
La suscripción se crea solo cuando el cliente completa el checkout, con el primer cobro o con el periodo de prueba del plan, y hereda el externalId y la metadata. El checkout vence en 24 horas, o en el expiresAt que envíes (máximo 7 días).
Con redirectUrl el cliente vuelve a tu URL con subscription_id y checkout_id; sin ella ve la página de resultado de Landa Pay.
app.get("/suscripcion", async (req, res) => { const checkout = await landapay.subscriptions.retrieveCheckout(String(req.query.checkout_id)); if (checkout.status === "completed" && checkout.subscription) { const subscription = await landapay.subscriptions.retrieve(checkout.subscription); return res.render("suscripcion-lista", { status: subscription.status }); } res.render("suscripcion-pendiente");});Cada cobro de la suscripción es un pago con el campo subscription, y te llega con los eventos payment.*.
Activar el acceso
No actives el acceso solo porque el cliente volvió
status. Da acceso solo si es active o trialing.const event = landapay.webhooks.constructEvent(req.body, req.get("Landa-Signature")); switch (event.type) { case "subscription.created": case "subscription.activated": case "subscription.past_due": case "subscription.canceled": case "subscription.completed": { const subscription = event.data.object; const allowed = subscription.status === "active" || subscription.status === "trialing"; if (allowed) await grantAccess(subscription.externalId, subscription.id); else await revokeAccess(subscription.externalId); break; }}No existe subscription.unpaid. Al fallar un cobro llega subscription.past_due y ahí quitas el acceso. unpaid significa que se agotaron los reintentos; el acceso ya debía estar quitado. No lo devuelvas hasta subscription.activated.
| Evento | Cuándo ocurre |
|---|---|
subscription.created | El cliente completó el checkout y se creó la suscripción |
subscription.activated | La suscripción quedó activa: primer cobro aprobado, fin de la prueba o recuperación de un cobro fallido |
subscription.past_due | Falló el cobro de renovación y la suscripción quedó en mora |
subscription.cancel_scheduled | Se programó la cancelación para el final del periodo |
subscription.resumed | Se quitó la cancelación programada |
subscription.canceled | La suscripción terminó por cancelación |
subscription.completed | La suscripción terminó al cumplir los ciclos del plan |
subscription.plan_change_scheduled | Se programó un cambio de plan para el final del periodo |
subscription.plan_changed | La suscripción pasó al plan nuevo |
subscription.plan_change_failed | El cobro del cambio de plan fue rechazado; la suscripción sigue en su plan |
Aprende a configurar el endpoint y verificar la firma en Recibir webhooks.
Actualizar el método de pago
Si un cobro falla porque la tarjeta cambió o no tiene saldo, no pidas los datos en tu app. Crea una sesión y redirige al cliente a Landa Pay para ver, agregar o eliminar sus métodos. El enlace es de un solo uso, caduca en 15 minutos y la API key no sale de tu servidor.
const session = await landapay.subscriptions.createPaymentMethodSession( "sub_SUBSCRIPTION2345", { returnUrl: "https://tuapp.com/facturacion" },); res.redirect(session.url);returnUrl es opcional. Si no lo envías, un cobro aprobado vuelve al redirectUrl del checkout de esa suscripción. La vuelta trae subscription_id y payment_id. Confirma con payments.retrieve o con el webhook; volver no activa el acceso.
- Si la suscripción está
past_dueounpaid, guardar un método nuevo o elegir uno anula los periodos impagos y cobra de inmediato un ciclo nuevo desde ese momento. Si aprueba, llegapayment.approvedysubscription.activated, y ahí devuelves el acceso. - Si está al día, solo se guarda el método. Los próximos cobros salen de ahí y no se cobra nada en ese momento.
- Si el cobro se rechaza, el cliente se queda en la página y puede probar otra tarjeta.
Estados
| Estado | Qué significa |
|---|---|
incomplete | Se está procesando el primer cobro |
trialing | En periodo de prueba; el primer cobro llega al terminarlo |
active | Al día con sus cobros |
past_due | Falló un cobro de renovación; seguimos reintentando |
unpaid | Se agotaron los reintentos de cobro |
canceled | Cancelada; ya no se cobra |
completed | Cumplió los ciclos del plan |
Los periodos que no se cobraron no se recuperan. Cuando el cliente guarda un método de pago, se abre un ciclo desde ese momento. Si ese cobro se aprueba, llega subscription.activated.
Cancelar y reanudar
Por defecto la suscripción sigue activa hasta el final del periodo pagado (subscription.cancel_scheduled) y puedes deshacerlo con resume. Con atPeriodEnd: false termina de inmediato (subscription.canceled). En ambos casos el cliente recibe un correo.
// Al final del periodo pagado; se puede deshacerawait landapay.subscriptions.cancel("sub_SUBSCRIPTION2345", { reason: "Cambio de plan" });await landapay.subscriptions.resume("sub_SUBSCRIPTION2345"); // De inmediatoawait landapay.subscriptions.cancel("sub_SUBSCRIPTION2345", { atPeriodEnd: false });Cambiar de plan
Una suscripción active o trialing puede subir o bajar de plan. Es una subida si el plan nuevo cuesta más por día que el actual, así que se pueden comparar planes de distinta frecuencia, y un empate cuenta como subida. Antes de confirmar, muestra a tu cliente lo que pasará con previewPlanChange; no cambia nada.
const preview = await landapay.subscriptions.previewPlanChange("sub_SUBSCRIPTION2345", { plan: "plan_PRO2345ABCDEFGH",}); preview.direction; // "upgrade" o "downgrade"preview.behavior; // el que se usará: el del panel o el que envíespreview.charge; // lo que se cobra hoy, en centavospreview.balanceDelta; // lo que queda a favor del cliente (positivo) o se suma al próximo cobropreview.effectiveAt; // cuándo entra en vigorpreview.nextBillingAt; // próximo cobro con el plan nuevopreview.nextAmount; // y su monto, ya con el saldo aplicadoComportamientos
Ejemplo: el cliente paga $50.000 al mes por Básico, ya usó el 20 % del periodo y sube a Pro de $100.000. Le quedan $40.000 sin usar del plan actual ($50.000 × 0,8).
| behavior | Qué hace | En el ejemplo |
|---|---|---|
prorate | Cambia ya y conserva la fecha de cobro. Cobra el plan nuevo por lo que falta del periodo menos lo no usado del actual | Cobra $40.000 hoy; el próximo cobro es de $100.000 en la fecha de siempre |
reset | Cambia ya y empieza un ciclo nuevo hoy. Cobra el plan nuevo completo menos lo no usado del actual | Cobra $60.000 hoy; el próximo cobro es en un mes desde hoy |
no_prorate | Cambia ya sin cobrar ni abonar nada. El próximo cobro ya es con el precio nuevo | No cobra hoy; el próximo cobro es de $100.000 en la fecha de siempre |
period_end | Programa el cambio para el final del periodo pagado; se puede deshacer | No cobra hoy; al renovar ya cobra $100.000 |
- Si el plan nuevo tiene otra frecuencia,
proratese aplica comoresetporque los periodos no coinciden. - En prueba gratis no hay nada cobrado: el cambio se aplica sin cobro y la prueba termina en la misma fecha, salvo con
period_end, que espera al final de la prueba. - El plan nuevo empieza en su primera fase con precio; su prueba gratis no se repite.
- Pedir otro cambio reemplaza al que estaba programado.
Cobro y saldo a favor
Si hay algo que cobrar, se cobra en el momento al método de pago de la suscripción y el cambio solo se aplica si el cobro se aprueba. Si se rechaza, recibes un InvalidRequestError con código payment_declined y la suscripción sigue en su plan. Con Nequi o Bancolombia el cobro puede quedar pendiente: pendingPlanChange.status es pending_payment hasta que te llegue subscription.plan_changed o subscription.plan_change_failed.
El cambio genera su propia factura con kind: "plan_change". Lo no usado del plan anterior aparece como descuento en creditApplied.
Cuando el resultado favorece al cliente, por ejemplo al bajar de Pro a Básico con prorate ($40.000 − $80.000), la diferencia queda como saldo a favor en subscription.balance y se descuenta de los próximos cobros: el siguiente de Básico sería de $10.000. Un cobro menor a $2.000 COP no se hace en el momento: el cambio se aplica y el monto se suma al próximo cobro. El saldo negativo es esa deuda pendiente.
import { InvalidRequestError, landapay } from "landapay"; try { const subscription = await landapay.subscriptions.changePlan("sub_SUBSCRIPTION2345", { plan: "plan_PRO2345ABCDEFGH", behavior: "prorate", // opcional; sin él se usa lo configurado en el panel }); if (subscription.pendingPlanChange?.status === "pending_payment") { // Nequi o Bancolombia: el cambio se aplica cuando se apruebe el cobro }} catch (error) { if (error instanceof InvalidRequestError && error.code === "payment_declined") { // El cliente sigue en su plan }} // Plan definido en la misma llamada, como en createawait landapay.subscriptions.changePlan("sub_SUBSCRIPTION2345", { plan: { name: "Licencia Pro - 10 usuarios", interval: "monthly", amount: 8_990_000 },}); // Deshacer un cambio programado con period_endawait landapay.subscriptions.cancelPlanChange("sub_SUBSCRIPTION2345");Configuración del proyecto
En el panel, en Configuración › Facturación › Cambios de plan, defines el comportamiento por defecto para subidas (prorate) y bajadas (period_end), si se permite bajar de plan y si se permite cambiar a un plan con otra frecuencia. El behavior que envíes en la llamada tiene prioridad sobre el del panel.
| Error | Cuándo ocurre |
|---|---|
409 same_plan | La suscripción ya está en ese plan |
409 downgrade_not_allowed | El proyecto no permite bajar de plan |
409 interval_change_not_allowed | El proyecto no permite cambiar de frecuencia |
409 subscription_past_due, subscription_incomplete, subscription_ended | Solo cambian de plan las suscripciones active o trialing |
409 cancel_scheduled | Tiene una cancelación programada; reanúdala primero |
409 plan_change_in_progress | Ya hay un cobro de cambio de plan esperando confirmación |
409 no_plan_change_scheduled | No hay un cambio programado para deshacer |
400 currency_mismatch | El plan nuevo tiene otra moneda |
402 payment_declined | Se rechazó el cobro; la suscripción sigue en su plan |
Webhooks
Usa subscription.plan_changed para ajustar los límites o el acceso de tu cliente; el evento trae la suscripción con su plan nuevo. El cliente también recibe un correo con el cambio.
switch (event.type) { case "subscription.plan_changed": { const subscription = event.data.object; const plan = await landapay.plans.retrieve(subscription.plan); await updateLimits(subscription.externalId, plan.metadata); break; } case "subscription.plan_change_failed": await notifyUser(event.data.object.externalId, "No pudimos cobrar el cambio de plan"); break;}Facturas de la suscripción
Cada ciclo tiene su factura, igual que en el tab Cobros del panel. Lístalas con landapay.subscriptions.listInvoices: llegan de 15 en 15 (máximo 50 con limit), la más reciente primero, con totalPages y hasMore para tu paginación. Cada factura trae su number y la url pública para mostrarla a tu cliente.
Los cobros de cambio de plan tienen su propia factura con kind: "plan_change" y cycle: null. Usa amountDue para mostrar lo cobrado: amount es el precio antes de descontar el saldo (creditApplied).
// Página de 15 facturas, la más reciente primeroconst page = await landapay.subscriptions.listInvoices("sub_SUBSCRIPTION2345", { page: Number(searchParams.get("pagina") ?? 1),}); const rows = page.data.map((invoice) => ({ number: invoice.number, // FAC-00012 url: invoice.url, // ábrelo con target="_blank" amount: invoice.amount, status: invoice.chargeStatus,})); console.log(`Página ${page.page} de ${page.totalPages}`); // O recórrelas todas sin manejar páginasfor await (const invoice of landapay.subscriptions.listInvoices("sub_SUBSCRIPTION2345")) { console.log(invoice.number, invoice.paidAt);}