El portal del SAT tiene tus CFDI pero tu descarga masiva baja un paquete vacío: diagnostícalo con NumeroCFDIs, en Python — Cesar Ayala
← Todos los artículos

El portal del SAT tiene tus CFDI pero tu descarga masiva baja un paquete vacío: diagnostícalo con NumeroCFDIs, en Python

El dato que decide es NumeroCFDIs en VerificaSolicitud: loguéalo con EstadoSolicitud. Si es 0, tu consulta del WS no es la del portal — rfcSolicitante debe coincidir con RfcEmisor (emitidas) o RfcReceptor (recibidas), y las fechas deben ir de día completo en hora del centro. Si el conteo coincide pero el ZIP sale vacío, no hiciste base64-decode del Paquete.

El síntoma: el portal del SAT sí tiene tus CFDI, pero el web service baja un ZIP vacío (o “no termina”)

El dato que decide es NumeroCFDIs en VerificaSolicitud: loguéalo junto con EstadoSolicitud y el problema se parte en 30 segundos. Si NumeroCFDIs es 0, tu consulta del web service no es la del portal — rfcSolicitante debe coincidir con RfcEmisor (emitidas) o con RfcReceptor (recibidas), y las fechas deben ir de día completo en hora del centro. Si el conteo sí coincide pero el ZIP sale vacío, no hiciste base64-decode del Paquete.

El escenario es siempre el mismo. Entras al portal del SAT, filtras un rango de fechas y ahí están tus comprobantes, listados, con su UUID y su monto. Corres tu script de Descarga Masiva sobre ese mismo rango y el paso Descarga te entrega un ZIP vacío — o peor, la solicitud “no termina” y se queda en EnProceso para siempre. Es una de las fallas más reportadas del servicio: el issue #49 de python-satcfdi describe exactamente esto — el portal tiene registros y aun así VerificaSolicitud devuelve NumeroCFDIs: 0, IdsPaquetes: [] y EstadoSolicitud: 1. No es que el SAT te esconda los CFDI; es que le estás preguntando otra cosa. El flujo completo de los 4 pasos SOAP firmados con tu e.firma está en la guía de Descarga Masiva con e.firma en Python; aquí solo diagnosticamos este modo de falla.

El diagnóstico de un solo dato: loguea NumeroCFDIs (y EstadoSolicitud) en cada VerificaSolicitud

Antes de tocar nada, instrumenta. En cada respuesta de VerificaSolicitud hay dos campos que, juntos, te dicen en qué mundo estás:

import logging
log = logging.getLogger("descarga-masiva")

estado = extract_attr(resp.text, "VerificaSolicitudDescargaResult", "EstadoSolicitud")
num    = extract_attr(resp.text, "VerificaSolicitudDescargaResult", "NumeroCFDIs")

log.info("verifica EstadoSolicitud=%s NumeroCFDIs=%s", estado, num)
# NumeroCFDIs == 0  -> Caso 1: tu consulta del WS no es la del portal
# NumeroCFDIs  > 0  -> Caso 2: si el ZIP sale vacío, es un bug de descarga/decode

Con esos dos números el “no sé por qué baja vacío” deja de ser un misterio. NumeroCFDIs es cuántos comprobantes encontró el SAT para tu consulta — no para la del portal. Si es 0, el SAT hizo bien su trabajo: buscó justo lo que le pediste y no había nada. El bug está en cómo formaste la petición. Si NumeroCFDIs es 15 y aun así el ZIP abre vacío, la consulta fue correcta y el bug está aguas abajo, en cómo bajas o decodificas el paquete. Un mismo síntoma, dos causas raíz totalmente distintas — y este campo las separa. Si tu solicitud lleva horas atorada en EnProceso, eso es otro tema (rango amplio sobre un RFC de alto volumen), y lo trato en el post de EnProceso y los límites reales.

NumeroCFDIs = 0

  • Tu consulta del WS NO es la del portal
  • rfcSolicitante en el slot equivocado (emitidas vs recibidas)
  • O el rango de fechas / zona horaria excluye todo
  • El SAT devuelve 0 en silencio, sin lanzar error

NumeroCFDIs coincide (p. ej. 15) pero ZIP vacío

  • La consulta sí es la correcta
  • Es un bug de descarga o de decodificación
  • Te faltó base64.b64decode del Paquete
  • Guardaste el string base64 como .zip

Caso 1 · NumeroCFDIs = 0: tu consulta del WS no es la del portal — la trampa del rol/dirección del RFC

