
Don't Cancel the CFDI — Issue a Nota de Crédito: Automating the CFDI de Egreso for Stripe & Mercado Pago Refunds
No. To refund a payment in Mexico you generally don't cancel the original ingreso CFDI — you issue a CFDI de Egreso (nota de crédito): TipoDeComprobante "E", CfdiRelacionados with TipoRelacion "01" pointing to the original UUID, UsoCFDI "G02", for the refunded amount plus its IVA. Automate it off the verified refund webhook, idempotent on the refund id. I'm an engineer, not a contador — confirm current SAT rules.
You refunded a payment — do you cancel the CFDI? (No.)
No — don’t cancel the CFDI. A refund doesn’t erase the sale, so issue a CFDI de Egreso (a nota de crédito) with TipoDeComprobante “E”, related to the original via CfdiRelacionados / TipoRelacion “01”, UsoCFDI “G02”. The original ingreso stays on record; the Egreso reverses the amount, full or partial.
You just refunded a customer in Stripe or Mercado Pago, and your first instinct is to go cancel the CFDI you stamped for that sale. Stop. That’s the wrong move, and it’s the single most common mistake I see MX builders make. Canceling a CFDI is a separate fiscal flow with its own SAT rules and time limits — it says the sale never happened. A refund is not that. The income was validly reported; you’re reducing it, not erasing it.
A CFDI de Egreso (TipoDeComprobante = “E”, the comprobante type that marks this as an Egreso) is the document you issue to reduce income already reported in a prior ingreso CFDI. It covers refunds (devoluciones), post-invoice discounts (bonificaciones), and corrections.
Here’s the framing for this whole post. The accountant and PAC (Proveedor Autorizado de Certificación — your authorized stamping provider) blogs explain what a nota de crédito is. This one shows how to build it — automatically, off a verified refund webhook. That’s the part the contador blogs skip. You already auto-stamp the ingreso CFDI on the sale (auto-invoicing CFDI from Stripe & Mercado Pago); this is that same pipeline run in reverse.
One disclaimer up front, and I mean it: I’m an engineer, not a contador. The cancel-vs-Egreso decision, the time limits, and the exact IVA (Impuesto al Valor Agregado — Mexico’s VAT) treatment are SAT and contador territory. Everything here is accurate as of 2026 — confirm the current rules with yours before you ship.
What exactly is a CFDI de Egreso? (the nota de crédito, in fields)
Strip away the accounting vocabulary and a nota de crédito is just a handful of fields an engineer sets. Here are the ones that matter, with exact claves (the SAT catalog codes).
TipoDeComprobante= “E” (Egreso). This is what makes it a nota de crédito instead of an ingreso ("I"). Get this one wrong and you’ve stamped another sale, not a reversal.CfdiRelacionados— the node that points back to the original ingreso CFDI by its UUID (the folio fiscal, the unique stamp id). It carriesTipoRelacion= “01” (“Nota de crédito de los documentos relacionados”). You can relate multiple original UUIDs in a single Egreso.UsoCFDI= “G02” (Devoluciones, descuentos o bonificaciones) — the receptor’s declared use of the document.- Amount: the refunded portion plus its mirrored IVA from the original. A full refund mirrors the whole amount; a partial refund mirrors only the refunded slice.
One 2026 rule to respect: when TipoRelacion is “01” or “02”, the related documents must not be type “T” (Traslado), “P” (Pago) or “N” (Nómina). Relate the ingreso "I", not a complemento de pago.
For the authoritative field definitions, work from the SAT — Anexo 20 (Guía de llenado del CFDI), and cross-check your PAC’s credit-note docs — for example Facturapi (Credit Note / CFDI Egreso) or Facturama (CFDI 4.0 Egreso / Nota de Crédito). I stay vendor-neutral on PACs; pick yours and confirm the exact field names.
Egreso vs cancel: when does each one apply?
Two fiscal paths, two very different mechanics. Knowing which is which is what keeps you out of trouble.
A CFDI de Egreso / nota de crédito keeps the original ingreso on record and reverses the amount — fully or partially — through a related document. This is the normal path once the sale’s CFDI already exists and you’re reducing the income.
Canceling the original CFDI is a separate, time-limited flow with its own SAT rules. It erases the original rather than reversing it. Different mechanics, different constraints, and you generally can’t reach for it just because a refund came in.
Why is the Egreso usually right for refunds? Because the income was validly reported and you’re reducing it, not pretending the sale never occurred. Partial refunds make this obvious — you can’t “half-cancel” a CFDI, but you can absolutely issue an Egreso for the refunded portion.
CFDI de Egreso (nota de crédito)
- Keeps the original ingreso on record
- Reverses the amount — full or partial
- Related via CfdiRelacionados, TipoRelacion "01"
- Normal path for a refund after the sale CFDI exists
- Supports multiple Egresos over time
Cancel the original CFDI
- Erases the original instead of reversing it
- Separate, time-limited SAT flow
- Its own rules + constraints
- Can't "half-cancel" for a partial refund
- Not the default for a refund
The honest boundary: the cancel-vs-Egreso choice, the time limits, and the exact IVA treatment are contador and SAT territory. I’m showing the engineering path, not making the fiscal call. (Chargebacks and the refund-before-CFDI case are their own thing — I’ll get to both.)
Catching the refund: the webhook (and verifying the signature first)
The trigger is the processor’s refund event — not a redirect, not a manual report, not someone pinging you in Slack. In Stripe that’s charge.refunded (or a refund object); in Mercado Pago it’s the refund/chargeback notification.
And before you act on a single byte of that payload: verify the webhook signature. A refund webhook that mints fiscal documents is exactly the kind of endpoint an attacker would love to forge. Never trust an unverified payload — same discipline you used when stamping the ingreso. If you’ve ever hit a Stripe webhook signature verification failure, you already know this drill.
From a verified event, pull four things: the refund id (your idempotency key), the original payment/charge id, the refunded amount, and whether it’s partial or full.
// ILLUSTRATIVE — Stripe refund webhook. Confirm event names + fields with Stripe docs.
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function handleStripeWebhook(req, res) {
let event;
try {
// Verify the signature BEFORE doing anything with the body.
event = stripe.webhooks.constructEvent(
req.rawBody, // raw bytes, not parsed JSON
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`signature verification failed: ${err.message}`);
}
if (event.type === "charge.refunded") {
// On current API versions `charge.refunds` may NOT be auto-expanded on the
// event payload — re-fetch the charge (or read a refund-scoped event) to be safe.
let charge = event.data.object;
if (!charge.refunds?.data?.length) {
charge = await stripe.charges.retrieve(charge.id, { expand: ["refunds"] });
}
// Pick the newest refund by created — data[] ordering is not guaranteed.
const refunds = charge.refunds?.data ?? [];
const refund = refunds.sort((a, b) => b.created - a.created)[0];
if (!refund) return res.json({ received: true }); // nothing to act on
await onRefund({
refundId: refund.id, // idempotency key
chargeId: charge.id, // links back to the sale
amountRefunded: refund.amount, // minor units (centavos)
currency: charge.currency,
fullyRefunded: charge.amount_refunded === charge.amount,
});
}
res.json({ received: true });
}
// ILLUSTRATIVE — Mercado Pago: refund/chargeback notification.
// CAVEAT: the `type` values below and EVERY refund field name (amount, payment_id,
// currency_id, payment_total) are PLACEHOLDERS. MP's real notification uses
// topic/resource values and a different payload shape — map these to MP's actual
// webhook schema, validate the signature, then fetch the payment + its refunds.
export async function handleMercadoPagoWebhook(req, res) {
// ... validate the notification first (per Mercado Pago's signing scheme) ...
const { type, data } = req.body;
if (type === "refund" || type === "chargeback") {
const refund = await mpFetchRefund(data.id); // your MP client
const payment = await mpFetchPayment(refund.payment_id); // for the full total
await onRefund({
refundId: String(refund.id),
chargeId: String(refund.payment_id),
amountRefunded: Math.round(refund.amount * 100),
currency: payment.currency_id,
fullyRefunded: refund.amount >= payment.transaction_amount,
});
}
res.json({ received: true });
}
A verified refund event now flows into one place: resolve which ingreso CFDI it reverses.
Resolving the original UUID and building the Egreso
This is the core build. You stored the original ingreso CFDI’s UUID against the payment when you timbró (stamped) the sale — this is why you persist UUID-against-payment in the first place. Look it up, then assemble the Egreso with the exact claves from earlier.
// ILLUSTRATIVE — resolve original UUID, build Egreso, timbrar via PAC.
// Field names are illustrative — confirm against your PAC + the SAT Anexo 20.
async function onRefund({ refundId, chargeId, amountRefunded, currency, fullyRefunded }) {
// 1) Idempotency: a re-delivered webhook must NOT double-issue.
if (await egresoExistsForRefund(refundId)) return;
// 2) Resolve the original ingreso CFDI you stored when you stamped the sale.
const sale = await getSaleByCharge(chargeId); // { uuid, subtotal, iva, ... }
if (!sale?.uuid) {
// Refund before the sale CFDI was ever issued — see the edge case below.
return await flagRefundBeforeCFDI(refundId, chargeId);
}
// 3) Split the refunded amount into base + mirrored IVA.
// ASSUMPTION: this treats the refunded amount as GROSS, IVA-inclusive, at a
// single 16% rate. A real Egreso must mirror the ORIGINAL CFDI's actual
// per-line tax breakdown — mixed rates, exempt items, or retenciones won't be
// a flat 1/1.16 split. Defer those cases to your contador.
const refundedTotal = amountRefunded / 100;
const base = +(refundedTotal / 1.16).toFixed(2);
const iva = +(refundedTotal - base).toFixed(2);
// 4) Guard: never let cumulative Egresos exceed the original CFDI total.
await assertWithinOriginalTotal(sale.uuid, refundedTotal);
// 5) Build the CFDI de Egreso payload.
const egreso = {
TipoDeComprobante: "E", // Egreso = nota de crédito
CfdiRelacionados: [
{ TipoRelacion: "01", CfdiRelacionado: [{ UUID: sale.uuid }] },
],
Receptor: { UsoCFDI: "G02", Rfc: sale.receptorRfc },
conceptos: [
{
descripcion: "Devolución / nota de crédito",
valorUnitario: base,
importe: base,
impuestos: { traslados: [{ impuesto: "002", tasaOCuota: 0.16, importe: iva }] },
},
],
};
// 6) Timbrar via your PAC — same plumbing as the ingreso CFDI.
const stamped = await pac.timbrar(egreso); // -> { uuid, xml, pdf }
// 7) Store the Egreso UUID against the payment AND the original CFDI.
await storeEgreso({
refundId,
chargeId,
originalUuid: sale.uuid,
egresoUuid: stamped.uuid,
amount: refundedTotal,
});
}
Timbrar through your PAC is the same call you already make for the ingreso — you get back stamped XML, a PDF, and the Egreso UUID. Store that UUID against the payment and against the original CFDI, so reconciliation and any future partials can find it.
Partial refunds, idempotency, and not double-issuing
The happy path is easy. These three gotchas are what separate a builder’s guide from an accountant’s.
Partial refunds. Issue an Egreso for just the refunded portion. You can issue multiple Egresos against one ingreso over time, up to the original total. Track the running reversed total so you never exceed it, and mirror IVA on each refunded portion — not on the whole original.
Idempotency. A re-delivered webhook must never mint a second Egreso for the same refund. Key on the refund id: check-then-issue atomically, and store the Egreso UUID under that refund id. Processors retry webhooks; without this, one refund quietly becomes two notas de crédito and your books drift.
Reconcile partial vs full. Sum the issued Egresos against the original and refuse to cross the original total. onRefund calls assertWithinOriginalTotal before it stamps — that’s the running-total guard wired into the build path, not just described.
// ILLUSTRATIVE — atomic idempotency + running-total guard.
async function egresoExistsForRefund(refundId) {
// Unique constraint on refund_id makes the issue idempotent at the DB layer.
const row = await db.egresos.findByRefundId(refundId);
return Boolean(row);
}
async function assertWithinOriginalTotal(originalUuid, addAmount) {
const sale = await getSaleByUuid(originalUuid);
const already = await sumEgresosForOriginal(originalUuid);
if (already + addAmount > sale.total + 0.005) {
throw new Error("refunds exceed original CFDI total — investigate, don't issue");
}
}
- Detect the refund webhookidempotent, keyed on the refund id
- Resolve the original UUIDstored when you timbró the sale
- Build the Egresorefunded amount + its mirrored IVA
- Timbrar via your PACget back the Egreso UUID + XML/PDF
- Reconcile partial vs fullsum Egresos, never exceed the original total
Chargebacks and the refund-before-CFDI edge case
Two related-but-distinct cases worth handling explicitly — and honestly.
Chargebacks / contracargos. A chargeback is the bank pulling the funds back, not a voluntary refund you initiated. The money-movement side is its own beast — see Stripe disputes & chargebacks with Radar. But the fiscal side is generally handled the same way: a CFDI de Egreso related to the original. Generally — confirm the treatment with your contador, because the funds movement and the dispute lifecycle differ from a clean refund.
Refund before the CFDI was ever issued. If the refund lands before you stamped the sale’s ingreso CFDI, there may be nothing to reverse — you may simply not issue the ingreso at all. In the handler above, that’s the flagRefundBeforeCFDI branch: I don’t stamp anything, I flag it. Whether you skip the ingreso is a fiscal call — confirm with your contador.
Both follow the same principle: engineer flags it, contador decides it. I show where the branch lives in code; the fiscal ruling isn’t mine to make. And SAT rules evolve — everything here is accurate as of 2026, so verify the current rules. (If you’re building payment plumbing for Mexico, the marketplace tax-withholding rules for 2026 are another SAT obligation worth wiring in.)
FAQ: refunds and the CFDI de Egreso
Do I cancel the original CFDI when I refund? Generally no. Issue a CFDI de Egreso related to it. Canceling is a separate, time-limited flow — ask your contador before you reach for it.
Which claves do I set? TipoDeComprobante “E”, CfdiRelacionados with TipoRelacion “01” pointing at the original UUID, UsoCFDI “G02”, and the refunded amount plus its mirrored IVA.
Can one Egreso relate multiple original CFDIs? Yes — you can list multiple original UUIDs in a single Egreso’s CfdiRelacionados.
Two partial refunds — two Egresos? Yes. You can issue multiple Egresos against one ingreso over time, up to the original total.
Does a re-delivered webhook issue two Egresos? Not if you’re idempotent on the refund id. Key on it, check-then-issue atomically.
Is a chargeback the same fiscally? Generally handled with an Egreso too, but the bank pulls the funds — confirm the treatment with your contador.
What if I refunded before issuing the sale CFDI? You may simply not issue the ingreso — nothing to reverse. Confirm with your contador.
Wiring the reverse direction
The Egreso is your ingreso pipeline run backwards: a verified refund webhook → resolve the original UUID → build with the exact claves ("E", TipoRelacion “01”, UsoCFDI “G02”) → timbrar via your PAC → store and relate it back. Same plumbing, opposite direction.
What makes it production-safe isn’t the happy path — it’s the idempotency on the refund id and the partial-refund accounting that keeps your running reversed total honest against the original. Skip those and you’ll double-issue notas de crédito the first time a processor retries a webhook.
I’ll say it one more time because in regulated fiscal territory it’s the whole point: I’m an engineer, not a contador. Confirm the cancel-vs-Egreso call, the time limits, and the IVA treatment with yours, and verify the current SAT rules — they move. For the full picture of CFDI and e-invoicing, the Mexico CFDI invoicing hub ties it together.
This reverse-direction fiscal plumbing — verified webhooks, idempotent Egresos, partial-refund reconciliation — is exactly the kind of thing I ship in production for MX clients at Nixbly.