
Cómo timbrar un CFDI de Nómina 4.0 en 2026 (guía dev)
En 2026 un CFDI de Nómina es un CFDI 4.0 (TipoDeComprobante="N") con el complemento nomina12 versión 1.2 Revisión E, obligatorio desde el 1 de enero. Construyes el XML, lo envías al endpoint de timbrado de un PAC y devuelve el UUID del TimbreFiscalDigital. El error #1: el subsidio al empleo va en OtrosPagos (TipoOtroPago="002"), nunca en Percepciones.
Qué es un CFDI de Nómina: CFDI 4.0 + nómina 1.2 Revisión E
En 2026, un CFDI de Nómina es un CFDI 4.0 con TipoDeComprobante="N" que carga el Complemento de Nómina versión 1.2, en su Revisión E (anunciada el 2 de diciembre de 2025 y obligatoria desde el 1 de enero de 2026). Construyes el XML, lo mandas al endpoint de timbrado de un PAC y, si pasa la matriz de errores del SAT, te devuelve el UUID dentro del TimbreFiscalDigital. El error que más se repite entre devs: meter el subsidio al empleo en Percepciones. No va ahí — va en OtrosPagos con TipoOtroPago="002".
Esta es una guía de ingeniero, no de contador. Aquí aprendes el pipeline de timbrado y los nodos y validaciones que los devs solemos equivocar. Los importes de ISR y subsidio los produce tu motor de nómina o tu contador; este post no calcula tu nómina por ti.
La combinación CFDI 4.0 + nómina 1.2 Revisión E es la única válida en 2026. Si tu integración seguía emitiendo con la revisión anterior, el PAC te rechaza. Si vienes del mundo CFDI general, este complemento es primo de los otros que ya automatizas desde el hub de facturación CFDI.
El esqueleto XML: Comprobante TipoDeComprobante=N y el nodo nomina12:Nomina
El Comprobante raíz lleva TipoDeComprobante="N" y Moneda="MXN". A nivel comprobante no se usa el nodo Impuestos: los impuestos viven dentro del complemento. Las relaciones aritméticas que el PAC valida son: SubTotal = total percepciones + total otros pagos, y Total = SubTotal − total deducciones.
<cfdi:Comprobante
Version="4.0"
TipoDeComprobante="N"
Moneda="MXN"
SubTotal="15000.00"
Descuento="2300.00"
Total="12700.00"
Exportacion="01"
LugarExpedicion="64000">
<cfdi:Emisor Rfc="EKU9003173C9" Nombre="EMPRESA DEMO SA DE CV" RegimenFiscal="601"/>
<cfdi:Receptor Rfc="XAXX010101000" Nombre="JUAN PEREZ"
DomicilioFiscalReceptor="64000"
RegimenFiscalReceptor="605" UsoCFDI="CN01"/>
<cfdi:Conceptos>
<cfdi:Concepto ClaveProdServ="84111505" ClaveUnidad="ACT"
Cantidad="1" Descripcion="Pago de nomina"
ValorUnitario="15000.00" Importe="15000.00"
Descuento="2300.00"/>
</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="15000.00" TotalDeducciones="2300.00"
TotalOtrosPagos="536.21">
<!-- Emisor, Receptor, Percepciones, Deducciones, OtrosPagos -->
</nomina12:Nomina>
</cfdi:Complemento>
</cfdi:Comprobante>
El nodo nomina12:Nomina lleva Version="1.2", TipoNomina ("O" ordinaria o "E" extraordinaria), FechaPago, FechaInicialPago, FechaFinalPago, NumDiasPagados, TotalPercepciones, TotalDeducciones y TotalOtrosPagos. Dentro van los hijos Emisor, Receptor, Percepciones, Deducciones y OtrosPagos.
El Receptor del complemento es el trabajador, y carga datos que no están en el Receptor del comprobante: Curp, NumSeguridadSocial, FechaInicioRelLaboral, TipoContrato, TipoRegimen, NumEmpleado, PeriodicidadPago, SalarioBaseCotApor, SalarioDiarioIntegrado y ClaveEntFed.
Percepciones, Deducciones y OtrosPagos: dónde va realmente cada importe
Tres nodos, tres propósitos distintos, y los devs los confunden seguido. En Percepciones van sueldos, horas extra, aguinaldo, prima vacacional: lo que devengó el trabajador. Cada percepción separa ImporteExento e ImporteGravado. En Deducciones van ISR retenido, IMSS, INFONAVIT, pensión alimenticia, préstamos. En OtrosPagos van conceptos que no son percepción ni deducción: el subsidio al empleo aplicado, viáticos entregados, ajustes.
<nomina12:Percepciones TotalSueldos="15000.00"
TotalGravado="13500.00" TotalExento="1500.00">
<nomina12:Percepcion TipoPercepcion="001" Clave="001"
Concepto="Sueldos, Salarios"
ImporteGravado="13500.00" ImporteExento="1500.00"/>
</nomina12:Percepciones>
<nomina12:Deducciones TotalOtrasDeducciones="500.00"
TotalImpuestosRetenidos="1800.00">
<nomina12:Deduccion TipoDeduccion="002" Clave="002"
Concepto="ISR" Importe="1800.00"/>
<nomina12:Deduccion TipoDeduccion="001" Clave="001"
Concepto="Seguridad social" Importe="500.00"/>
</nomina12:Deducciones>
Ojo con TipoDeduccion="002": ese es el ISR retenido, y va a importar mucho en la siguiente sección. La regla de Revisión E con la que vas a tropezar: para un mismo concepto no puedes reportar ImporteExento="0" y ImporteGravado="0" al mismo tiempo. Si un concepto da cero en ambos, no debería estar en el XML.
El error #1: el subsidio al empleo va en OtrosPagos, no en Percepciones
Este es el que más rechazos genera. El subsidio para el empleo NO va en Percepciones. Va en OtrosPagos como un OtroPago con TipoOtroPago="002", más un nodo hijo SubsidioAlEmpleo cuyo atributo SubsidioCausado lleva el subsidio causado. El importe efectivamente aplicado va en el atributo Importe del OtroPago.
<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>
La regla de conciliación: cuando hay subsidio causado, el CFDI debe reportar de forma consistente el ISR retenido (una Deduccion con TipoDeduccion="002") y el subsidio. Las validaciones del SAT rechazan o marcan pares inconsistentes — si declaras subsidio pero el ISR retenido no cuadra con lo que tu motor calculó, el timbrado falla. Estos dos importes salen del mismo cálculo de tu motor de nómina; tu trabajo como ingeniero es mapearlos al nodo correcto, no inventarlos.
Los cambios de Revisión E que debes implementar
Revisión E entró en vigor el 1 de enero de 2026 y trae cambios concretos en la matriz de errores que afectan tu validación previa al timbrado:
- El tope de validación del subsidio al empleo (error 101) subió de $475.00 (2025) a $628.00 (2026).
- El error 108 (factor del subsidio cuando
NumDiasPagadoses mayor a 31) se actualizó de 15.63 a 20.66. - Para un mismo concepto no puedes reportar
ImporteExento="0"eImporteGravado="0"simultáneamente. - La clave de percepción 038 (Otros ingresos por salarios) debe reportarse totalmente gravada, sin porción exenta.
- Nuevas claves de percepción: 054 (Días de descanso trabajados) y 055 (Días de descanso obligatorio trabajados).
- Nuevas claves de deducción: 108 / 109 (ajuste por días de descanso trabajados, gravado/exento) y 110 / 111 (ajuste por días de descanso obligatorio trabajados, gravado/exento).
Si tienes un mapa de claves en caché de 2025, actualízalo. Las claves 054, 055 y 108–111 no existían, y un catálogo viejo te va a rechazar percepciones legítimas de días de descanso trabajados.
El pipeline de timbrado vía un PAC
El flujo es siempre el mismo: construyes el CFDI 4.0 con el complemento de nómina, lo mandas al endpoint de timbrado del PAC, el PAC valida contra la matriz de errores del SAT y, si pasa, te devuelve el TimbreFiscalDigital con UUID, SelloSAT, FechaTimbrado y NoCertificadoSAT. Persistes el UUID y el XML timbrado, y entregas XML y PDF al trabajador.
import requests
def timbrar_nomina(xml_sellado: str, pac_token: str) -> dict:
resp = requests.post(
"https://api.tu-pac.mx/v1/cfdi/nomina/stamp",
headers={"Authorization": f"Bearer {pac_token}"},
json={"xml": xml_sellado},
timeout=30,
)
if resp.status_code != 200:
# El PAC devuelve el codigo de la matriz de errores (p.ej. 101, 108)
raise PacError(resp.json().get("errors"))
data = resp.json()
return {
"uuid": data["timbre"]["UUID"],
"fecha_timbrado": data["timbre"]["FechaTimbrado"],
"sello_sat": data["timbre"]["SelloSAT"],
"no_certificado_sat": data["timbre"]["NoCertificadoSAT"],
"xml_timbrado": data["xml"],
}
def persistir_y_entregar(empleado_id: str, resultado: dict):
db.nomina_cfdi.insert({
"empleado_id": empleado_id,
"uuid": resultado["uuid"],
"xml": resultado["xml_timbrado"],
"fecha_timbrado": resultado["fecha_timbrado"],
"estatus": "vigente",
})
enviar_xml_y_pdf(empleado_id, resultado["xml_timbrado"])
Las correcciones dentro del periodo se reexpiden (timbras un nuevo CFDI). Las cancelaciones siguen las reglas de cancelación de CFDI vigentes; guarda siempre el UUID porque lo necesitas para cancelar y para acuses. El patrón de persistir-UUID-y-entregar es idéntico al que ya usas si automatizas la facturación CFDI desde Stripe o Mercado Pago o el Complemento de Pago (REP).
- Construir XMLCFDI 4.0 TipoDeComprobante=N + nomina12:Nomina, sellado con tu CSD.
- TimbrarPOST al endpoint del PAC; valida contra la matriz de errores del SAT.
- Recibir TimbreFiscalDigitalUUID, SelloSAT, FechaTimbrado, NoCertificadoSAT.
- Persistir y entregarGuarda UUID + XML; manda XML y PDF al trabajador.
- Cancelar / reexpedirCorrecciones del periodo se reexpiden; cancelar sigue las reglas de CFDI.
Los números del subsidio para el empleo 2026: configuración, no hardcode
Aquí entra el terreno del contador y del motor de nómina, no de la estructura del CFDI. El decreto del subsidio para el empleo se publicó en el DOF el 31 de diciembre de 2025, con vigencia desde el 1 de enero de 2026. Las cifras de referencia:
- Subsidio mensual = UMA mensual × 15.02% para febrero–diciembre.
- Enero es transitorio al 15.59% (el nuevo valor de la UMA anual entra en vigor el 1 de febrero de 2026). El importe mensual de enero ronda los $536.21.
- Elegibilidad: aplica solo cuando el ingreso mensual usado como base de ISR no excede $11,492.66.
- Se excluyen de esa prueba de ingreso: primas de antigüedad, retiro, indemnizaciones y otros pagos por separación.
# config/subsidio_2026.py -- valores de configuracion, NO constantes de codigo
SUBSIDIO = {
"uma_factor_feb_dic": 0.1502, # 15.02%
"uma_factor_enero": 0.1559, # 15.59% transitorio
"monto_mensual_enero": 536.21,
"tope_ingreso_mensual": 11492.66,
"tope_validacion_pac_error_101": 628.00, # Revision E 2026
"factor_error_108": 20.66, # NumDiasPagados > 31
}
Advertencia ingeniero-no-contador, explícita: este post te enseña el pipeline de timbrado y los nodos y validaciones que los devs equivocan. Los importes de ISR y subsidio los produce tu motor de nómina o tu contador. Jala la UMA, el porcentaje de subsidio, los topes de ingreso y las tablas de ISR desde configuración; nunca los hardcodees. Y valida siempre contra la matriz de errores del SAT vigente, las reglas de tu PAC y la RMF, porque estas cifras cambian cada año. Yo soy ingeniero y sí he timbrado nómina contra un PAC — no soy tu contador.
Checklist de validación y pruebas pre-timbrado contra la matriz de errores
Valida en tu propio código antes de gastar un timbre. Cada rechazo del PAC consume tiempo y, en muchos planes, cuota. Estas son las verificaciones que atrapan la mayoría de los errores:
def validar_pre_timbrado(nomina: dict) -> list[str]:
errores = []
# Aritmetica del comprobante
subtotal = nomina["total_percepciones"] + nomina["total_otros_pagos"]
if round(nomina["subtotal"], 2) != round(subtotal, 2):
errores.append("SubTotal != percepciones + otros pagos")
if round(nomina["total"], 2) != round(subtotal - nomina["total_deducciones"], 2):
errores.append("Total != SubTotal - deducciones")
# El error #1: subsidio NO en Percepciones
if any(p["clave"] == "002" for p in nomina["percepciones"]):
errores.append("Subsidio al empleo no va en Percepciones; usa OtrosPagos TipoOtroPago=002")
# Conciliacion subsidio <-> ISR retenido (TipoDeduccion=002)
tiene_subsidio = any(o["tipo"] == "002" for o in nomina["otros_pagos"])
tiene_isr = any(d["tipo"] == "002" for d in nomina["deducciones"])
if tiene_subsidio and not tiene_isr:
errores.append("Subsidio causado sin ISR retenido consistente")
# Revision E: no exento=0 y gravado=0 a la vez
for p in nomina["percepciones"]:
if p["importe_exento"] == 0 and p["importe_gravado"] == 0:
errores.append(f"Concepto {p['clave']}: exento y gravado en cero")
# Clave 038 totalmente gravada
for p in nomina["percepciones"]:
if p["clave"] == "038" and p["importe_exento"] != 0:
errores.append("Clave 038 debe ir totalmente gravada")
# Tope subsidio (error 101) y factor (error 108)
for o in nomina["otros_pagos"]:
if o["tipo"] == "002" and o["subsidio_causado"] > 628.00:
errores.append("Subsidio causado excede tope 628.00 (error 101)")
if nomina["num_dias_pagados"] > 31:
errores.append("NumDiasPagados > 31: aplica factor 20.66 (error 108)")
return errores
Prueba contra el ambiente de timbrado de prueba de tu PAC antes de producción, y arma casos por cada cambio de Revisión E: una percepción 054/055, una deducción 108–111, un concepto con clave 038, un trabajador con subsidio y otro sin él. Si tu flujo de nómina convive con otros complementos, revisa también la actualización de catálogos CFDI 2026 y, para reembolsos o ajustes negativos, el CFDI de Egreso.
Fuentes y referencias
Verifica siempre contra las fuentes oficiales vigentes, nunca contra tu memoria ni contra este post:
- SAT — CFDI de Nómina y Anexo 20, guía de llenado
- DOF — Decreto del subsidio para el empleo (31 de diciembre de 2025)
- Changelog de Revisión E del complemento de nómina de tu PAC (consulta el de tu proveedor de timbrado).
Con esto tienes el esqueleto XML correcto, el subsidio en OtrosPagos en vez de Percepciones, los cambios de Revisión E mapeados y un pipeline de timbrado que persiste el UUID y valida antes de gastar un timbre. Los importes los pone tu motor de nómina; la estructura y el timbrado los pones tú.