
Conekta Subscriptions: Recurring Payments Done Right — Plans, Webhooks & Dunning
Conekta does recurring billing in three steps: create a Plan (amount, interval, optional trial), create a Customer with a card source via Conekta.js, then create a Subscription linking them. You drive access off RSA-verified subscription and charge webhooks — grant on charge.paid, revoke on subscription.canceled, and dun on past_due.
How Conekta subscriptions differ from one-time charges
A one-time charge is a single decision: the customer pays, you confirm the charge, you grant access, done. A subscription is a decision your webhook handler re-makes every billing cycle, forever, for every subscriber. The real risk isn’t timbrado — it’s access and billing drifting out of sync when a card silently dies mid-renewal.
This is the close of a trilogy. In the one-time Conekta post I wired cards, OXXO, and SPEI, and RSA-verified the webhook. That flow is one decision and you find out within minutes whether it cleared. A subscription is the same machinery applied on a loop — and that loop is where the failure modes live.
That shift is the whole point. With a subscription, the card you charged successfully in March silently expires in July, the renewal declines, and unless your handler reacts to that event, you’ve got a churned customer still holding access — or worse, a paying customer you accidentally locked out. The docs walk you cleanly through creating a plan, a customer, and a subscription. They gloss the failure path: declines, dunning, cancel-sync. That gap is where this post earns its keep, and it sits right next to the Mercado Pago subscriptions post in the LATAM recurring story.
Opinion, and I’ll say it plainly: the thing that breaks subscription businesses in Mexico is not timbrado. Timbrado is a solved, mechanical problem you trigger off a webhook. What breaks them is silently losing access-revocation sync when a card dies — you keep granting, or you keep billing, and nobody notices until the support ticket or the chargeback.
Step 1 — Create a Plan (amount, interval, trial)
A Plan is the recurring template. It defines the fixed amount, the currency (MXN), the interval and frequency, and an optional trial. Every subscriber you attach gets charged against this template — so a “299 MXN monthly” plan is one Plan object that a thousand customers can share.
The one gotcha that bites everyone on day one: amounts are in the smallest currency unit — centavos. A 299 MXN plan is amount: 29900, not 299. Get this wrong and you’ll charge someone 2.99 pesos or 29,900 pesos, and you won’t notice in test mode.
Conekta’s plan object also carries max_retries and retry_delay_hours — Conekta’s own retry behavior for failed recurring charges. Worth setting deliberately rather than leaving to defaults, because it shapes your dunning window (more on that below). Note the asymmetry in this post: the Plan fields below are verified-exact, but the Subscription operation signatures further down are illustrative — confirm those against the current SDK reference.
// Illustrative — confirm exact field names in the Conekta docs.
import { Configuration, PlansApi } from 'conekta';
const config = new Configuration({ accessToken: process.env.CONEKTA_PRIVATE_KEY });
const plans = new PlansApi(config);
const { data: plan } = await plans.createPlan({
id: 'plan-pro-mensual',
name: 'Pro Mensual',
amount: 29900, // centavos -> 299.00 MXN
currency: 'MXN',
interval: 'month', // week | month | year
frequency: 1, // every 1 interval
trial_period_days: 7, // optional
// expiry_count omitted -> indefinite
max_retries: 3,
retry_delay_hours: 24,
});
Even with max_retries set, whether any given retry succeeds depends on the issuer — so treat these values as the shape of your dunning window, not a guarantee of recovery. Reference: Conekta — crea un plan.
Step 2 — Subscribe a Customer with a card source
Two parts, in order. First, never let a raw PAN touch your server — tokenize the card client-side with Conekta.js and send only the token. This keeps you out of the worst of PCI scope and is non-negotiable. Second, create a Customer with that token as a payment source, then create a Subscription linking the customer to the plan.
These subscription operations are customer-scoped — they take the customer id, and the single-subscription ops also take the subscription id. The exact method names and signatures (single subscription vs list) must be confirmed in the SDK reference; the names below reflect the current Conekta Node SDK.
// Illustrative — confirm exact method names in the Conekta docs.
import { Configuration, CustomersApi, SubscriptionsApi } from 'conekta';
const config = new Configuration({ accessToken: process.env.CONEKTA_PRIVATE_KEY });
const customers = new CustomersApi(config);
const subs = new SubscriptionsApi(config);
// 1) Customer with the card token from Conekta.js
const { data: customer } = await customers.createCustomer(
{
name: 'Cesar Ayala',
email: 'cesar@example.mx',
payment_sources: [{ type: 'card', token_id: req.body.conektaTokenId }],
},
undefined,
{ headers: { 'Idempotency-Key': `cust-${myUserId}` } }
);
// 2) Subscription linking customer -> plan (customer-scoped op)
const { data: subscription } = await subs.subscriptionCreate(
customer.id,
{ plan_id: 'plan-pro-mensual' }
);
// Persist subscription.id + subscription.status keyed to your user.
await db.users.update(myUserId, {
conektaCustomerId: customer.id,
conektaSubscriptionId: subscription.id,
subscriptionStatus: subscription.status, // active | in_trial
});
A new subscription starts active, or in_trial if the plan has a trial. Store the subscription id and status keyed to your user — that local record is what your access checks read between webhooks. And use an idempotency key on the create calls: a retried request over a flaky mobile connection should never double-subscribe a customer.
Reference: Conekta — suscripciones.
The subscription lifecycle: pause, resume, cancel
A subscription is a small state machine. Conekta subscriptions move through statuses like in_trial, active, past_due, paused, and canceled — confirm the current exact set in the dashboard and docs, since these evolve. The operations you drive are: change/update the plan (upgrade/downgrade), pause, cancel, and resume. These ops are customer-scoped and the single-subscription ones take both the customer id and the subscription id — confirm the exact signatures in the SDK reference.
// Illustrative operations — confirm method names + signatures in the Conekta docs.
await subs.subscriptionCancel(customerId, subscriptionId); // stops future charges
await subs.subscriptionPause(customerId, subscriptionId); // suspend, keep the record
await subs.subscriptionResume(customerId, subscriptionId); // un-pause
await subs.subscriptionUpdate(customerId, subscriptionId, { plan_id: 'plan-pro-anual' }); // up/downgrade
The discipline is mapping each status to an access decision in your app:
active/in_trial→ grant accesspast_due→ grace window + dunning (don’t kill access yet)paused/canceled→ no access
Opinion: treat the subscription status as the source of truth synced from webhooks, not a flag you flip optimistically in your own UI. If a user clicks “cancel” in your dashboard, don’t immediately set them to no-access locally and call it done — call Conekta to cancel, then let the subscription.canceled webhook be the thing that revokes. Optimistic local flips drift out of sync with reality, and reality is what gets charged.
- Stand up the webhook endpointCapture the RAW body; always return HTTP 200
- RSA-verify every eventDigest header vs raw body, then fetch-to-confirm
- Map status to accessactive/in_trial grant; past_due dun; paused/canceled revoke
- Build dunning on past_duePrompt card update, limit access, escalate
- Sync cancellations both waysApp cancel calls Conekta; canceled event revokes
Wiring the webhooks (and RSA-verifying them like the docs don’t)
This is the core technical section. Subscribe to subscription.created, subscription.paused, subscription.resumed, and subscription.canceled, plus the recurring charge/order events — charge.paid and order.paid. For recurring payments Conekta sends events to dynamically approve or decline, and your webhook must always return HTTP 200 — otherwise Conekta keeps retrying and you drown in duplicates.
Now the part the docs underplay. Verify Conekta’s RSA signature using the Digest header against the RAW request body — the exact bytes Conekta sent, never a re-stringified JSON object. If you let your framework parse the body to JSON and then re-serialize it for verification, key ordering and whitespace shift and the signature fails. Capture the raw buffer. Then, even after the signature passes, fetch the resource from Conekta to confirm its current status before you grant or revoke. Never trust a redirect, and never trust the payload’s status field as gospel — the resource is the truth. This is the same discipline from the one-time post, applied per cycle.
import express from 'express';
import crypto from 'crypto';
import { Configuration, SubscriptionsApi, OrdersApi } from 'conekta';
const app = express();
const config = new Configuration({ accessToken: process.env.CONEKTA_PRIVATE_KEY });
// Capture the RAW body for signature verification.
app.post('/webhooks/conekta', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body; // Buffer — the exact bytes Conekta sent
// Verify RSA signature: Digest header vs RAW body, using Conekta's public key.
const digest = req.header('digest') || '';
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(rawBody);
const ok = verifier.verify(
process.env.CONEKTA_WEBHOOK_PUBLIC_KEY, // from your Conekta dashboard
Buffer.from(digest, 'base64')
);
if (!ok) return res.status(401).send('bad signature');
// ALWAYS 200 so Conekta stops retrying — then process async.
res.status(200).send('ok');
const event = JSON.parse(rawBody.toString('utf8'));
handleEvent(event).catch((err) => console.error('webhook handler', err));
});
async function handleEvent(event) {
// Illustrative getters — confirm exact method names in the Conekta SDK reference.
const subs = new SubscriptionsApi(config);
const orders = new OrdersApi(config);
switch (event.type) {
case 'subscription.canceled':
case 'subscription.paused': {
// Fetch the SINGLE subscription by id — not the list — then confirm status.
const { customerId, id } = subscriptionRef(event);
const { data: sub } = await subs.subscriptionsGet(customerId, id);
if (sub?.status === 'canceled' || sub?.status === 'paused') {
await limitOrRevokeAccess(event, sub.status);
}
break;
}
case 'subscription.created':
case 'subscription.resumed': {
// Re-read the subscription before granting — don't trust the payload.
const { customerId, id } = subscriptionRef(event);
const { data: sub } = await subs.subscriptionsGet(customerId, id);
if (sub?.status === 'active' || sub?.status === 'in_trial') {
await grantAccess(event);
}
break;
}
case 'charge.paid':
case 'order.paid': {
// Re-read the order before granting — confirm it's actually paid.
const orderId = event?.data?.object?.order_id || event?.data?.object?.id;
const { data: order } = await orders.getOrderById(orderId);
if (order?.payment_status === 'paid') await grantAccess(event);
break;
}
default:
break; // ignore the rest, still 200
}
}
Reference: Conekta — eventos webhooks.
The part the docs skip: failed payments, dunning, and cancel-sync
Recurring card charges WILL fail. Cards expire, balances run dry, issuers decline cross-border or flag a renewal as suspicious. This isn’t an edge case — at any real scale, a measurable slice of your renewals fail every single month. A subscription that goes past_due is your dunning trigger, and how you handle that window is the difference between recovering revenue and bleeding it.
When a recurring charge fails: prompt the customer to update their card (email plus an in-app banner), limit access rather than immediately killing it during a grace window, and escalate as the window closes. Conekta retries on its own according to the max_retries and retry_delay_hours you set on the plan — but the realized cadence still depends on issuer behavior, and a configured retry doesn’t guarantee a successful one. Confirm the exact cadence in the dashboard and docs rather than assuming a schedule, and build your dunning around the events you actually receive, not a retry timeline you imagined. The mechanics here are the same ones I covered for Stripe dunning; only the event names change.
The trap that quietly bills people who left is one-way cancel-sync. It has to go both ways. If the customer cancels in their bank or you cancel from your dashboard, the subscription.canceled event must revoke access. And a cancellation initiated in your app must call Conekta to actually stop the charges — flipping a local flag without calling the API means Conekta keeps charging a card for a service the user thinks they cancelled. That’s a chargeback and a one-star review waiting to happen.
Opinion: this reasoned dunning gap is where most Mexican subscription integrations silently leak revenue or infuriate churned customers. It’s unglamorous plumbing, it’s not in the quickstart, and it’s exactly what separates a real integration from a demo.
Conekta Subscriptions vs Mercado Pago preapproval: when each fits
The two mainstream LATAM recurring options are Conekta Subscriptions (plan + customer + subscription) and Mercado Pago preapproval. Both follow the same shape: a recurring template, a payer with a saved instrument, and webhooks driving access. The discipline transfers cleanly — if you’ve internalized RSA-verify, fetch-to-confirm, and cancel-sync on one, you’ll port it to the other in an afternoon.
Choose on your existing rails. If you already process cards, OXXO, and SPEI on Conekta in Mexico, staying on Conekta Subscriptions avoids a second integration, a second webhook signature scheme, and a second reconciliation surface. If your one-time flow already lives on Mercado Pago, its preapproval keeps you on one set of books. The integration you don’t have to maintain twice is always the cheaper one.
Vendor-neutral note: validate current pricing, supported intervals, and retry behavior for both against their docs as of 2026 before committing — these change, and the right choice depends on numbers that aren’t static. I compared the gateways more broadly in payment gateways in Mexico, and there are more guides under /latam-payments/.
Conekta Subscriptions
- Model: Plan + Customer + Subscription
- Card source tokenized via Conekta.js
- Webhooks: subscription.* + charge.paid / order.paid
- RSA signature on the raw body
- Best if you already run cards/OXXO/SPEI on Conekta
Mercado Pago preapproval
- Model: preapproval plan + preapproval
- Saved card instrument on the payer
- Webhooks drive access the same way
- Own signature/secret scheme
- Best if your one-time flow already lives on MP
FAQ: trials, timbrado, and proration
Do I timbrar each recurring charge? Yes. Stamp the CFDI per successful charge through a PAC — Facturapi, Facturama, or Fiscalapi are options; show the pattern, pick your vendor — triggered off the verified charge.paid or order.paid webhook, and store the stamped XML. I’m an engineer, not a contador: confirm current SAT rules and your facturación obligations with a contador, since the fiscal edge cases (RFC genérico, público en general, periodicity) are theirs to rule on.
What happens during a trial? The subscription sits in in_trial and no charge fires until the trial ends. Gate access on that status like any other — in_trial grants, and the first real charge.paid confirms conversion.
How do I handle upgrades/downgrades mid-cycle? Update the plan on the subscription. Don’t assume how Conekta prorates the partial period — confirm its proration behavior in the docs rather than guessing, because billing the wrong delta erodes trust fast.
Can I trust the webhook payload directly? No. RSA-verify against the raw body and fetch-to-confirm the resource before you act on it. The payload tells you something happened; the resource tells you what’s actually true now. Act on the second one.