
Automate the Complemento de Pago (REP) from Stripe & Mercado Pago — Plus the RMF 2026 Cancellation Trap
If you invoice as PPD, you can't stamp the REP at invoice time — only when the payment lands, so your gateway webhook is the fiscal trigger. On Stripe, react to invoice.payment_succeeded; on Mercado Pago, re-fetch the full payment on payment.updated. Build the Pago 2.0 node, stamp at your PAC by the 5th of the following month.
Why the payment webhook — not the invoice — is your fiscal trigger
For PPD invoices (installments or deferred), you cannot stamp the REP at invoice time — the fiscal event is the payment arriving. Your payment gateway is the only system that knows when that happens, so your Stripe or Mercado Pago webhook becomes the trigger that builds and stamps the Complemento de Pagos 2.0 at your PAC.
Here’s the distinction underneath that. The REP — the Complemento para Recepción de Pagos, the recibo electrónico de pago — only exists for invoices you stamped as PPD (Pago en Parcialidades o Diferido). It is never issued for a PUE invoice (Pago en Una sola Exhibición, paid in one go). If the client pays everything up front, you stamp PUE and you’re done. If they don’t, you stamp the ingreso CFDI as PPD — and now you owe SAT a separate REP every time money actually lands.
That’s the trap. When you create a PPD invoice you don’t yet know when the client will pay, how much, or in how many parcialidades. You can’t predict the future, so the REP cannot be built at invoice time. The fiscal event — the thing SAT actually cares about — is the payment arriving. And where does your system learn that a payment arrived? Your payment gateway. The webhook fires, you build the REP, you stamp it at your PAC.
This runs on Complemento de Pagos 2.0 — pin that version against the SAT Anexo 20 Guía de llenado at build time, because the field names and structure are exact and unforgiving. And the deadline is real: you must issue the REP no later than the 5th calendar day of the month following the month the payment was received. One small mercy — a single REP can cover multiple PPD invoices and multiple payments, which matters when you’re catching up on misses (more on that later).
One honest disclaimer before the code: I’m an engineer, not a contador. What follows is the pipeline that wires your gateway to your PAC. The regulatory edge cases — your exact tax situation, current deadlines, whether a specific operation even needs a REP — confirm those with your contador. This is the plumbing. The ingreso CFDI this REP settles is its own job, covered in auto-invoicing CFDI from Stripe & Mercado Pago.
Stripe: stamp the REP on the payment event
Stripe is the friendly case because the webhook carries the full object. When a payment lands against a Stripe Invoice that maps to a PPD CFDI, you get invoice.payment_succeeded. From that object you pull everything the Pago node needs: the payment date (FechaPago), the amount (Monto), the currency (MonedaP), and the forma de pago (FormaDePagoP).
One failure mode to call out, because it silently drops fiscal events: invoice.payment_succeeded only fires for the Stripe Invoice object. If you collect via PaymentIntent or Checkout without creating a Stripe Invoice, that event never fires — key off payment_intent.succeeded (or charge.succeeded) instead and map that to the PPD CFDI. Pick the event that matches how you actually collect, and pin it.
The one piece Stripe can’t give you is the CFDI UUID of the PPD invoice this payment settles. You have to have stored that mapping yourself — when you first stamped the PPD ingreso CFDI, you saved stripe_invoice_id → cfdi_uuid. Now you look it up to fill IdDocumento in the DoctoRelacionado.
And before you trust any of this: verify the webhook signature. An unverified webhook is an open door to forged fiscal events. If your verification is failing, see Stripe webhook signature verification failed.
// ILLUSTRATIVE — confirm field names vs SAT Anexo 20 + your PAC docs.
// Stripe handler: PPD invoice paid -> build Pago 2.0 -> stamp REP.
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function handleStripeWebhook(req, res) {
// 1. Verify signature BEFORE trusting the event.
let event;
try {
event = stripe.webhooks.constructEvent(
req.rawBody,
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`signature failed: ${err.message}`);
}
// If you collect via PaymentIntent/Checkout with no Stripe Invoice,
// this event never fires — key off payment_intent.succeeded instead.
if (event.type !== "invoice.payment_succeeded") return res.json({ ok: true });
const invoice = event.data.object;
// 2. Idempotency key MUST be a stable, non-null payment id. The exact
// field depends on your invoice flow; fall back to event.id for
// delivery de-dup. Never key on a possibly-null field.
const paymentId =
invoice.payment_intent || `${invoice.id}:${event.id}`;
if (await repAlreadyStamped(paymentId)) return res.json({ ok: true });
// 3. Resolve the PPD CFDI UUID we stored when stamping the ingreso.
const ppd = await lookupPpdCfdi(invoice.id);
if (!ppd || ppd.metodoPago !== "PPD") return res.json({ ok: true });
// 4. Build the Pago 2.0 node + DoctoRelacionado, then stamp.
// buildPago20 must format FechaPago as SAT local datetime
// (yyyy-MM-ddTHH:mm:ss, America/Mexico_City, NO timezone offset) —
// a raw UTC toISOString() with a "Z" is rejected by the PAC/SAT.
const pago = buildPago20({
paidAtEpoch: invoice.status_transitions.paid_at, // seconds; format locally
formaDePagoP: mapFormaDePago(invoice), // e.g. "03" transferencia
monedaP: invoice.currency.toUpperCase(), // "MXN"
monto: invoice.amount_paid / 100,
cfdi: ppd,
});
const rep = await stampRepAtPac(pago); // your PAC's stamp call
await persistRep({ paymentId, ppdUuid: ppd.uuid, repUuid: rep.uuid });
return res.json({ ok: true });
}
Mercado Pago: re-fetch the full payment, then emit the REP
This is the piece the bank-marketing and PAC blogs skip entirely, because it’s where the operational reality bites. The Mercado Pago webhook is thin. When a payment changes state, MP sends you a notification carrying only action=payment.updated, type=payment, and data.id. That’s it. No amount, no status, no date. You do not have enough to build a Pago node from the webhook payload alone.
So the pattern is two steps. First, validate the x-signature header — MP signs its notifications and you must verify before acting (same discipline as Stripe; the full MP webhook + re-fetch pattern is in how to integrate Mercado Pago). The footgun here: the signature manifest must use the data.id value from the query string (?data.id=...), lowercased — not whatever you destructured from the body. Read it from one canonical source and reuse it everywhere. Second, re-fetch the full payment from the MP API using that same id. Only then do you have status, transaction_amount, currency_id, and date_approved. Act only when status === "approved" — a webhook fires on many state transitions, and an authorized-but-not-captured or rejected payment is not a fiscal event.
// ILLUSTRATIVE — Mercado Pago: validate x-signature, RE-FETCH, then stamp REP.
import crypto from "crypto";
export async function handleMpWebhook(req, res) {
const { type } = req.body;
if (type !== "payment") return res.json({ ok: true });
// Canonical id: MP sends it as the ?data.id query param for payment
// notifications. Lowercase it — the manifest and re-fetch use THIS value.
const dataId = String(req.query["data.id"] || "").toLowerCase();
if (!dataId) return res.status(400).end();
// 1. Validate x-signature (ts + v1 hash over the manifest).
if (!verifyMpSignature(req, dataId)) return res.status(401).end();
// 2. RE-FETCH the full payment — the webhook only carried data.id.
const r = await fetch(`https://api.mercadopago.com/v1/payments/${dataId}`, {
headers: { Authorization: `Bearer ${process.env.MP_ACCESS_TOKEN}` },
});
const payment = await r.json();
// 3. Only "approved" is a fiscal event.
if (payment.status !== "approved") return res.json({ ok: true });
// 4. Idempotency on the payment id.
if (await repAlreadyStamped(dataId)) return res.json({ ok: true });
// 5. Map to the PPD CFDI and stamp the REP.
const ppd = await lookupPpdCfdiByMpRef(payment.external_reference);
if (!ppd || ppd.metodoPago !== "PPD") return res.json({ ok: true });
const pago = buildPago20({
// date_approved is ISO; buildPago20 reformats to SAT local datetime
// (America/Mexico_City, no offset) — not a raw UTC string with "Z".
fechaPagoIso: payment.date_approved,
formaDePagoP: mapMpFormaDePago(payment.payment_type_id),
monedaP: payment.currency_id, // "MXN"
monto: payment.transaction_amount,
cfdi: ppd,
});
const rep = await stampRepAtPac(pago);
await persistRep({ paymentId: dataId, ppdUuid: ppd.uuid, repUuid: rep.uuid });
return res.json({ ok: true });
}
function verifyMpSignature(req, dataId) {
// x-signature: "ts=...,v1=...". Manifest is
// id:{data.id};request-id:{x-request-id};ts:{ts};
// where {data.id} is the LOWERCASED query-string value, NOT the body id.
const header = req.headers["x-signature"];
if (!header) return false;
const sig = Object.fromEntries(
header.split(",").map((kv) => kv.split("="))
);
if (!sig.v1 || !sig.ts) return false;
const manifest =
`id:${dataId};request-id:${req.headers["x-request-id"]};ts:${sig.ts};`;
const hmac = crypto
.createHmac("sha256", process.env.MP_WEBHOOK_SECRET)
.update(manifest)
.digest("hex");
// timingSafeEqual THROWS on length mismatch — guard length first.
if (hmac.length !== sig.v1.length) return false;
return crypto.timingSafeEqual(Buffer.from(hmac), Buffer.from(sig.v1));
}
Building the Pago 2.0 node: parcialidades and the running saldos
Most code samples you’ll find gloss over the actual field-level work. Here’s what a Pago 2.0 node carries, and where the real bookkeeping lives.
The Pago node itself carries FechaPago, FormaDePagoP, MonedaP (plus TipoCambioP if the currency is not MXN), and Monto — the total of this payment. Then it contains one or more DoctoRelacionado nodes, one per PPD invoice this payment touches. Each DoctoRelacionado carries the related invoice UUID (IdDocumento), the Serie/Folio, MonedaDR, and the three numbers that are the whole point: NumParcialidad, ImpSaldoAnt, ImpPagado, and ImpSaldoInsoluto — plus the IVA traslados/retenciones on the payment itself.
The parcialidades are where you must keep your own running ledger across payments on the same invoice:
- NumParcialidad increments per payment against that invoice: 1, 2, 3…
- ImpSaldoAnt is the balance before this payment. On the first parcialidad it equals the invoice total; on later ones it equals the previous payment’s
ImpSaldoInsoluto. - ImpPagado is what this payment applied.
- ImpSaldoInsoluto =
ImpSaldoAnt − ImpPagado. When it hits zero, the invoice is settled.
ILLUSTRATIVE — running saldos for a $10,000 PPD invoice paid in 3 parts
Parcialidad ImpSaldoAnt ImpPagado ImpSaldoInsoluto
1 10,000.00 4,000.00 6,000.00
2 6,000.00 4,000.00 2,000.00
3 2,000.00 2,000.00 0.00
Here’s roughly what one DoctoRelacionado looks like once assembled (illustrative — confirm exact node names and casing against your PAC’s schema):
<!-- ILLUSTRATIVE — Complemento de Pagos 2.0, one Pago with one DoctoRelacionado -->
<pago20:Pago FechaPago="2026-02-15T13:40:00"
FormaDePagoP="03"
MonedaP="MXN"
Monto="4000.00">
<pago20:DoctoRelacionado
IdDocumento="A1B2C3D4-0000-1111-2222-33445566AABB"
Serie="A" Folio="1024"
MonedaDR="MXN" EquivalenciaDR="1"
NumParcialidad="1"
ImpSaldoAnt="10000.00"
ImpPagado="4000.00"
ImpSaldoInsoluto="6000.00"
ObjetoImpDR="02"/>
</pago20:Pago>
If MonedaP is anything other than MXN, you must include TipoCambioP — the exchange rate to MXN at the FechaPago. And again: confirm exact field names, casing, and that you’re on Complemento de Pagos 2.0 against SAT Anexo 20 and your PAC’s schema. The Egreso side — refunds and notas de crédito — is a different complemento entirely, covered in CFDI egreso, refunds & credit notes.
The hard parts: idempotency, the 5th-of-month deadline, and a cron fallback
The Pago node is the easy part once you’ve done it once. Reliability is the hard part, and it’s exactly what the contador blogs never mention because they’re not the ones getting paged.
Idempotency. Webhooks retry. Stripe retries on any non-2xx; Mercado Pago re-notifies. If you stamp a REP on every delivery, you’ll double-stamp — two REPs for one payment, a fiscal mess. Key every stamp on a stable, non-null payment id. Before building anything, check whether a REP already exists for that id. You saw the repAlreadyStamped(...) guard in both handlers above — that guard is non-negotiable.
The deadline is unforgiving. 5th calendar day of the following month. Picture a payment that lands at 11pm on the 31st and the webhook delivery fails — Stripe’s retry schedule could push the redelivery days out, and now you’re racing a hard fiscal cutoff. A missed webhook is not a hypothetical; it will happen.
So build a cron fallback. Run a job ahead of the 5th — give yourself margin, run it on the 2nd or 3rd — that scans every approved payment from the prior month with no stamped REP, and stamps them. This is where batching pays off: one REP can cover multiple invoices and multiple payments, so the fallback can clean up a batch of misses efficiently. And store every stamped REP, related back to both the payment id and the PPD UUID, so reconciliation (and the cron’s “what’s missing?” query) is trivial.
- Detect a PPD payment, idempotentlywebhook fires; guard on a stable payment id so retries never double-stamp
- Assemble the Pagos 2.0 nodeNumParcialidad + running ImpSaldoAnt -> ImpPagado -> ImpSaldoInsoluto
- Stamp by the 5th-of-month deadlineREP due the 5th calendar day of the following month
- Run a cron fallbacka job before the 5th scans approved payments with no REP and stamps them
- Design cancellation around acceptancea wrong REP needs the 3-business-day receiver window — plan for it
The RMF 2026 cancellation trap: a REP is no longer a unilateral undo
You’ll stamp a wrong REP eventually — wrong invoice, wrong amount, a duplicate. And in 2026 fixing it is no longer the instant operation it used to be. This one will bite teams that wrote their correction flow before this year.
The RMF 2026 — published in the DOF on 2025-12-28, in force 2026-01-01 — modified rule 2.7.1.35. A CFDI with a REP is now expressly excluded from the “cancel without acceptance up to $1,000” facility. Previously, low-value CFDIs could be cancelled unilaterally. Not anymore for anything carrying a REP.
So cancelling a REP always goes through receiver acceptance via the Buzón Tributario (the taxpayer’s electronic mailbox). The flow: status becomes “En proceso de cancelación,” an acuse is generated, SAT notifies the receiver, and the receiver has 3 business days to respond. The afirmativa ficta (deemed-acceptance rule) still applies — if they don’t respond within those 3 business days, it’s deemed accepted and the status flips to “Cancelado por plazo vencido.” For the regulatory detail, see te-cinco on REP cancellation in 2026. I’m an engineer, not a contador — treat these rules as time-sensitive: as of 2026, confirm current with your contador.
The engineering consequence: design your correction flow around the acceptance window. A wrong REP is not an instant unilateral undo. Your system needs to model the “En proceso de cancelación” state, track the acuse, and wait out (or watch for) the 3-business-day window before treating the REP as gone. Don’t build UI or reconciliation that assumes cancellation is synchronous.
Before RMF 2026
- CFDI with a REP could fall under cancel-without-acceptance up to $1,000
- Low-value cancellation was effectively unilateral / instant
- Correction flows could assume a synchronous undo
After RMF 2026 (rule 2.7.1.35)
- A CFDI WITH a REP is expressly EXCLUDED from that facility
- Cancellation ALWAYS needs receiver acceptance via Buzon Tributario
- Status -> 'En proceso de cancelacion', acuse, receiver has 3 business days
- Afirmativa ficta still auto-accepts if no response -> 'Cancelado por plazo vencido'
FAQ
Do I issue a REP for a PUE invoice? No. The REP is only for PPD invoices. A PUE invoice (paid in one exhibition) never gets a REP.
Which version do I build? Complemento de Pagos 2.0. Pin it against the SAT Anexo 20 Guía de llenado at build time.
Why do I have to re-fetch on Mercado Pago? Because the MP webhook only carries action, type, and data.id — not the amount, status, or date. You re-fetch the full payment from the MP API by data.id to get what the Pago node needs.
Which data.id goes in the MP signature manifest? The one from the query string (?data.id=...), lowercased — not the value you read off the request body. Build the manifest as id:{data.id};request-id:{x-request-id};ts:{ts};.
When is the REP due? No later than the 5th calendar day of the month following the month the payment was received.
Can one REP cover several invoices? Yes. A single REP can cover multiple PPD invoices and multiple payments — which is exactly what your cron fallback should exploit.
Can I still cancel a REP under $1,000 without acceptance? No. RMF 2026 excludes REP CFDIs from that facility. Cancellation requires receiver acceptance via the Buzón Tributario, with afirmativa ficta after 3 business days of no response.
Ship it — then check with your contador
The takeaway is simple: for PPD invoices, the payment-gateway webhook is your fiscal trigger, and the whole pipeline — detect the payment, build the Pago 2.0 node with its parcialidades and saldos, stamp the REP at your PAC, idempotent on a stable payment id, with a 5th-of-month cron fallback — is buildable today. Stripe hands you the full object; Mercado Pago makes you re-fetch. Both end at your PAC.
Pin the moving parts at build time: Complemento de Pagos 2.0, the exact Stripe and Mercado Pago event names, and the SAT rules. This whole flow is part of the broader CFDI & e-invoicing hub if you want the surrounding pieces.
And the honest line, because correctness is the value here: I’m an engineer, not a contador. I can wire your gateway to your PAC. The regulatory edge cases — your specific tax obligations, the current deadlines, whether a given operation needs a REP at all — defer those to your contador or compliance team. Treat every rule above as time-sensitive: as of 2026, confirm current.