
Auto-Invoice CFDI 4.0 the Moment a Payment Lands: A Developer's Guide to Stripe & Mercado Pago in Mexico (2026)
You can't legally stamp a CFDI yourself — you go through a PAC. On a verified payment webhook (never the redirect), call your PAC's API with the receptor's tax data to timbrar the CFDI 4.0, get back the stamped XML, PDF, and UUID, then email it and store the XML. No fiscal data? Issue público en general with RFC XAXX010101000.
Why does every payments tutorial stop before the factura?
In Mexico, a completed payment isn’t legally finished — it also needs a CFDI 4.0, the SAT-mandated electronic invoice (the factura). You assemble an XML, a PAC stamps it (timbrado) to add the SAT seal plus a UUID (folio fiscal), then you store the stamped XML and deliver it to the customer. Trigger the stamping off the verified payment webhook, never the browser redirect.
Now the narrative. Open any Stripe tutorial written outside Mexico and it ends at the same place: payment_intent.succeeded, a green checkmark, maybe a receipt email. Money landed, ship it. But if your customer is in Mexico, the transaction isn’t done. The SAT (Servicio de Administración Tributaria, Mexico’s tax authority) expects that second document, and your customer will absolutely ask for it.
A CFDI is an XML carrying the transaction data that has to be validated and TIMBRADO — stamped, meaning it gets the SAT’s digital seal plus a UUID. The gringo tutorial skips it because it’s Mexico-specific. The Mexican marketing content covers it, but usually as a wall around someone’s product. This guide is neither. I’ve wired real CFDI into production at Nixbly and FinHOA, and I’m going to show you the engineer’s flow and the decisions that matter, vendor-neutral, with code.
This is the fiscal layer that completes the “accept payments in Mexico” pillar. You’ve got the gateway (Stripe or Mercado Pago), you’ve got splits and subscriptions — now the factura.
One honest disclaimer before we touch code: I’m an engineer, not a contador. I can show you the integration pattern. For fiscal edge cases — weird régimenes, retenciones, your specific tax situation — defer to your accountant, and confirm the current SAT rules, because they evolve. Everything below is accurate as of 2026; verify against your PAC’s live docs before you ship.
Why can’t you stamp a CFDI yourself? (PAC + CSD, explained for engineers)
Here’s the non-obvious part of the architecture: you cannot stamp a CFDI directly with the SAT. There’s no public SAT endpoint where you POST an XML and get a stamped invoice back. Timbrado legally happens through a PAC — a Proveedor Autorizado de Certificación, a SAT-authorized certification provider. The PAC validates your XML, the SAT seals it, you get the UUID. That’s the only road.
To stamp under your own RFC, you need your CSD — the Certificado de Sello Digital, your SAT-issued digital-seal credentials: a .cer file, a .key file, and a password. The good news for builders: the developer-first PAC APIs can store and manage your CSD for you, so you’re not shipping private keys around your own infrastructure.
The vendor-neutral menu of REST APIs that wrap a PAC: Facturapi (very dev-first, multi-RFC, manages your CSD; roughly $0.60–$1.50 MXN per factura with no monthly fee, as of 2026 — confirm current), Facturama (REST API plus libs for PHP/.NET/Java/JS/Ruby), Fiscalapi (SDKs for C#/Python/JS/PHP/Java/Go), and Factura.com / Timbrify / Enlace Fiscal as additional PAC-backed REST APIs. There are also no-code auto-invoicing layers (e.g. gigstack) if you’d rather not write the integration. Pick by SDK fit and docs quality — the pattern below is portable across all of them.
What actually goes inside a CFDI 4.0, so you know what you’re assembling:
- emisor — you: your RFC and your régimen fiscal.
- receptor — your customer: RFC, razón social (legal name), código postal, régimen fiscal, and Uso del CFDI (what they’ll use the invoice for).
- conceptos — line items, each with a
ClaveProdServ(SAT product/service key) andClaveUnidad(unit key). - impuestos — taxes, normally IVA at 16%.
- tipo de comprobante
"I"(Ingreso), plus forma de pago and método de pago.
One quietly valuable feature: many PAC APIs will also validate the receptor’s RFC + régimen + código postal against the SAT — and against the 69-B “lista negra” (the SAT’s list of taxpayers flagged for simulated operations) — before they let you stamp. Use it.
How do you stamp a CFDI from a verified Stripe or Mercado Pago webhook?
The single most important discipline in this whole flow: trigger invoicing off the verified, signature-checked webhook — never the browser redirect. The redirect (?status=success) is a URL a user can fabricate, bookmark, or simply never reach if they close the tab. The webhook, with its signature verified, is the only event that actually means “money moved.” If you stamp off the redirect, you’ll mint facturas for payments that never happened.
So step one is always: verify the signature. (If Stripe’s verification is throwing on you, I wrote up the exact causes.)
import Stripe from "stripe";
import Facturapi from "facturapi"; // illustrative PAC client
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const pac = new Facturapi(process.env.FACTURAPI_KEY);
export async function handler(req, res) {
let event;
try {
// VERIFY first — raw body + signature header. Never trust the redirect.
event = stripe.webhooks.constructEvent(
req.rawBody,
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Signature check failed: ${err.message}`);
}
if (event.type !== "payment_intent.succeeded") return res.status(200).end();
const pi = event.data.object;
// Idempotency: a webhook can fire twice. Don't double-stamp.
if (await alreadyInvoiced(pi.id)) return res.status(200).end();
const tax = await getCustomerTaxData(pi.metadata.customer_id); // RFC, etc.
// Field names below are ILLUSTRATIVE — check your PAC's docs for the exact shape.
const cfdi = await pac.invoices.create({
customer: {
legal_name: tax.razonSocial,
tax_id: tax.rfc, // receptor RFC
tax_system: tax.regimenFiscal, // e.g. "601"
address: { zip: tax.codigoPostal },
},
use: tax.usoCfdi, // e.g. "G03" Gastos en general
items: [{
quantity: 1,
product: {
description: "Plan Pro - mensual",
product_key: "81112101", // ClaveProdServ (SAT)
unit_key: "E48", // ClaveUnidad (Unidad de servicio)
price: pi.amount / 100, // IVA 16% applied by the PAC
tax_included: true,
},
}],
payment_form: "04", // forma de pago (SAT c_FormaPago): 04 = Tarjeta de crédito
payment_method: "PUE", // método de pago (SAT c_MetodoPago) — PUE/PPD, see below
});
await persistInvoice(pi.id, cfdi); // store the UUID + XML, key by payment id
await emailInvoice(tax.email, cfdi); // XML + PDF
return res.status(200).end(); // ack fast
}
Note the two distinct fields: payment_form maps to the SAT’s c_FormaPago (how they paid — 04 is credit card) and payment_method maps to c_MetodoPago (PUE vs PPD). Different concepts; don’t conflate forma with método.
Mercado Pago is the same fiscal flow — only the verification and payload differ. You verify the payment.updated notification, fetch the payment to confirm status === "approved", then call the exact same PAC code. (Choosing between gateways? See payment gateways in Mexico.)
Two operational notes that will save you a 3am page: make the handler idempotent (key on the payment id, as above — webhooks retry), and return 200 fast. If timbrado is slow or your PAC hiccups, push the stamping onto a queue and ack the webhook immediately, otherwise the gateway will retry and you’ll get duplicates.
Auto-invoice pipeline: payment to delivered factura
What if the customer gives no fiscal data? (público en general vs the factura global)
Reality check: most customers don’t hand you an RFC at the moment they pay. You can’t block the checkout waiting for tax data. So you have a few patterns, and you’ll probably ship more than one.
When the customer gives you no fiscal data, you issue to PÚBLICO EN GENERAL using the generic RFC XAXX010101000. The SAT is strict about the exact values here, and getting them wrong is a common rejection:
ReceptorNombremust be exactlyPUBLICO EN GENERAL— uppercase, no accents.- régimen fiscal
616(Sin obligaciones fiscales). - Uso del CFDI
S01(Sin efectos fiscales). - público en general is PUE only — you can’t issue it as PPD.
The conventional, SAT-blessed path here is the CFDI GLOBAL (factura global), not a per-transaction stamp. Per the SAT’s Guía de llenado del CFDI global, when no customer requests a nominative factura you group the period’s público-en-general operations into a single periodic CFDI that carries an Información Global node: Periodicidad (01 Diaria, 02 Semanal, 03 Quincenal, 04 Mensual, 05 Bimestral), Meses, and Año. You typically issue it within 72 hours of the period close (confirm the current rule). You can stamp an individual CFDI to XAXX010101000 per payment, but the global is the normal mechanism for ungrouped public sales — reach for the per-transaction individual only when you have a specific reason.
// Periodic CFDI GLOBAL — groups público-en-general sales for the period.
// Field names are ILLUSTRATIVE — check your PAC's docs for the exact shape.
const cfdiGlobal = await pac.invoices.create({
customer: {
legal_name: "PUBLICO EN GENERAL", // exact: uppercase, no accents
tax_id: "XAXX010101000", // RFC genérico nacional
tax_system: "616", // Sin obligaciones fiscales
address: { zip: process.env.EMISOR_CP }, // emisor CP for the global
},
use: "S01", // Sin efectos fiscales
global: { // Información Global node
periodicity: "04", // 04 = Mensual
months: "06", // Mes
year: 2026, // Año
},
items: [/* grouped conceptos for the period */],
payment_form: "04",
payment_method: "PUE", // público en general is always PUE
});
Then layer one of two patterns for customers who do want a nominative factura (one with their name and RFC on it):
Público en general vs nominative factura
Público en general (CFDI global)
- RFC XAXX010101000
- Name exactly 'PUBLICO EN GENERAL'
- Régimen 616 (Sin obligaciones fiscales)
- Uso S01 (Sin efectos fiscales)
- PUE only
- Información Global node: Periodicidad + Mes + Año
- Grouped + issued periodically (commonly within 72 hrs of close)
Nominative via autofactura portal
- Customer enters their real RFC
- Plus Uso del CFDI
- Plus régimen fiscal
- Plus código postal
- Validated vs their CSF before stamping
- Self-service, after they've paid
Pattern A — the autofactura portal: a self-service page where, after paying, the customer enters their RFC, Uso del CFDI, régimen, and CP to generate a nominative CFDI themselves. Pattern B: invoice público en general (via the global) by default and let customers request their nominative factura within the fiscal period.
Whichever you pick, validate the receptor’s RFC + régimen + código postal against their CSF (constancia de situación fiscal) before you stamp. A mismatch with the SAT’s records is the single most common cause of timbrado rejection. Most dev-first PACs expose a validation endpoint for exactly this — call it before invoices.create.
PUE or PPD? The complemento de pago decision SaaS builders get wrong
This is the fiscal gotcha that trips up engineers who think a factura is just an invoice. Método de pago has two values and they imply completely different amounts of work:
- PUE (Pago en Una sola Exhibición) — paid in full at the moment of issue. One CFDI de Ingreso, done. No complemento needed.
- PPD (Pago en Parcialidades o Diferido) — credit, installments, or “pay later.” You issue one CFDI de Ingreso for the total, and then a Complemento de Pago (REP — Recibo Electrónico de Pago) every single time you actually receive money. The SAT requires the complemento on receipt, not at issue.
That last bullet is the part people miss. If you mark something PPD, your job isn’t done when you stamp the first invoice — your webhook now has to fire a complemento de pago on each subsequent payment. And there’s a deadline: the REP must be stamped no later than the 5th natural day of the month following the month you received the payment (RMF 2026, Regla 2.7.1.32 — confirm the current rule with your PAC/contador). That deadline is exactly why you queue REPs rather than block on real-time stamping. Forget the recurring REP and you’re out of compliance.
PUE vs PPD: how many documents you owe the SAT
For subscriptions (say an annual plan billed monthly), you have a choice: a monthly PUE invoice per charge — simplest, and what most SaaS do — or an annual PPD invoice plus monthly complementos de pago.
My opinion, having shipped both: default to per-payment PUE unless you have a concrete reason not to. Each charge succeeds, you stamp one PUE CFDI for that charge, and you never touch complementos de pago. It’s dramatically less code and far fewer ongoing SAT obligations. Reach for PPD only when the business genuinely sells on credit.
What do you store, deliver, and do when you have to cancel?
Stamping is the middle of the story, not the end. After the UUID comes back:
Store the XML. This is the part newcomers get wrong: the legal document is the stamped XML, not the human-readable PDF. The PDF is a courtesy rendering. Persist the XML durably, keyed by UUID — that’s your source of truth in an audit.
Deliver both files. Email the customer the XML and the PDF, and surface the UUID (folio fiscal) in your dashboard and on receipts so support can find any factura fast.
Reconcile. Link every UUID back to its payment id (Stripe PaymentIntent / Mercado Pago payment) so refunds and disputes map cleanly to the right factura. You stored the XML keyed by payment id in the handler above — this is why.
Cancellations are their own SAT flow. You don’t just delete a CFDI. Cancellation requires an acuse and a motivo de cancelación, and since CFDI 4.0 the receptor may have to accept the cancellation before it takes effect. Build for that — it’s asynchronous, not a single API call that resolves instantly.
And the standing reminder: the IVA rate and the CFDI version are SAT-defined and change. Don’t hardcode assumptions you can’t easily update — confirm current rules and your PAC’s docs as of 2026.
FAQ: RFCs, timbrado failures, sandbox, and costs
Can I test without burning real timbres? Yes. The dev-first PACs give you a sandbox / test secret key. Develop against test CFDIs, validate your payloads, and only flip to your live key when the flow is solid.
What’s the most common timbrado failure? The receptor’s RFC + régimen fiscal + código postal not matching their CSF. Validate against the SAT before you call the stamp endpoint and you’ll kill most of your failures.
How much does a factura cost? Roughly $0.60–$1.50 MXN per CFDI on pay-per-stamp PACs like Facturapi, with no monthly fee (as of 2026 — confirm current pricing). At SaaS volumes that’s noise.
Do I need my own CSD? Yes — you stamp under your RFC’s CSD (.cer + .key + password). But many PAC APIs store and manage it for you, so you’re not handling raw private keys in your app.
Does the CFDI flow differ between Stripe and Mercado Pago? No. The fiscal flow — assemble, stamp, store, deliver — is identical. Only the webhook verification and the payload shape differ. Trigger off the verified event either way.
How do you ship the fiscal layer and defer the edge cases?
The whole thing in one line: verified webhook → assemble CFDI 4.0 → PAC timbra → store the XML + deliver the PDF/UUID.
Ship auto-invoicing end to end
- Set up PAC + CSDPick a dev-friendly PAC, upload your CSD (.cer/.key/password)
- Validate the receptorCheck RFC + régimen + CP against the SAT before stamping
- Stamp on the verified webhookpayment succeeds → assemble CFDI 4.0 → PAC timbra → UUID
- Deliver + storeEmail XML + PDF; persist the XML keyed by UUID as your source of truth
Default to per-payment PUE, group público-en-general sales into a periodic CFDI global, validate the RFC before you stamp, and treat the stamped XML as your source of truth. The pattern is portable, so pick the PAC whose SDK and docs fit you.
And again: I’m an engineer, not a contador. For cancellations, PPD complementos, and régimen edge cases, loop in your accountant and confirm the current SAT rules. Build the layer; defer the fiscal judgment calls.
Official sources worth bookmarking: the SAT, the Facturapi docs, and the Facturama API reference.