
Automatiza el Complemento de Pago (REP) desde Stripe y Mercado Pago — y la trampa de cancelación de la RMF 2026
Si facturas como PPD, no puedes timbrar el REP al emitir la factura — solo cuando cae el pago, así que tu webhook de pasarela es el disparador fiscal. En Stripe reaccionas a invoice.payment_succeeded; en Mercado Pago re-consultas el pago completo en payment.updated. Arma el nodo Pago 2.0 y timbra en tu PAC antes del día 5 del mes siguiente.
Por qué el webhook de pago — y no la factura — es tu disparador fiscal
Aquí va la realidad operativa antes de cualquier definición: el REP no lo puedes timbrar cuando emites la factura. No tienes manera. Cuando facturas como PPD (Pago en Parcialidades o Diferido) todavía no sabes cuándo te van a pagar, cuánto, ni en cuántas exhibiciones. Esa información solo existe cuando el dinero cae en tu pasarela. Por eso el disparador fiscal del REP no es tu sistema de facturación: es tu webhook de Stripe o de Mercado Pago.
El REP — Complemento para Recepción de Pagos, el recibo electrónico de pago — solo se emite para CFDI timbrados como PPD. Nunca para PUE (Pago en Una sola Exhibición). Si facturaste PUE, ya cobraste fiscalmente en ese mismo CFDI y no hay REP que emitir. Esa distinción es la que define todo el pipeline: tu código tiene que mirar el CFDI que la factura liquida, ver si es PPD, y solo entonces armar el complemento cuando llega el pago.
Trabajamos con Complemento de Pagos 2.0. Fija la versión contra el Anexo 20 del SAT al momento de construir, porque los nombres de campo y la estructura son exactos y no perdonan.
El plazo es duro: tienes hasta el día 5 natural del mes siguiente al mes en que recibiste el pago. Un pago que cae el 31 te deja muy poco margen. Y un detalle que ayuda: un solo REP puede cubrir varias facturas PPD y varios pagos, lo que más adelante te sirve para limpiar eventos que el webhook se haya saltado.
Soy ingeniero, no contador. Lo que viene es el pipeline — la parte que las pasarelas y los PAC documentan a medias. Los casos de borde regulatorios y los plazos vigentes confírmalos con tu contador. Si todavía no tienes resuelto el lado de emitir el CFDI de ingreso que este REP liquida, ese patrón está en facturación CFDI automática con Stripe y Mercado Pago, y todo el contexto fiscal vive en el hub de CFDI y facturación.
Stripe: timbra el REP en invoice.payment_succeeded
En Stripe el flujo es relativamente directo. Cuando una factura PPD recibe un pago, dispara invoice.payment_succeeded (o un evento de payment/charge, según cómo estructures el cobro). Ese es el evento al que te enganchas.
Antes de confiar en cualquier cosa, verifica la firma del webhook. Nunca timbres sobre un evento sin validar — el detalle completo de cómo lo hago está en verificar la firma del webhook de Stripe.
Del objeto de pago de Stripe sacas lo que alimenta el nodo Pago: la fecha del pago (FechaPago), el monto (Monto), la moneda (MonedaP) y la forma de pago (FormaDePagoP). Lo que Stripe no te da es el UUID del CFDI PPD relacionado — ese mapeo lo tienes que haber guardado tú cuando timbraste la factura PPD original. Por eso el consejo es: en el momento que timbras el PPD, persiste la relación Stripe↔UUID del CFDI. Sin ese puente no puedes resolver el IdDocumento del DoctoRelacionado.
// ILUSTRATIVO — verifica nombres de campo vs SAT Anexo 20 y tu PAC.
// Handler de Stripe: timbra el REP cuando cae el pago de un PPD.
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function handleStripeWebhook(req, res) {
let event;
try {
// Verifica la firma ANTES de confiar en el evento
event = stripe.webhooks.constructEvent(
req.rawBody,
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Firma invalida: ${err.message}`);
}
if (event.type !== "invoice.payment_succeeded") {
return res.json({ received: true });
}
const invoice = event.data.object;
// OJO: en API >= 2025 el Invoice ya NO trae payment_intent de nivel
// superior; vive en invoice.payments[].payment.payment_intent. Resuelve
// la llave de idempotencia de forma robusta segun tu version de API.
const pagoId =
invoice.payment_intent ??
invoice.payments?.data?.[0]?.payment?.payment_intent ??
invoice.id;
// Idempotencia: si ya timbramos este pago, no lo repitas
if (await repYaTimbrado(pagoId)) {
return res.json({ received: true, duplicado: true });
}
// Resuelve el CFDI PPD relacionado (mapeo guardado al timbrar el PPD)
const cfdiPpd = await buscarCfdiPpdPorStripe(invoice.id);
if (!cfdiPpd || cfdiPpd.metodoPago !== "PPD") {
return res.json({ received: true, sinRep: true });
}
const pago = {
FechaPago: new Date(invoice.status_transitions.paid_at * 1000),
FormaDePagoP: mapearFormaDePago(invoice), // p.ej. "03" transferencia
MonedaP: invoice.currency.toUpperCase(), // "MXN"
Monto: invoice.amount_paid / 100,
// + TipoCambioP cuando MonedaP !== "MXN" (frecuente en cobros USD por Stripe)
};
const docto = construirDoctoRelacionado(cfdiPpd, pago.Monto);
await timbrarRepEnPac({ pago, doctos: [docto] }); // especifico del PAC
await guardarRep({ pagoId, uuidPpd: cfdiPpd.uuid });
return res.json({ received: true });
}
Mercado Pago: re-consulta el pago completo, luego emite el REP
Esta es la pieza que casi nadie cubre. El webhook de Mercado Pago es flaco a propósito: en payment.updated te llega únicamente action=payment.updated, type=payment y data.id. No te manda el pago completo. No traes ni monto, ni estatus, ni fecha. Con eso solo no puedes armar el nodo Pago.
Entonces el patrón obligatorio es: valida el header x-signature, y luego re-consulta el pago completo contra la API de Mercado Pago usando data.id. Hasta ese momento ya tienes estatus, monto, moneda y fecha. Y solo actúas cuando status === "approved" — un pago pendiente o rechazado no genera REP.
Este patrón de webhook + re-fetch es el mismo que detallo en cómo integrar Mercado Pago; aquí lo conectamos al timbrado.
// ILUSTRATIVO — valida x-signature y re-consulta antes de timbrar.
// Handler de Mercado Pago: el webhook es flaco, hay que re-fetch.
import crypto from "node:crypto";
export async function handleMpWebhook(req, res) {
// 1) Valida x-signature: manifest = id:<data.id>;request-id:<x-request-id>;ts:<ts>;
// -> HMAC-SHA256 con el secret, comparar contra v1 (formato ts=...,v1=...)
if (!validarXSignature(req)) {
return res.status(401).send("x-signature invalida");
}
const { type, data } = req.body;
if (type !== "payment") return res.json({ received: true });
const pagoId = data.id; // clave de idempotencia
if (await repYaTimbrado(pagoId)) {
return res.json({ received: true, duplicado: true });
}
// 2) RE-CONSULTA el pago completo: el webhook NO lo trae
const resp = await fetch(`https://api.mercadopago.com/v1/payments/${data.id}`, {
headers: { Authorization: `Bearer ${process.env.MP_ACCESS_TOKEN}` },
});
const mp = await resp.json();
// 3) Solo actua sobre pagos aprobados
if (mp.status !== "approved") return res.json({ received: true });
const cfdiPpd = await buscarCfdiPpdPorMp(mp.external_reference);
if (!cfdiPpd || cfdiPpd.metodoPago !== "PPD") {
return res.json({ received: true, sinRep: true });
}
const pago = {
FechaPago: new Date(mp.date_approved),
FormaDePagoP: mapearFormaDePagoMp(mp.payment_type_id),
MonedaP: mp.currency_id, // "MXN"
Monto: mp.transaction_amount,
// + TipoCambioP cuando MonedaP !== "MXN"
};
const docto = construirDoctoRelacionado(cfdiPpd, pago.Monto);
await timbrarRepEnPac({ pago, doctos: [docto] });
await guardarRep({ pagoId, uuidPpd: cfdiPpd.uuid });
return res.json({ received: true });
}
Armando el nodo Pago 2.0: parcialidades y los saldos corrientes
Aquí está el detalle a nivel de campo que los ejemplos de código de los contadores se saltan. El nodo Pago carga FechaPago, FormaDePagoP, MonedaP (más TipoCambioP si la moneda no es MXN) y Monto. Ese es el resumen del pago en sí.
Debajo del Pago va uno o más DoctoRelacionado, uno por cada factura PPD que ese pago liquida. Cada DoctoRelacionado carga el UUID de la factura relacionada (IdDocumento), su Serie/Folio, MonedaDR, NumParcialidad, y el trío de saldos: ImpSaldoAnt, ImpPagado, ImpSaldoInsoluto. Más los traslados/retenciones de IVA del pago cuando apliquen.
La parte que rompe la primera implementación de todos son las parcialidades. No es un valor fijo: tienes que llevar la cuenta corriente a lo largo de los pagos sucesivos sobre la misma factura. En cada pago, ImpSaldoAnt es el ImpSaldoInsoluto que dejó el pago anterior; ImpPagado es lo que cae en este pago; e ImpSaldoInsoluto es la resta. Y NumParcialidad sube de uno en uno.
// ILUSTRATIVO — la aritmetica de parcialidades.
// Confirma nombres/caso de campo vs SAT Anexo 20 + tu PAC.
function construirDoctoRelacionado(cfdiPpd, impPagado) {
const numParcialidad = cfdiPpd.parcialidadesPagadas + 1;
const impSaldoAnt = cfdiPpd.saldoInsoluto; // saldo que dejo el pago previo
const impSaldoInsoluto = +(impSaldoAnt - impPagado).toFixed(2);
return {
IdDocumento: cfdiPpd.uuid, // UUID del CFDI PPD
Serie: cfdiPpd.serie,
Folio: cfdiPpd.folio,
MonedaDR: cfdiPpd.moneda, // "MXN"
NumParcialidad: numParcialidad,
ImpSaldoAnt: impSaldoAnt,
ImpPagado: impPagado,
ImpSaldoInsoluto: impSaldoInsoluto,
// ...traslados / retenciones de IVA del pago cuando apliquen
};
}
Multi-moneda: incluye TipoCambioP siempre que MonedaP no sea MXN. Si cobras en USD por Stripe, ese tipo de cambio va en el nodo Pago, no a nivel documento. Y otra vez: confirma los nombres y el casing exactos, y que estás en Complemento de Pagos 2.0, contra el Anexo 20 y tu PAC. Estos nombres los pongo de memoria del build; valídalos en el tuyo.
Las partes difíciles: idempotencia, el plazo del día 5 y un cron de respaldo
Los webhooks se reintentan. Stripe y Mercado Pago reentregan el mismo evento si tu endpoint tarda o devuelve un error transitorio. Si timbras a ciegas, terminas con dos REP para el mismo pago — un problema fiscal real. La defensa es idempotencia: llave en el id del pago (payment_intent en Stripe, data.id en Mercado Pago). Antes de timbrar, checas si ya existe un REP para ese id; si existe, devuelves 200 y no haces nada.
El plazo no perdona: día 5 natural del mes siguiente. Un webhook que se pierde un 31 a las 23:50 — porque tu servicio estaba caído, porque el reintento agotó sus intentos — es un REP que se te puede pasar. No puedes depender solo del webhook.
La red de seguridad es un cron. Programa un job mensual (córrelo antes del día 5, con margen — yo lo corro el 2 o el 3) que escanee todos los pagos aprobados que no tienen un REP timbrado y los timbre. Como un solo REP puede agrupar varias facturas y varios pagos, este cron también te sirve para limpiar de forma eficiente los eventos que el webhook se saltó.
Y guarda cada REP timbrado relacionándolo de vuelta con el id de pago y con el UUID del PPD. Esa tabla es tu fuente de verdad para la conciliación y para responder la pregunta “¿este pago ya tiene su REP?”.
- Detecta el pago PPDIdempotente en el id de pago; solo si el CFDI relacionado es PPD
- Arma Pagos 2.0Nodo Pago + DoctoRelacionado con NumParcialidad y los saldos corrientes
- Timbra antes del día 5Día 5 natural del mes siguiente al pago — plazo duro
- Cron de respaldoJob mensual que escanea pagos aprobados sin REP y los timbra
- Diseña la cancelaciónAlrededor de la ventana de aceptación de 3 días hábiles de la RMF 2026
La trampa de cancelación de la RMF 2026: un REP ya no se deshace unilateralmente
Aquí va la trampa de cierre, y es importante para cómo diseñas tu flujo de corrección. La RMF 2026 — publicada en el DOF el 28 de diciembre de 2025, en vigor desde el 1 de enero de 2026 — modificó la regla 2.7.1.35. El resultado: un CFDI con un REP queda expresamente excluido de la facilidad de “cancelar sin aceptación hasta $1,000”.
Antes, un comprobante de monto bajo lo cancelabas unilateralmente y listo. Con un REP eso ya no aplica. Cancelar un REP siempre pasa por aceptación del receptor vía Buzón Tributario: el estatus cambia a “En proceso de cancelación”, se genera un acuse, el SAT notifica al receptor, y este tiene 3 días hábiles para responder.
La afirmativa ficta sigue vigente: si el receptor no responde en esos 3 días hábiles, se da por aceptada y el estatus pasa a “Cancelado por plazo vencido”. Pero el punto de diseño es claro: un REP equivocado ya no se revierte al instante. Tu flujo de corrección tiene que vivir con esa ventana de aceptación de 3 días hábiles. Si tu lógica de “timbré mal, lo cancelo y re-timbro en segundos” asumía cancelación unilateral, hay que rediseñarla. Para el lado del egreso — reembolsos y notas de crédito — el patrón está en CFDI de egreso, reembolsos y notas de crédito.
Fuente sobre la cancelación de REP en 2026: te-cinco.
Antes (hasta 2025)
- CFDI de monto bajo se cancelaba sin aceptación hasta $1,000
- Cancelación unilateral, prácticamente instantánea
- Corregir un REP equivocado: cancelar y re-timbrar al momento
Con la RMF 2026
- Un CFDI con REP queda EXCLUIDO de cancelar sin aceptación hasta $1,000
- Siempre pasa por aceptación del receptor vía Buzón Tributario
- Estatus En proceso de cancelación + acuse; receptor tiene 3 días hábiles
- Afirmativa ficta: sin respuesta en 3 días hábiles → Cancelado por plazo vencido
Preguntas frecuentes
¿Emito un REP para una factura PUE? No. El REP solo aplica a facturas PPD. Una PUE ya quedó cobrada fiscalmente en su propio CFDI y nunca lleva REP.
¿Qué versión uso? Complemento de Pagos 2.0. Confírmala contra el Anexo 20 del SAT al momento de construir.
¿Por qué hay que re-consultar en Mercado Pago? Porque el webhook solo trae data.id, action y type — no el pago completo. Re-consultas el pago vía la API para obtener monto, estatus y fecha antes de poder armar el nodo Pago.
¿Cuándo vence el REP? A más tardar el día 5 natural del mes siguiente al mes en que recibiste el pago.
¿Un solo REP puede cubrir varias facturas? Sí. Un REP puede cubrir múltiples facturas PPD y múltiples pagos — útil para el cron de respaldo.
¿Todavía puedo cancelar un REP de menos de $1,000 sin aceptación? No. La RMF 2026 excluye los CFDI con REP de esa facilidad. Siempre va por aceptación del receptor vía Buzón Tributario, con afirmativa ficta a los 3 días hábiles.
Móntalo — luego confírmalo con tu contador
El resumen operativo es uno: el webhook de la pasarela es tu disparador fiscal. En Stripe reaccionas a invoice.payment_succeeded; en Mercado Pago validas el x-signature y re-consultas el pago completo en payment.updated. Armas el nodo Pago 2.0 con sus parcialidades y saldos corrientes, timbras en tu PAC antes del día 5 del mes siguiente, y proteges todo con idempotencia y un cron de respaldo. Es un pipeline construible hoy.
Fija al momento del build: la versión Complemento de Pagos 2.0, los nombres exactos de los eventos de Stripe y Mercado Pago, y las reglas del SAT — incluida la trampa de cancelación de la RMF 2026.
Y la línea honesta de siempre: soy ingeniero, no contador. Los casos de borde regulatorios, los plazos vigentes y la interpretación fiscal fina déjaselos a tu contador o a tu equipo de cumplimiento. Trata todas las reglas como sensibles al tiempo: al 2026, confirma lo vigente.