Esta es la causa más común, y la más silenciosa. En el portal, cuando ves tus facturas, implícitamente estás en una vista: “emitidas” (las que tú expediste) o “recibidas” (las que te expidieron a ti). El web service no adivina esa vista: la infieres tú según en qué slot pones tu RFC. rfcSolicitante — el RFC de tu FIEL — debe coincidir con RfcEmisor si pides emitidas, o con RfcReceptor si pides recibidas.

# emitidas (las que TÚ expediste): filtra por RfcEmisor = tu RFC
solicita_descarga(rfc_emisor=MI_RFC, tipo="CFDI")       # NO pongas RfcReceptor

# recibidas (las que te expidieron a ti): filtra por RfcReceptor = tu RFC
solicita_descarga(rfc_receptor=MI_RFC, tipo="CFDI")     # NO pongas RfcEmisor

# El error clásico: pedir "recibidas" filtrando por RfcReceptor
# cuando lo que buscabas eran las emitidas -> NumeroCFDIs: 0, en silencio

Lo traicionero es que el SAT no te marca error. Una consulta de “recibidas” filtrada por RfcReceptor jamás va a encontrar los CFDI que tú emitiste — pero no te dice “dirección equivocada”, te dice NumeroCFDIs: 0, que es indistinguible de “no hay nada en el rango”. Por eso la gente pierde horas revisando fechas cuando el problema era el rol del RFC. Antes de asumir que tu librería está rota, confirma que rfcSolicitante cae en el slot correcto para la dirección que estás pidiendo.

emitidasrfcSolicitante debe coincidir con RfcEmisor
recibidasrfcSolicitante debe coincidir con RfcReceptor
slot equivocadodevuelve NumeroCFDIs: 0 en silencio
conteo 0IdsPaquetes llega como lista vacía

Caso 1, la otra mitad: fechas de día completo y la zona horaria (hora del centro de México)

Si el rol del RFC ya está bien y NumeroCFDIs sigue en 0, el sospechoso es el rango de fechas. Dos errores lo tumban. El primero: mandar una fecha “pelona” sin hora. El SAT la interpreta a medianoche exacta, así que fechaFinal a las 00:00:00 recorta todo el último día — y si tu rango es de un solo día, te quedas sin nada. Manda siempre día completo:

# MAL: fecha sola -> se interpreta a medianoche y recorta el día
fecha_inicial = "2026-06-01"
fecha_final   = "2026-06-30"

# BIEN: día completo, hora del centro de México
fecha_inicial = "2026-06-01T00:00:00"
fecha_final   = "2026-06-30T23:59:59"

El segundo error es la zona horaria. El SAT trabaja en hora del centro de México. Si tu código genera los datetime en UTC (o los “normaliza” a UTC antes de mandarlos), un rango se desfasa varias horas y puede dejar fuera comprobantes que caen justo en los bordes del día — o el rango completo, si es corto. No conviertas a UTC: forma fechaInicial con T00:00:00 y fechaFinal con T23:59:59 en hora local del centro, tal como los ves en el portal. Entre el slot del RFC y estas dos trampas de fecha se explica la enorme mayoría de los NumeroCFDIs: 0.

Caso 2 · NumeroCFDIs coincide pero el ZIP sale vacío: te faltó base64-decode del Paquete

Ahora el caso bueno: NumeroCFDIs dice 15, tu consulta era la correcta, y aun así el archivo que guardaste abre “vacío” o inválido. Aquí el bug es de decodificación. La respuesta del paso Descarga te entrega el campo Paquete codificado en base64 — es un string de texto, no los bytes del ZIP. Tienes que decodificarlo con base64.b64decode(...) y escribir los bytes resultantes en un .zip. Si guardas el string base64 tal cual, el archivo tiene la extensión correcta pero no es un ZIP, y por eso “abre vacío”.

import base64, io, zipfile

# Solo cuando EstadoSolicitud == "3" (Terminada) hay IdsPaquetes que bajar
if estado != "3":
    raise RuntimeError("todavía no hay paquete: no llames Descarga aún")

paquete_b64 = extract_attr(resp.text, "RespuestaDescargaMasivaTercerosSalida", "Paquete")

# El Paquete viene en base64: decodifícalo a BYTES antes de tratarlo como ZIP
zip_bytes = base64.b64decode(paquete_b64)

with open("paquete.zip", "wb") as f:     # escribe los BYTES, no el string
    f.write(zip_bytes)

