ForgeStack
CFDI 🇲🇽 Español

CFDI 4.0 en PHP: Los 5 errores que se repiten en cada proyecto

XSLT offline mal configurado, confusión DER/PEM, c_ClaveProdServ incorrecta, PAC sin reintentos, cadena original errónea. Código real mostrando el error y la corrección.

R. Elizondo · · 8 min de lectura

Llevo más de dos décadas construyendo aplicaciones PHP que interactúan con el SAT. En ese tiempo he visto los mismos cinco errores en casi cada integración de CFDI que me han pedido revisar o rescatar. Algunos los cometí yo mismo. Te los explico para que no tengas que aprenderlos de la manera difícil.

TU APLICACIÓN PHP PAC SAT 📄 XML CFDI Genera tu XML con los datos 🔗 Cadena Original XSLT local ✓ 🔐 Firma CSD Llave privada SHA-256+RSA 📤 Enviar HTTP al PAC con reintentos 🏛️ PAC valida SAT autoriza UUID generado XML Timbrado UUID + Sello SAT Listo para PDF
El flujo completo de timbrado CFDI. Los tres pasos del fondo verde son tu responsabilidad — los errores que vemos aquí ocurren ahí.

Error #1: XSLT para cadena original cargado desde el servidor del SAT

El error más frecuente que veo. Para construir la cadena original del CFDI necesitas aplicar una transformación XSLT al XML. El SAT publica esos archivos XSLT en su sitio. Muchos developers los cargan directamente desde ahí.

El error — carga desde internet en producción
// Funciona en tu máquina. Falla en producción exactamente cuando no puedes permitirte que falle.
$xslt = new XSLTProcessor();
$doc  = new DOMDocument();
$doc->load('http://www.sat.gob.mx/sitio_internet/cfd/4/cadenaoriginal_4_0.xslt');
$xslt->importStyleSheet($doc);

El servidor del SAT tiene tiempos de respuesta variables y cae en mantenimiento. Cuando eso pasa, tu aplicación no puede generar la cadena original y el timbrado falla. El error llega en producción, a las 11pm, cuando tu cliente más grande está facturando cierre de mes.

Correcto — XSLT local, sin dependencia de red
// Descarga el XSLT una vez, lo commiteas con tu proyecto, lo lees desde disco.
$xslt = new XSLTProcessor();
$doc  = new DOMDocument();
$doc->load(__DIR__ . '/../resources/xslt/cadenaoriginal_4_0.xslt');
$xslt->importStyleSheet($doc);

// Con ForgeStack: los XSLTs van empaquetados, no tienes que gestionar esto.
$cadena = CadenaOriginal::fromXml($xml); // local, offline, 0ms extra

Error #2: Confusión entre el formato DER y PEM de la llave privada

El archivo .key que el SAT genera para tu CSD está en formato DER (binario). La mayoría de librerías PHP de criptografía — y openssl_* en particular — esperan formato PEM (base64 con cabeceras). Si alimentas directamente el .key a openssl_pkey_get_private, el resultado es un error críptico que no te explica nada sobre el formato.

Alimentar el .key DER directamente a openssl
$keyDer  = file_get_contents('/ruta/a/tu.key');
// ⛔ openssl_pkey_get_private espera PEM, no DER — devuelve false sin mensaje claro
$keyRes  = openssl_pkey_get_private($keyDer, $password);
$sello   = openssl_sign($cadena, $sig, $keyRes, OPENSSL_ALGO_SHA256);
Convertir DER → PEM antes de usarlo
$keyDer  = file_get_contents('/ruta/a/tu.key');
// Descifra DER con la contraseña del CSD (algoritmo 3DES que usa el SAT)
$keyDec  = openssl_pkcs12_read(...) // o el equivalente para PKCS8

// Más limpio: usa una librería que entiende el formato del SAT de entrada.
// Con ForgeStack:
$csd = Csd::fromFiles(
    certPath: '/ruta/a/tu.cer',
    keyPath:  '/ruta/a/tu.key',  // DER aceptado directamente
    password: $tuPassword
);
$sello = $csd->sign($cadena); // Base64, listo para el XML

Error #3: c_ClaveProdServ incorrecta o genérica

El SAT mantiene un catálogo de más de 50,000 claves de productos y servicios (c_ClaveProdServ). Cada artículo de tu factura necesita la clave correcta. Lo que veo frecuentemente es usar 01010101 — la clave genérica "no identificado" — para todo. El PAC la acepta porque es válida. El problema viene cuando el SAT audita: facturas con todo como "no identificado" son una señal de alerta.

El segundo problema: el catálogo del SAT se actualiza. Si lo descargas una vez y lo hardcodeas, en 6 meses tienes claves obsoletas y el PAC empieza a rechazar facturas.

