
How to Stamp a CFDI de Nómina 4.0 (timbrado) in 2026
In 2026 a CFDI de Nómina is a CFDI 4.0 (TipoDeComprobante="N") carrying complemento nomina12 version 1.2 Revisión E, mandatory since January 1. You build the XML, send it to a PAC's stamp endpoint, and it returns the TimbreFiscalDigital UUID. The #1 bug: subsidio al empleo goes in OtrosPagos (TipoOtroPago="002"), never Percepciones.
What a CFDI de Nómina actually is in 2026
A CFDI de Nómina is a regular CFDI 4.0 with TipoDeComprobante="N" that carries the Complemento de Nómina version 1.2. The current revision is Revisión E, announced December 2, 2025, and mandatory since January 1, 2026 — so in 2026 the only valid combination is CFDI 4.0 + nómina 1.2 Revisión E. You build the XML, send it to a PAC’s stamp (timbrado) endpoint, and if it passes SAT’s matriz de errores the PAC returns the TimbreFiscalDigital with its UUID. The single most common bug engineers ship: putting subsidio al empleo in Percepciones. It does not go there — it lives in OtrosPagos with TipoOtroPago="002".
This is an engineer’s tutorial: the real XML and the validations that get rejected, not payroll accounting. The ISR and subsidio amounts come from your payroll engine or your contador. Read the engineer-not-contador caveat near the end before you ship.
The XML skeleton: Comprobante N plus the nomina12:Nomina node
At the comprobante level you set TipoDeComprobante="N" and Moneda="MXN". For nómina, the comprobante-level Impuestos node is not used — all taxes live inside the complemento. The arithmetic the PAC checks: SubTotal = total percepciones + total otros pagos, and Total = SubTotal minus total deducciones.
Inside Complemento sits nomina12:Nomina with Version="1.2", TipoNomina ("O" ordinaria or "E" extraordinaria), FechaPago, FechaInicialPago, FechaFinalPago, NumDiasPagados, and the three totals: TotalPercepciones, TotalDeducciones, TotalOtrosPagos.
<cfdi:Comprobante Version="4.0" TipoDeComprobante="N"
Moneda="MXN" SubTotal="10000.00" Descuento="1200.00" Total="8800.00"
Fecha="2026-01-15T12:00:00" LugarExpedicion="64000">
<cfdi:Emisor Rfc="EKU9003173C9" Nombre="ESCUELA KEMPER URGATE"
RegimenFiscal="601"/>
<cfdi:Receptor Rfc="XAXX010101000" Nombre="JUAN PEREZ"
DomicilioFiscalReceptor="64000" RegimenFiscalReceptor="605"
UsoCFDI="CN01"/>
<cfdi:Conceptos>
<cfdi:Concepto ClaveProdServ="84111505" Cantidad="1"
ClaveUnidad="ACT" Descripcion="Pago de nómina"
ValorUnitario="10000.00" Importe="10000.00" Descuento="1200.00"
ObjetoImp="01"/>
</cfdi:Conceptos>
<cfdi:Complemento>
<nomina12:Nomina Version="1.2" TipoNomina="O"
FechaPago="2026-01-15" FechaInicialPago="2026-01-01"
FechaFinalPago="2026-01-15" NumDiasPagados="15"
TotalPercepciones="10000.00" TotalDeducciones="1200.00"
TotalOtrosPagos="536.21">
<!-- Emisor, Receptor, Percepciones, Deducciones, OtrosPagos -->
</nomina12:Nomina>
</cfdi:Complemento>
</cfdi:Comprobante>
The complemento has its own Emisor (with RegistroPatronal) and Receptor for the worker — Curp, NumSeguridadSocial, FechaInicioRelLaboral, TipoContrato, TipoRegimen, NumEmpleado, PeriodicidadPago, SalarioBaseCotApor, SalarioDiarioIntegrado, and ClaveEntFed. Then the three amount containers.
<nomina12:Receptor Curp="PEPJ800101HNLRRN09"
NumSeguridadSocial="12345678901" FechaInicioRelLaboral="2020-03-01"
TipoContrato="01" TipoRegimen="02" NumEmpleado="0042"
PeriodicidadPago="04" SalarioBaseCotApor="500.00"
SalarioDiarioIntegrado="520.00" ClaveEntFed="NLE"/>
Percepciones, Deducciones and OtrosPagos: where each amount goes
Three buckets, and the SAT matriz de errores is strict about which one a concept belongs to.
- Percepciones — gross pay: salary, overtime, bonuses, aguinaldo, vacation premium. Each
Percepcioncarries aTipoPercepcioncode plusImporteGravadoandImporteExento. - Deducciones — withholdings: IMSS, ISR retenido (
TipoDeduccion="002"), loans, garnishments. EachDeduccioncarries aTipoDeduccioncode and anImporte. - OtrosPagos — payments that are not perceptions and not deductions. Subsidio al empleo is the canonical case (
TipoOtroPago="002"), along with viáticos reintegrados and balances in favor.
<nomina12:Percepciones TotalSueldos="10000.00"
TotalGravado="10000.00" TotalExento="0.00">
<nomina12:Percepcion TipoPercepcion="001" Clave="001"
Concepto="Sueldos" ImporteGravado="10000.00" ImporteExento="0.00"/>
</nomina12:Percepciones>
<nomina12:Deducciones TotalOtrasDeducciones="0.00"
TotalImpuestosRetenidos="1200.00">
<nomina12:Deduccion TipoDeduccion="002" Clave="002"
Concepto="ISR" Importe="1200.00"/>
</nomina12:Deducciones>
The #1 mistake: subsidio al empleo lives in OtrosPagos, not Percepciones
This is the one I see in every other implementation. Subsidio para el empleo is not a percepción. It goes in OtrosPagos as an OtroPago with TipoOtroPago="002", and that OtroPago carries a child SubsidioAlEmpleo node whose attribute SubsidioCausado reports the caused subsidy. The amount actually applied in this period goes in the OtroPago @Importe — and the applied amount is at most the caused subsidy, with any gap showing up against the ISR retenido.
<nomina12:OtrosPagos>
<nomina12:OtroPago TipoOtroPago="002" Clave="002"
Concepto="Subsidio para el empleo" Importe="536.21">
<nomina12:SubsidioAlEmpleo SubsidioCausado="536.21"/>
</nomina12:OtroPago>
</nomina12:OtrosPagos>
The reconciliation rule: when there is subsidio causado, the CFDI must consistently report the ISR retenido — a Deduccion with TipoDeduccion="002" — together with the subsidio. SAT validations reject or flag inconsistent pairings, so your payroll engine has to emit ISR and subsidio as a matched pair, not independently. If you compute subsidio but drop the ISR deduction (or vice versa), the stamp fails.
The Revisión E changes you must implement
Revisión E is effective 2026-01-01. These are the concrete validation changes to ship:
- Error 101 — the subsidio al empleo validation ceiling rose from $475.00 (2025) to $628.00 (2026). If your reported subsidio exceeds the cap, the stamp is rejected.
- Error 108 — the subsidy factor applied when
NumDiasPagadosis greater than 31 changed from 15.63 to 20.66. - A single concept can no longer report
ImporteExento="0"andImporteGravado="0"at the same time. One of them must be nonzero. - Perception code 038 (Otros ingresos por salarios) must be reported fully gravado — no exempt portion.
- New perception codes: 054 (Días de descanso trabajados) and 055 (Días de descanso obligatorio trabajados).
- New deduction codes: 108 / 109 (ajuste por días de descanso trabajados, gravado / exento) and 110 / 111 (ajuste por días de descanso obligatorio trabajados, gravado / exento).
Wire these as configuration constants tied to the revision, not as literals scattered across your serializer. When Revisión F lands you want to change one config file, not grep the codebase.
The timbrado pipeline via a PAC
The end-to-end flow is the same regardless of PAC: build the XML, sign it, stamp it, persist the result, deliver it, and handle corrections.
Build the CFDI 4.0 XML with the nómina complemento, sign with your CSD, then send it to the PAC’s stamp endpoint. The PAC validates against SAT’s matriz de errores and, if valid, returns the TimbreFiscalDigital — the UUID, SelloSAT, FechaTimbrado, and NoCertificadoSAT. You persist the UUID together with the stamped XML, then deliver the XML and PDF to the worker.
import requests
def timbrar_nomina(signed_xml: str, pac_cfg: dict) -> dict:
resp = requests.post(
pac_cfg["stamp_url"],
data=signed_xml.encode("utf-8"),
headers={
"Content-Type": "application/xml",
"Authorization": f"Bearer {pac_cfg['token']}",
},
timeout=30,
)
if resp.status_code != 200:
# PAC echoes the SAT matriz de errores code + message
raise TimbradoError(resp.status_code, resp.text)
data = resp.json()
return {
"uuid": data["uuid"], # TimbreFiscalDigital UUID
"sello_sat": data["selloSAT"],
"fecha_timbrado": data["fechaTimbrado"],
"no_cert_sat": data["noCertificadoSAT"],
"xml_timbrado": data["xml"], # persist this verbatim
}
Corrections inside the same period are handled by reissuing; cancellations follow the standard CFDI cancellation rules (motivo 01 when you replace one, referencing the substitute UUID).
def cancelar_nomina(uuid: str, motivo: str, uuid_sub: str | None, pac_cfg: dict):
payload = {"uuid": uuid, "motivo": motivo} # "01" = con sustitución
if motivo == "01":
payload["folioSustitucion"] = uuid_sub # required when motivo is 01
return requests.post(
pac_cfg["cancel_url"], json=payload,
headers={"Authorization": f"Bearer {pac_cfg['token']}"},
timeout=30,
).json()
Persist the stamped XML exactly as the PAC returned it — that file, not your pre-stamp draft, is the fiscal record. The same persist-then-deliver discipline applies to the Complemento de Pago (REP) and to auto-invoicing CFDIs from Stripe or Mercado Pago.
The subsidio para el empleo 2026 numbers: config, not hardcode
The DOF decreto del subsidio para el empleo was published 31-dic-2025 and is effective 2026-01-01. The numbers your payroll engine needs:
- Monthly subsidy = monthly UMA × 15.02% for February through December.
- January is transitional at 15.59%, because the new annual UMA value takes effect February 1, 2026. The January monthly amount works out to about $536.21.
- Eligibility applies only when the monthly income used as the ISR base does not exceed $11,492.66.
- Excluded from that income test: primas de antigüedad, retiro, indemnizaciones, and other pagos por separación.
{
"subsidio_empleo_2026": {
"porcentaje_uma_feb_dic": 0.1502,
"porcentaje_uma_enero_transitorio": 0.1559,
"monto_mensual_enero_aprox": 536.21,
"tope_ingreso_mensual": 11492.66,
"excluidos_de_la_base": [
"prima_antiguedad", "retiro", "indemnizacion", "pagos_separacion"
]
}
}
Engineer-not-contador caveat — state it plainly. This post teaches the timbrado pipeline and the XML nodes and validations engineers get wrong. The ISR and subsidio amounts are produced by your payroll engine or your contador, not by this tutorial. Pull UMA, the subsidy percentages, income caps, and the ISR tables from configuration; never hardcode them; and validate against the current SAT matriz de errores, your PAC’s rules, and the RMF — because these figures change every year. The JSON above is an illustration of where the numbers live, not a source of truth for your nómina.
Pre-timbrado validation and testing checklist
Run these checks before you ever hit the PAC. Most rejections are catchable locally and every round-trip to a stamp endpoint costs you a timbre and latency.
- ArithmeticSubTotal = percepciones + otros pagos; Total = SubTotal − deducciones; complemento totals match the line items
- Subsidio placementIn OtrosPagos with TipoOtroPago 002 and a SubsidioAlEmpleo child — never in Percepciones
- ISR + subsidio pairingWhen subsidio is causado, a Deduccion TipoDeduccion 002 (ISR) is present and consistent
- Rev E rulesSubsidio ≤ $628 (err 101); factor 20.66 when días > 31 (err 108); code 038 fully gravado
- No double-zeroNo concept with ImporteExento=0 AND ImporteGravado=0 at once
- XSD + catalogsValidate against nómina 1.2 XSD; confirm codes 054/055 and 108–111 against the current catalog
Build a fixture suite that runs each rule above against known-good and known-bad XML, and keep one fixture per matriz-de-errores code you have ever been bitten by (101, 108, the double-zero rule). When SAT publishes the next revision, that suite tells you in seconds what broke.
For the rest of the CFDI surface area, the same build-validate-stamp discipline covers the factura global 24h job, CFDI de Egreso for refunds and credit notes, and the 2026 pedimento catalog update. If you are still mapping out your fiscal stack, start from the CFDI invoicing hub.
Sources
- SAT — CFDI de Nómina y Anexo 20 (guía de llenado)
- DOF — Decreto del subsidio para el empleo, 31-dic-2025
- Your PAC’s Revisión E changelog (compare its published matriz de errores against the values above — error 101 ceiling $628, error 108 factor 20.66 — before you go live).