# o ábrelo en memoria, sin tocar disco
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
    print(zf.namelist())

El otro detalle de este caso: solo llama Descarga cuando EstadoSolicitud es 3 (Terminada). En 1 (Aceptada) o 2 (EnProceso) todavía no hay IdsPaquetes, y bajar “con las manos vacías” produce un ZIP inválido que se confunde con este mismo síntoma. La referencia completa de estados y códigos — qué significa cada EstadoSolicitud y cada CodEstatus — está en el post de códigos de error y “solicitud aceptada”.

  1. Espera EstadoSolicitud == 3 (Terminada)Solo ahí hay IdsPaquetes; no llames Descarga antes.
  2. Descarga devuelve Paquete en base64Es un string de texto, NO los bytes del ZIP todavía.
  3. base64.b64decode(paquete)Convierte el string a los bytes reales del ZIP.
  4. Escribe los BYTES en un .zip (o ábrelo en memoria)Si guardas el string base64 tal cual, el zip abre vacío o inválido.

La prueba de 30 segundos: replica la consulta EXACTA del portal y pide Metadata primero

Cuando dudes, no depures a ciegas: reproduce en el web service exactamente la misma consulta que ves en el portal — mismas fechas, misma dirección (emitidas o recibidas), mismo RFC en el slot correcto — y pide Metadata en vez de CFDI. Una petición de Metadata es del orden de mil veces más ligera que bajar el XML completo, así que confirmas el conteo casi al instante sin gastar cupo ni ancho de banda.

# Réplica mínima de la consulta del portal, pidiendo Metadata (ligero)
resp = solicita_descarga(rfc_emisor=MI_RFC,               # emitidas -> RfcEmisor
                         fecha_inicial="2026-06-01T00:00:00",
                         fecha_final="2026-06-30T23:59:59",
                         tipo="Metadata")
# luego pollea VerificaSolicitud y loguea EstadoSolicitud + NumeroCFDIs

Con esos dos valores en el log ya sabes el veredicto: NumeroCFDIs: 0 es Caso 1 (arregla el rol del RFC o las fechas); NumeroCFDIs que coincide con el portal es Caso 2 (arregla el base64-decode). El Metadata te devuelve un ZIP con un índice .txt delimitado por tildes ~ — cómo leerlo y parsearlo sin que las columnas se desalineen lo detallo en el post hermano de cómo leer el paquete. Una vez que confirmes el conteo, cambia a tipo="CFDI" para bajar los XML firmados que después vas a validar (sello, vigencia, 69-B).

Copia la consulta EXACTA del portal
Pide Metadata, no CFDI
Loguea EstadoSolicitud + NumeroCFDIs
0 = Caso 1 · coincide = Caso 2

La mentalidad: el mismo síntoma en varios issues de GitHub suele ser el mismo error, no un bug de la librería

Cuando encuentras cinco issues abiertos con “el portal tiene registros pero el web service baja vacío”, el instinto es concluir “la librería está rota” o “el SAT está fallando”. Casi nunca es eso. El síntoma compartido es, en la gran mayoría de los casos, el error compartido: el mismo NumeroCFDIs: 0 por dirección de RFC mal puesta o por rango de fechas mal formado. El issue #49 de python-satcfdi y el issue #23 de phpcfdi/sat-ws-descarga-masiva son ejemplos de reportes que, mirados con el log de NumeroCFDIs en mano, resultan ser configuración de la consulta, no defectos del cliente.

Estas librerías — python-satcfdi y phpcfdi/sat-ws-descarga-masiva — están muy usadas y bien mantenidas. Si tu resultado difiere del portal, la hipótesis de mayor probabilidad es que tu petición difiere del portal. Primero instrumenta (EstadoSolicitud + NumeroCFDIs), luego replica la consulta exacta, y solo si NumeroCFDIs coincide y el paquete sigue corrupto tras un base64-decode limpio empieza a sospechar de una capa más abajo. Ese orden te ahorra horas y evita que abras un issue que en realidad era tu propio filtro. Todo el ecosistema de facturación arranca en el hub de facturación CFDI en México.

Ingeniero, no contador (y fuentes)

Yo escribo el código y sí lo pongo en producción contra el SAT — pero soy ingeniero, no tu contador ni tu abogado. Los campos, estados y formatos de este post están verificados hoy contra la especificación y las implementaciones de referencia, pero las reglas del SAT cambian: confírmalas siempre contra las fuentes oficiales vigentes, nunca contra tu memoria (ni contra la mía).