Clave genérica hardcodeada
// ⚠️ '01010101' es "no identificado" — acepta el PAC hoy, bandera roja en auditoría mañana.
$concepto = [
    'ClaveProdServ' => '01010101',  // ❌ no hacer esto
    'Descripcion'   => 'Servicios de desarrollo',
    'Importe'       => '10000.00',
];
Clave correcta + catálogo siempre actualizado
// Para "Servicios de desarrollo de software": 43232408
// Busca la tuya en: sat.gob.mx/cs/Satellite?c=Page&pagename=CatalogoCFDI

// Con ForgeStack — catálogo validado en build time:
$item = InvoiceItem::service(
    description:    'Desarrollo de software',
    quantity:       1,
    unitPrice:      Money::mxn(10000_00),  // en centavos
    claveProdServ:  '43232408',            // validado vs catálogo vigente
    claveUnidad:    'E48',                 // Unidad de Servicio
);
// Lanza InvalidCatalogCodeException si la clave no existe en el catálogo actual.

Deja de resolver esto cada vez.

Ver el paquete

Error #4: Sin manejo de rechazos del PAC

El PAC puede rechazar tu CFDI por varias razones: contenido inválido (error tuyo, no recuperable), UUID duplicado (ya timbrado, idempotente — devuelve el timbrado original), o error transitorio del servicio (reintentable). Si no distingues entre estos casos, o no reintentas los transitorios, acabas con facturas que nunca se timbran y tu cliente llama enojado.

Fire and forget — no maneja rechazos
$response = $pac->timbrar($cfdiXml);
// Si $response->codigo !== '200' simplemente lanzamos una excepción genérica.
// No sabemos si fue transitorio (reintentable) o fatal (no reintentable).
// No guardamos el estado. No alertamos. La factura queda en el limbo.
Clasificación de errores + reintentos con backoff
// Los errores del PAC tienen categorías con semántica distinta:
// - 307: UUID duplicado  → idempotente, devuelve el XML ya timbrado
// - 301: XML inválido    → error tuyo, no reintentes — corrígelo
// - 5xx: servicio caído  → reintentable con backoff exponencial

// Con ForgeStack — la clasificación y los reintentos están incluidos:
try {
    $timbrado = $cfdi->stamp($invoice);  // reintentos automáticos si es transitorio
} catch (DuplicateUuidException $e) {
    $timbrado = $e->getExistingStamp();  // ya estaba timbrado, usa ese
} catch (PacValidationException $e) {
    // Error en tu XML — no reintentes, corrígelo
    logger()->error('pac.validation', ['code' => $e->getPacCode(), 'detail' => $e->getMessage()]);
}

Error #5: Cadena original construida manualmente

La cadena original de un CFDI no es simplemente concatenar los atributos del XML. Es el resultado de aplicar una transformación XSLT específica que el SAT publica, que maneja orden de atributos, nodos opcionales, y escape de caracteres de una manera muy precisa. Si la construyes manualmente — concatenando strings con pipes, iterando atributos a mano — tarde o temprano vas a tener una cadena que no coincide con lo que el PAC calcula, y el sello no va a verificar.

Construcción manual con strings
// Parece razonable hasta que el SAT cambia el orden de atributos en el catálogo,
// o un nodo complemento tiene espacios de nombre adicionales.
$cadena  = '||' . $version  . '|';
$cadena .= $serie    . '|';
$cadena .= $folio    . '|';
// ... 40 campos más, todos a mano. Cualquier nodo opcional rompe la concatenación.
XSLT local — el único método correcto
// La cadena original SE CALCULA con la XSLT oficial del SAT. No hay otro camino.
// La transformación maneja orden, opcionalidad, y complementos automáticamente.

$xslt = new XSLTProcessor();
$xslt->importStyleSheet(local_xslt_document());   // ← local, no desde internet
$cadena = $xslt->transformToXml($xmlDoc);

// ForgeStack: esto está resuelto en CadenaOriginal::fromXml()
// No tienes que pensarlo.

En resumen

Los cinco errores tienen algo en común: son consecuencias de construir CFDI desde cero sin suficiente documentación sobre las peculiaridades del SAT. El SAT no es Stripe — no tiene una API diseñada para developers, no tiene SDKs oficiales bien mantenidos, y la documentación oficial asume que ya conoces el contexto del sistema fiscal mexicano.

El antídoto no es leer más documentación del SAT. Es usar una implementación que ya tiene estos casos resueltos, probados, y mantenidos. Que cuando el catálogo se actualiza, se actualiza solo. Que cuando el PAC cambia una respuesta, el manejo de errores ya estaba previsto.

Nota sobre versiones: Este artículo aplica a CFDI 4.0, vigente y obligatorio desde enero 2023. CFDI 3.3 fue cancelado por el SAT — si tu integración aún genera 3.3, es urgente migrar.
ForgeStack · Investigación de producto

¿Conciliar pagos SPEI a mano cada mes?

Estamos construyendo una herramienta para automatizar la conciliación de pagos SPEI en despachos y negocios mexicanos. Antes de construirla, queremos entender bien el problema — 4 preguntas, 2 minutos.

Sin compromiso, sin dejar tus datos.

Responder la encuesta