
Por qué tu descarga masiva del SAT se queda en "EnProceso" (y por qué el límite de 2,000 CFDI/día es un mito)
Tu solicitud se queda en EnProceso porque el SAT procesa la descarga masiva de forma asíncrona: haces polling a VerificaSolicitud (con backoff, no en bucle apretado) hasta que pasa a Terminada, y descargas los paquetes dentro de 72 horas. No existe un tope de 2,000 CFDI/día en el Web Service — ese límite es del portal manual. Los límites reales de la v1.5 son 200,000 registros por solicitud CFDI y 1,000,000 en metadata.
La respuesta corta: EnProceso es el modelo asíncrono funcionando, no un bug
Tu solicitud de Descarga Masiva se queda en EnProceso porque el Servicio Web del SAT es asíncrono: SolicitaDescarga solo registra el job y te devuelve un IdSolicitud; el SAT arma los paquetes en segundo plano. Haces polling a VerificaSolicitud (con backoff, no en un bucle apretado) hasta que el estado pasa a Terminada, y descargas cada paquete dentro de las 72 horas disponibles. No hay bug.
Y el mito que casi todos repiten: no existe un tope de 2,000 CFDI/día en el Web Service. Ese límite es del portal manual de descarga, no del WS. Los límites reales de la v1.5 son 200,000 registros por solicitud para CFDI y 1,000,000 en metadata.
Aviso de siempre: soy ingeniero, no tu contador ni tu abogado. Aquí explico el flujo técnico y cómo hacerlo idempotente; las reglas fiscales vigentes y cualquier cambio de la especificación los confirmas contra el SAT y contra github.com/phpcfdi/sat-ws-descarga-masiva.
Desmontando el mito de los “2,000 CFDI/día”: ese tope es del portal manual
El “límite de 2,000 CFDI por día” circula en foros y hasta en documentación de terceros. Es real, pero pertenece al portal manual de “Consulta y recuperación de comprobantes” que abres en el navegador, donde el SAT limita cuántos comprobantes exportas a mano.
El Servicio Web de Descarga Masiva es otra cosa. Se autentica con tu e.firma (FIEL) y sus límites están definidos por solicitud, no por día. Si diseñas tu integración persiguiendo un tope diario de 2,000 que no existe en el WS, sub-utilizas el servicio por un factor de cien.
Todo lo que sigue asume el WS, que es lo que quieres automatizar.
El flujo de cuatro pasos como máquina de estados
El servicio son cuatro operaciones encadenadas, y lo más sano es modelarlas como una máquina de estados que persiste el IdSolicitud. Los cuatro pasos:
- Autenticacionfirmas un SOAP con tu FIEL y obtienes un token bearer (~5 minutos)
- SolicitaDescargaenvías fechaInicial/fechaFinal, tipoSolicitud, RfcEmisor/RfcReceptor y recibes un IdSolicitud
- VerificaSolicitudhaces polling con el IdSolicitud; el estado viaja de EnProceso a Terminada
- Descargabajas cada paquete como un ZIP en base64 por su id
El detalle que ata todo: el token de Autenticacion vive ~5 minutos (el <u:Created> y el <u:Expires> del Timestamp están separados por 5 minutos). Si tu job de polling corre durante horas, el token va a caducar en medio — por eso re-autenticas cuando lo necesites, no una sola vez al inicio.
El endpoint de autenticación es POST https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc con el SOAPAction http://DescargaMasivaTerceros.gob.mx/IAutenticacion/Autentica. La firma es WS-Security con digest SHA1 y firma RSA-SHA1 (http://www.w3.org/2000/09/xmldsig#rsa-sha1), un Timestamp y un BinarySecurityToken con tu .cer en base64 (perfil X509v3); firma tu .key privada. Un mensaje mal formado se rechaza de entrada.
Por qué queda en EnProceso y cuánto tarda: de minutos a horas
Cuando llamas SolicitaDescarga, el SAT no te devuelve los CFDI — te devuelve un acuse con el IdSolicitud. A partir de ahí, el SAT encola tu solicitud y arma los paquetes de forma asíncrona. Mientras eso pasa, VerificaSolicitud te responde EnProceso.
¿Cuánto tarda? Depende del volumen del rango y de la carga del SAT: de minutos a horas, y en picos puede acercarse a las 72 horas antes de estar listo. No es determinista. Por eso lo correcto es polling con backoff, no un bucle apretado que golpea el WS cada segundo:
import time
def poll_backoff(client, id_solicitud):
# backoff creciente: no golpees el WS en un bucle apretado
delays = [5, 10, 30, 60, 120, 300] # segundos
for i in range(200):
estado = client.verifica(id_solicitud) # -> EnProceso | Terminada | ...
if estado.codigo != "EnProceso":
return estado
wait = delays[min(i, len(delays) - 1)]
time.sleep(wait)
raise TimeoutError("VerificaSolicitud siguió EnProceso demasiado tiempo")
La regla mental: EnProceso no es un error y no significa que debas reintentar SolicitaDescarga. Reintentar la solicitud solo genera otro IdSolicitud y desperdicia tu presupuesto de “no descargar el mismo XML dos veces”. Espera, con paciencia y con backoff.
Límites reales de la Descarga Masiva CFDI (v1.5)
Estos son los números que sí están en la especificación de la v1.5 (2025-05-30). Grábatelos, porque son los que de verdad restringen tu diseño:
En detalle:
- Hasta 200,000 registros por solicitud cuando pides
tipoSolicitud = CFDI. - Hasta 1,000,000 de registros por solicitud cuando pides
tipoSolicitud = Metadata. - No hay tope en el número de solicitudes, siempre que no descargues el mismo XML más de dos veces.
- Una solicitud no puede ser instantánea:
fechaInicialtiene que ser menor quefechaFinal. - Los paquetes/resultados están disponibles 72 horas una vez
Terminada. Pasado eso, se vencen y tienes que volver a solicitar. - Si el resultado es grande, el SAT lo parte en varios paquetes — tu paso
Descargaitera sobre todos los ids que te dioVerificaSolicitud.
Ni un tope diario, ni una operación de descarga por UUID que se salte el rango: el flujo verificado es por rango de fechas (el UUID solo es un filtro opcional dentro de la solicitud normal, nunca una operación aparte). Si necesitas correlacionar con tu pasarela después, ese trabajo de conciliación vive aguas abajo — lee conciliación de pagos multi-rail en México.
Metadata vs CFDI: pide metadata primero para dimensionar el rango
Aquí está la jugada que separa una integración ingenua de una que escala. Antes de pedir 200,000 XML completos, pide metadata. La metadata es ligera (por eso su tope es 1,000,000 y no 200,000): trae UUID, RFC, fechas e importes, sin el XML completo. Con eso en mano sabes exactamente cuántos comprobantes hay en el rango, y recién entonces armas solicitudes CFDI que respeten el tope de 200,000:
# Paso 1: dimensiona con metadata (barata, tope de 1,000,000)
meta_id = client.solicita(
fecha_inicial="2026-01-01T00:00:00",
fecha_final="2026-01-31T23:59:59",
tipo_solicitud="Metadata",
rfc_emisor=MI_RFC, # emitidas; usa rfc_receptor para recibidas
)
meta = poll_backoff(client, meta_id)
total = meta.total_registros # ahora sabes el volumen real del rango
# Paso 2: solo entonces pide los CFDI completos que necesitas
if total <= 200_000:
cfdi_id = client.solicita(
fecha_inicial="2026-01-01T00:00:00",
fecha_final="2026-01-31T23:59:59",
tipo_solicitud="CFDI",
rfc_emisor=MI_RFC,
)
Distingue bien emitidas vs recibidas: RfcEmisor filtra las que tú expediste, RfcReceptor las que recibiste. No mezcles ambos criterios en una sola solicitud esperando el universo completo.
Ingeniería alrededor de los límites reales: parte el rango en ventanas
Como no existe un tope diario, no persigas uno. El límite que sí importa es 200,000 CFDI por solicitud. Si un mes cae por debajo de eso, va en una sola solicitud. Si tu volumen es alto y un rango grande rebasa los 200,000, parte el rango en ventanas (por mes, por quincena, por semana — lo que mantenga cada solicitud bajo el tope), no en “días de 2,000”:
from datetime import datetime, timedelta
def ventanas_mensuales(desde, hasta):
# divide un rango grande en ventanas <= 1 mes para no rebasar 200k/solicitud
cur = desde
while cur < hasta:
fin_mes = (cur.replace(day=28) + timedelta(days=4)).replace(day=1)
fin = min(fin_mes - timedelta(seconds=1), hasta)
yield cur, fin
cur = fin + timedelta(seconds=1)
for ini, fin in ventanas_mensuales(datetime(2025,1,1), datetime(2026,1,1)):
id_sol = client.solicita(
fecha_inicial=ini.isoformat(),
fecha_final=fin.isoformat(),
tipo_solicitud="CFDI",
rfc_emisor=MI_RFC,
)
# persiste id_sol, hazle polling con backoff, descarga cuando Terminada
Si una ventana específica sigue rebasando los 200,000 (por ejemplo un emisor de altísimo volumen), la partes más fino. La metadata del paso anterior te dice exactamente dónde apretar.
Los estados que debes manejar: una tabla de decisión
VerificaSolicitud no solo devuelve EnProceso y Terminada. Tu worker tiene que manejar el conjunto completo, cada uno con su acción correcta:
Sigue / avanza
- EnProceso: aún se está armando — espera con backoff, no reintentes la solicitud
- Terminada: listo — lee los ids de paquetes y descarga dentro de 72 h
- Regla: solo marcas el job como listo en Terminada, nunca antes
Detente / corrige
- Rechazada: la solicitud fue inválida — corrige y vuelve a solicitar
- Vencida: pasaron las 72 h — vuelve a solicitar el rango
- Error: falla del lado del SAT — reintenta con backoff, registra el código
La disciplina clave: solo marcas el job como listo en Terminada. EnProceso te dice “sigue esperando”. Rechazada, Vencida y Error te sacan del bucle de polling por caminos distintos. Este mismo patrón de “un estado terminal claro, no adivines” es el que defiendo en webhooks vs polling vs API: el SAT no te empuja eventos, así que el polling con backoff es lo correcto aquí — pero acótalo con estados terminales explícitos.
Por qué una solicitud vuelve Rechazada
Rechazada casi siempre es culpa tuya, no del SAT, y suele venir con un código de estatus que apunta al motivo. Los tres clásicos:
- Firma inválida. El sello WS-Security no cuadra: usaste el algoritmo equivocado (recuerda, digest SHA1 y firma RSA-SHA1), mandaste mal el
.ceren elBinarySecurityToken, o firmaste con una.keyque no corresponde. Un mensaje mal formado se rechaza de entrada. fechaInicial >= fechaFinal. La solicitud no puede ser instantánea. Si mandas el mismo instante en ambos, o los inviertes, el SAT la rechaza.- Token de 5 minutos vencido. Autenticaste, armaste la solicitud con calma, y para cuando la enviaste el token bearer ya expiró. Re-autentica y reenvía.
El patrón de firmar, verificar y no reprocesar a ciegas es el mismo espíritu que la verificación de firma de webhooks de Stripe: un sobre criptográfico que, si no cuadra, se rechaza sin ambigüedad.
Jobs idempotentes y reanudables: persiste el IdSolicitud
La descarga masiva es un job largo que va a cruzar reinicios de proceso, deploys y expiraciones de token. Diséñalo para sobrevivir eso:
- Persiste el
IdSolicituden tu base de datos apenasSolicitaDescargate lo devuelve. Es tu ancla: si el worker se reinicia, reanudas el polling con ese id en vez de mandar otra solicitud (y quemar tu presupuesto de “no bajar el mismo XML dos veces”). - Haz backoff entre polls a
VerificaSolicitud. Nada de bucles apretados. - Marca listo solo en
Terminada. Cualquier otro estado tiene su propia rama. - No descargues el mismo XML más de dos veces. Lleva registro de qué paquetes ya bajaste y desduplica por UUID al ingerir. Este apetito por la idempotencia es hermano del que uso para timbrar sin duplicar en outbox para timbrar CFDI.
Esqueleto en Python: el worker de polling y descarga de principio a fin
Juntando todo — autenticar (renovando el token), solicitar, hacer polling con backoff y descargar cada paquete dentro de las 72 horas:
import base64, time
def descarga_masiva(client, rfc, ini, fin, tipo="CFDI"):
# 1) Autenticacion: token bearer ~5 min. Renuévalo cuando caduque.
client.autentica() # firma WS-Security SHA1 / RSA-SHA1 con la FIEL
# 2) SolicitaDescarga: fechaInicial < fechaFinal (no instantánea)
id_solicitud = client.solicita(
fecha_inicial=ini, fecha_final=fin,
tipo_solicitud=tipo, # "CFDI" (<=200k) o "Metadata" (<=1M)
rfc_emisor=rfc, # emitidas; rfc_receptor para recibidas
)
persist(id_solicitud) # ancla idempotente: sobrevive reinicios
# 3) VerificaSolicitud: polling con backoff hasta Terminada
delays = [5, 10, 30, 60, 120, 300]
for i in range(500):
if client.token_expirado():
client.autentica() # el token vive ~5 min
estado = client.verifica(id_solicitud)
if estado.codigo == "Terminada":
break
if estado.codigo in ("Rechazada", "Vencida", "Error"):
raise RuntimeError(f"solicitud {estado.codigo}: {estado.mensaje}")
# EnProceso: espera y vuelve a preguntar
time.sleep(delays[min(i, len(delays) - 1)])
else:
raise TimeoutError("nunca llegó a Terminada")
# 4) Descarga: cada paquete es un ZIP en base64. No lo bajes dos veces.
for paquete_id in estado.paquetes:
if ya_descargado(paquete_id):
continue
b64 = client.descarga(paquete_id) # dentro de las 72 h
with open(f"{paquete_id}.zip", "wb") as f:
f.write(base64.b64decode(b64))
marca_descargado(paquete_id)
Como implementación de referencia canónica, mira github.com/phpcfdi/sat-ws-descarga-masiva (PHP), y en Python SAT-CFDI/python-satcfdi o cfdiclient. Todos modelan lo mismo: una máquina de estados con IdSolicitud persistido, backoff entre polls, manejo de Vencida/Error y “listo” solo en Terminada.
Dónde encaja esto
Este post es el complemento operativo del desglose de los 4 pasos de la Descarga Masiva con e.firma en Python: ahí construyes el flujo, aquí lo endureces contra EnProceso, los límites reales y los estados de error. Es la pieza de entrada del stack de CFDI; la de salida — emitir sin duplicar — vive en outbox para timbrar CFDI. Todo esto cuelga del hub de facturación CFDI en México.
Último recordatorio: soy ingeniero, no tu contador ni tu abogado. La especificación del WS y las reglas fiscales vigentes cambian — confírmalas contra el SAT y contra phpcfdi/sat-ws-descarga-masiva antes de ponerlo en producción.