
Suscripciones con Conekta: Cobros Recurrentes Bien Hechos — Planes, Webhooks y Dunning
Conekta cobra de forma recurrente en tres pasos: crea un Plan (monto, intervalo, trial opcional), crea un Cliente con tarjeta vía Conekta.js, y crea una Suscripción que los une. El acceso lo manejas con webhooks de suscripción y cargo verificados con RSA: otorgas en charge.paid, revocas en subscription.canceled y haces dunning en past_due.
Por qué el cobro recurrente es harina de otro costal
Con esto cierro la trilogía de Conekta. Primero armamos el pago único con tarjeta, OXXO y SPEI, donde aprendiste a verificar la firma RSA del webhook. Ahora vamos por lo que de verdad rompe negocios en México: los cobros recurrentes.
El cambio mental es brutal y casi nadie lo dice de frente. En un pago único confirmas un estatus una vez y se acabó: el cliente pagó, le das acceso, fin. En una suscripción no tomas una sola decisión: tomas la misma decisión cada ciclo de facturación, para siempre. Cada mes Conekta intenta cobrar, y cada mes tu sistema decide si el cliente sigue dentro o se queda fuera.
Los docs te enseñan el camino feliz: crea un Plan, crea un Customer, crea una Subscription, listo. Lo que glosan por encima es el camino de la falla — las tarjetas que se vencen, los declines del banco, el dunning, la sincronización de cancelaciones. Ahí es donde este post se gana el lugar.
Mi opinión, después de meter esto en producción para clientes en México: lo que tumba a los negocios de suscripción no es el timbrado. Es perder silenciosamente la sincronización de revocación de acceso cuando una tarjeta muere. El cliente dejó de pagar hace tres meses y tú sigues dándole el producto gratis, o peor, lo cancelaron en tu app y tú le seguiste cobrando. Eso es lo que vamos a blindar.
Si vienes del mundo de Mercado Pago, esto es el equivalente recurrente de las suscripciones con preapproval de MP — misma forma, distinto SDK.
Paso 1 — Crea un Plan (monto, intervalo, trial, reintentos)
El Plan es la plantilla recurrente. Define el monto fijo, la moneda (MXN), el intervalo/frecuencia con el que se cobra y, opcionalmente, un periodo de prueba. Todos tus suscriptores se cobran contra esa plantilla. Un plan, muchas suscripciones.
El gotcha número uno: los montos van en la unidad mínima de la moneda, es decir, centavos. Un plan de 299 MXN no es amount: 299, es amount: 29900. Si te equivocas aquí, le cobras a tu cliente 2.99 pesos o 29,900 pesos, y ninguna de las dos te va bien.
Y aquí va el dato que el contenido de marketing de los PAC nunca te da: la cadencia de reintentos del cobro recurrente se configura en el propio Plan, vía max_retries y retry_delay_hours. Tú defines cuántas veces y cada cuántas horas Conekta reintenta una tarjeta que falló, antes de reaccionar a los eventos.
// Ilustrativo — confirma los nombres de campo en developers.conekta.com
import { Configuration, PlansApi } from 'conekta';
const config = new Configuration({ accessToken: process.env.CONEKTA_PRIVATE_KEY });
const plansApi = new PlansApi(config);
const { data: plan } = await plansApi.createPlan({
name: 'Plan Pro Mensual',
amount: 29900, // 299.00 MXN en centavos
currency: 'MXN',
interval: 'month', // frecuencia del cobro
frequency: 1, // cada 1 intervalo
trial_period_days: 14, // opcional: 14 días de prueba
max_retries: 3, // reintentos antes de marcar falla definitiva
retry_delay_hours: 50 // horas entre reintentos
// omite expiry_count para un plan sin fin (es la cantidad de cobros)
});
// Guarda plan.id en tu base — es tu plan_id
Trata las etiquetas de los campos como ilustrativas: el SDK y los nombres exactos cambian entre versiones, así que cruza esto contra la referencia oficial Conekta — crea un plan antes de mandarlo a producción.
Paso 2 — Suscribe un Cliente con una tarjeta
Aquí el flujo tiene dos partes y un mandamiento que no se negocia: nunca dejes que un PAN crudo toque tu servidor. Tokeniza la tarjeta del lado del cliente con Conekta.js, y a tu backend solo le llega el token. Esto te mantiene fuera del alcance pesado de PCI y es la única forma sensata de hacerlo.
Con el token en mano, creas un Customer con esa tarjeta como payment source, y luego creas una Subscription que une el customer_id con el plan_id.
// Ilustrativo — confirma campos en la referencia de suscripciones
import { CustomersApi, SubscriptionsApi } from 'conekta';
const customersApi = new CustomersApi(config);
const subsApi = new SubscriptionsApi(config);
// 1) Customer con la tarjeta tokenizada (token de Conekta.js)
const { data: customer } = await customersApi.createCustomer({
name: 'Cesar Ayala',
email: 'cliente@example.com',
payment_sources: [{ type: 'card', token_id: cardToken }]
}, undefined, { headers: { 'X-Idempotency-Key': idemKey } });
// 2) Subscription que une customer + plan
const { data: sub } = await subsApi.createSubscription(
customer.id,
{ plan_id: plan.id },
{ headers: { 'X-Idempotency-Key': idemKey } }
);
// Guarda sub.id y sub.status keyed a tu usuario
await db.users.update(userId, {
conektaCustomerId: customer.id,
subscriptionId: sub.id,
subscriptionStatus: sub.status
});
Una suscripción nueva arranca en active — o en in_trial si el plan trae trial. Guarda el id y el status amarrados a tu usuario: ese estatus es lo que va a manejar el acceso de aquí en adelante.
Ojo con las firmas del SDK: si el método recibe solo customer_id, solo subscription_id o ambos cambia entre versiones, así que confírmalas en la referencia en vez de copiarlas a ciegas. Y usa idempotency keys en las llamadas de creación. Si tu request se reintenta por un timeout de red, no quieres suscribir dos veces al mismo cliente ni cobrarle doble. Una key por operación lógica y Conekta deduplica. Detalles en la referencia de suscripciones de Conekta.
El ciclo de vida: pausar, reanudar, cancelar
Una suscripción es una máquina de estados. En Conekta se mueve entre estatus como active, in_trial, past_due, paused y canceled — confirma el conjunto vigente en el dashboard y los docs, porque esto evoluciona.
Las operaciones que tienes encima son: cambiar el plan (upgrade/downgrade), pausar, cancelar y reanudar.
// Ilustrativo — confirma métodos y firmas en los docs
await subsApi.updateSubscription(customer.id, { plan_id: nuevoPlan.id }); // upgrade/downgrade
await subsApi.pauseSubscription(customer.id); // pausar cobros
await subsApi.resumeSubscription(customer.id); // reanudar
await subsApi.cancelSubscription(customer.id); // cancelar
La firma exacta — si recibe customer_id, subscription_id o ambos — cambia entre versiones del SDK; confírmala en la referencia de suscripciones antes de mandarlo a producción.
Lo importante no es la llamada, es mapear cada estatus a una decisión de acceso en tu app:
active/in_trial→ acceso completo.past_due→ ventana de gracia + dunning (sigue dentro, pero ya empezó la cuenta regresiva).paused/canceled→ sin acceso.
Mi opinión, y esta me ha salvado varias veces: trata el status como fuente de verdad sincronizada desde los webhooks, no como un flag que prendes optimistamente en tu propia UI. El momento en que tu app cree que alguien está active porque “le diste click a suscribir” pero Conekta dice past_due, ya perdiste. La verdad vive en Conekta; tu base la refleja.
Conectando los webhooks (y verificándolos con RSA como los docs no enseñan)
Esta es la sección técnica que importa. Suscríbete a estos eventos:
subscription.created,subscription.paused,subscription.resumed,subscription.canceled— el ciclo de vida.subscription.paid— el cobro recurrente que sí salió (tu disparador para otorgar acceso y timbrar).subscription.payment_failed— el cobro recurrente que falló (tu disparador de dunning /past_due).subscription.expiredtambién existe; confirma el set vigente en el dashboard.
Para los pagos recurrentes Conekta manda eventos que aprueban o declinan dinámicamente cada cobro. Y tu webhook siempre tiene que regresar HTTP 200, pase lo que pase del lado de tu lógica. Si truenas con un 500, Conekta va a reintentar el evento, y vas a procesar duplicados o llenar tus logs de basura. Además, para los eventos de cobro recurrente Conekta espera el 200 en una ventana muy corta (del orden de ~2 segundos — confirma el límite vigente en los docs), así que la regla es: acusa recibo con 200 rápido, procesa después.
Ahora la disciplina que ya conoces del post de pago único: verifica la firma RSA usando el header Digest contra el body crudo de la request — nunca contra el JSON re-serializado, porque JSON.stringify te reordena llaves y te cambia espacios, y la firma deja de cuadrar. El header Digest viene como la firma en base64 cruda, sin prefijo tipo clave=valor: pásalo tal cual al verificador, no le hagas split ni replace (una firma base64 puede terminar en = de padding, y recortarla la destruye). Después de verificar, haz fetch del recurso para confirmar su estatus antes de otorgar o revocar. Nunca confíes en el payload a ciegas ni en un redirect.
import express from 'express';
import crypto from 'crypto';
import { SubscriptionsApi } from 'conekta';
const app = express();
// Captura el body CRUDO — indispensable para la firma RSA
app.use('/webhooks/conekta', express.raw({ type: '*/*' }));
const CONEKTA_PUBLIC_KEY = process.env.CONEKTA_WEBHOOK_PUBLIC_KEY; // PEM RSA
app.post('/webhooks/conekta', async (req, res) => {
const rawBody = req.body; // Buffer crudo, sin parsear
const signature = req.header('Digest') || ''; // firma base64 CRUDA, sin prefijo
// 1) Verifica la firma RSA contra el body CRUDO
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(rawBody);
const sigOk = verifier.verify(CONEKTA_PUBLIC_KEY, signature, 'base64');
if (!sigOk) return res.status(401).send('firma inválida');
// 2) Responde 200 cuanto antes (ventana corta en eventos recurrentes)
res.status(200).send('ok');
// 3) Procesa: NO confíes en el payload, haz fetch para confirmar
const event = JSON.parse(rawBody.toString());
try {
switch (event.type) {
case 'subscription.paid': {
// El objeto subscription ya trae customer + subscription
const sub = event.data.object;
const { data: fresh } = await new SubscriptionsApi(config)
.getSubscription(sub.customer_id);
if (fresh.status === 'active') {
await grantAccessByCustomer(sub.customer_id);
await timbrarCFDI(sub); // dispara el PAC en el cobro confirmado
}
break;
}
case 'subscription.payment_failed': {
const sub = event.data.object;
await startDunningByCustomer(sub.customer_id); // past_due → dunning
break;
}
case 'subscription.canceled':
case 'subscription.paused': {
const sub = event.data.object;
await revokeAccessByCustomer(sub.customer_id); // corta acceso
break;
}
case 'subscription.resumed':
case 'subscription.created': {
const sub = event.data.object;
await syncStatusByCustomer(sub.customer_id, sub.status);
break;
}
}
} catch (err) {
console.error('procesando webhook Conekta', err); // ya respondiste 200
}
});
Fíjate en el orden: verificas, respondes 200, luego procesas en background. Y nota que para el caso recurrente engancho el acceso al evento subscription.paid (que ya trae customer + subscription) en vez de derivarlo de un order — es más directo. Confirma el shape exacto del objeto (dónde vive el customer_id, cuál es el campo de status) en los docs. La lista completa de eventos está en eventos de webhooks de Conekta.
- Crea el PlanMonto en centavos, MXN, intervalo, trial y reintentos (max_retries / retry_delay_hours)
- Tokeniza + crea CustomerConekta.js del lado cliente; payment source con el token
- Crea la SubscriptionUne customer + plan con idempotency key
- Levanta el endpoint de webhooksCaptura el body crudo; responde 200 en una ventana corta (~2s)
- Verifica la firma RSAHeader Digest crudo (base64, sin prefijo) contra el body crudo, nunca el JSON re-serializado
- Mapea status a accesoactive/in_trial dentro; past_due gracia; paused/canceled fuera
- Dunning en payment_failedsubscription.payment_failed dispara: pide actualizar tarjeta, limita acceso, escala
- Sincroniza cancelacionesEn ambas direcciones: app↔Conekta deben coincidir
Lo que los docs no cuentan: pagos fallidos, dunning y sincronizar cancelaciones
Aquí está el diferenciador, lo que separa una integración de juguete de una de producción.
Los cobros recurrentes con tarjeta van a fallar. No es una posibilidad, es una certeza estadística: tarjetas vencidas, fondos insuficientes, declines del banco emisor. Si tienes 200 suscriptores, cada mes un puñado va a tronar. El evento subscription.payment_failed (y la caída a past_due) es tu disparador de dunning.
Cuando un cobro recurrente falla, no mates el acceso de golpe. Haz esto:
- Pide al cliente actualizar su tarjeta — email, banner en la app, lo que sea. La mayoría de las fallas son tarjetas vencidas de gente que sí quiere pagar.
- Limita, no necesariamente cortes, el acceso durante una ventana de gracia. Cortar de tajo a un buen cliente por un decline temporal del banco es la forma más rápida de perderlo.
- Escala conforme pasan los días sin que se resuelva, hasta cortar.
Algo crítico sobre los reintentos: tú defines la cadencia en el Plan con max_retries y retry_delay_hours, pero no asumas el calendario desde tu código de dunning — constrúyelo alrededor de los eventos que realmente recibes (subscription.payment_failed, y el subscription.paid que confirma la recuperación), no de un schedule que imaginaste. Configura la cadencia en el plan, reacciona a los eventos. Si la misma disciplina de dunning te interesa a fondo, la desmenucé en recuperar pagos fallidos y dunning — los principios transfieren directo a Conekta.
Y el que más revenue leak causa: sincroniza las cancelaciones en ambas direcciones.
- Si el cliente cancela (desde Conekta o desde su banco), el evento
subscription.canceledtiene que revocar el acceso en tu app. - Si el cliente cancela en tu app, tu app tiene que llamar a Conekta para detener los cobros.
La sincronización de una sola vía es exactamente cómo terminas cobrándole a alguien que ya se fue — o regalándole el producto a alguien que dejó de pagar. Mi opinión sin filtro: este gap de dunning y cancel-sync es donde la mayoría de las integraciones de suscripción en México fugan dinero silenciosamente o enojan a clientes que ya se iban. No es glamoroso, pero es lo que decide si tu negocio recurrente sobrevive.
Suscripciones Conekta vs preapproval de Mercado Pago: cuándo usar cada uno
Las dos opciones mainstream para cobros recurrentes en LATAM son Conekta Subscriptions (plan + customer + subscription) y Mercado Pago preapproval. La buena noticia: siguen la misma forma — una plantilla recurrente, un pagador con un instrumento guardado, y webhooks manejando el acceso. La disciplina que aprendiste aquí transfiere entre las dos.
La decisión real es práctica, no religiosa: elige según los rieles que ya tienes. Si ya procesas tarjetas, OXXO y SPEI en Conekta para tu operación en México, quedarte en Conekta Subscriptions te evita una segunda integración y una segunda superficie de reconciliación. Cada pasarela extra es otro set de webhooks, otro dashboard, otra conciliación contable. No multipliques eso sin razón.
Nota vendor-neutral: valida el pricing actual, los intervalos soportados y el comportamiento de reintentos de ambas contra sus docs, como están en 2026, antes de comprometerte. Si todavía estás eligiendo pasarela de cero, comparé las tres en Stripe vs Mercado Pago vs Conekta.
Conekta Subscriptions
- Modelo: Plan + Customer + Subscription
- Webhooks: subscription.* + subscription.paid/payment_failed
- Verificación: firma RSA (Digest crudo) contra body crudo
- Encaja si: ya procesas tarjeta/OXXO/SPEI en Conekta en MX
Mercado Pago preapproval
- Modelo: preapproval (plantilla + pagador autorizado)
- Webhooks: notificaciones de preapproval + payment
- Verificación: validación de firma del webhook de MP
- Encaja si: ya vives en el ecosistema Mercado Pago
Preguntas frecuentes: trials, timbrado y prorrateo
¿Tengo que timbrar cada cobro recurrente?
Sí. Timbra el CFDI por cada cobro exitoso vía un PAC — Facturapi, Facturama o Fiscalapi como opciones, muestra el patrón —, disparado desde el webhook verificado de subscription.paid, y guarda el XML timbrado. No timbres en el momento de crear la suscripción; timbra cuando el cargo se confirma. Las reglas fiscales son sensibles al tiempo y yo soy ingeniero, no contador: confirma las reglas vigentes del SAT como están en 2026 con un contador.
¿Qué pasa durante un trial?
La suscripción se queda en in_trial y no se dispara ningún cargo hasta que el trial termina. Gatea el acceso sobre ese estatus: in_trial da acceso, pero no esperes un subscription.paid ni un CFDI hasta que el periodo de prueba acabe y salga el primer cobro real.
¿Cómo manejo upgrades/downgrades a mitad de ciclo? Actualiza el plan en la suscripción. Pero confirma el comportamiento de prorrateo de Conekta en los docs en lugar de asumirlo — distintos proveedores prorratean distinto, y aquí es muy fácil cobrar de más o de menos sin darte cuenta.
¿Puedo confiar en el payload del webhook directo? No. Verifica con RSA contra el body crudo y haz fetch del recurso para confirmar su estatus antes de actuar. El payload te dice “algo pasó”; el fetch te dice “qué pasó de verdad”. Esa es la diferencia entre una integración que aguanta producción y una que te despierta a las 3 de la mañana.
Con esto cierras la trilogía de Conekta de punta a punta. Si quieres más patrones de pagos para México, todo está en integraciones.