
Descarga masiva de tus CFDI del web service del SAT con tu e.firma, en Python
Es un flujo SOAP de 4 pasos — Autenticacion, SolicitaDescarga, VerificaSolicitud, Descarga — firmado con tu e.firma (.cer/.key) usando digest SHA1 + RSA-SHA1 (XML-DSig) dentro de WS-Security. Autenticacion devuelve un token valido ~5 minutos; polleas VerificaSolicitud hasta Terminada y bajas cada paquete como un ZIP en base64.
Descarga masiva de tus CFDI del SAT: la respuesta corta
Bajar todos tus CFDI del SAT es un flujo SOAP de cuatro pasos — Autenticacion, SolicitaDescarga, VerificaSolicitud y Descarga — firmado con tu e.firma (FIEL): el .cer público y el .key privado, usando digest SHA1 y firma RSA-SHA1 (XML-DSig) dentro de WS-Security. Autenticacion te devuelve un token válido ~5 minutos; con ese token mandas la solicitud por rango de fechas, obtienes un IdSolicitud, consultas VerificaSolicitud en bucle hasta que el estado sea Terminada, y bajas cada paquete como un ZIP en base64. Sin PAC, sin extraer del portal a mano, directo del SAT y en Python.
Esto es una guía de ingeniero, no de contador. Aquí aprendes la máquina de estados, la firma y las llamadas. Las reglas fiscales vigentes las confirmas contra el SAT.
Qué es el web service de Descarga Masiva (y por qué saltarte el PAC y el portal)
El Servicio Web de Descarga Masiva deja que un contribuyente — o un tercero autorizado — baje en bloque sus CFDI emitidos o recibidos, o solo la metadata, directo del SAT, autenticándose con la e.firma. No pasa por un PAC ni por automatizar el portal a mano: es tu propia información, servida por el SAT. La versión vigente es la v1.5 (2025-05-30), que cubre CFDI y Retenciones e información de pagos.
La diferencia con timbrar es la dirección. En el outbox de timbrado de CFDI empujas comprobantes hacia afuera contra un PAC; aquí jalas comprobantes hacia adentro directo del SAT. La criptografía es la misma e.firma — cambia el sentido del flujo.
Tu e.firma: el certificado público .cer y la llave privada .key
La e.firma (antes FIEL) son dos archivos: el certificado público .cer (X.509) y la llave privada .key. El .key firma; el .cer va, en base64, dentro del mensaje para que el SAT sepa con qué certificado validar. En Python lo cargas con cryptography, y como el .key del SAT viene en formato DER cifrado, lo desencriptas con tu contraseña.
from cryptography.hazmat.primitives.serialization import load_der_private_key
from cryptography.x509 import load_der_x509_certificate
import base64
with open("fiel.key", "rb") as f:
private_key = load_der_private_key(f.read(), password=b"TU_CONTRASENA_FIEL")
with open("fiel.cer", "rb") as f:
cer_der = f.read()
certificate = load_der_x509_certificate(cer_der)
# El blob base64 que va dentro del BinarySecurityToken:
cer_b64 = base64.b64encode(cer_der).decode()
El SAT entrega la FIEL en formato DER, no PEM — usa load_der_*, no load_pem_*. Si te sale un error tipo “could not deserialize key”, casi siempre es por esa confusión. Nunca metas el .key ni la contraseña al repo: móntalo como secreto en runtime y trátalo como lo que es, la llave con la que te identificas ante el SAT.
La firma que tienes que dejar impecable: WS-Security, digest SHA1, RSA-SHA1, BinarySecurityToken y Timestamp
Cada mensaje va firmado con WS-Security. El SAT es estricto: un mensaje mal formado se rechaza de tajo, sin pistas útiles. Las piezas obligatorias son:
- Un
Timestampcon<u:Created>y<u:Expires>(la ventana de validez del mensaje, yo uso 5 minutos). - Un
BinarySecurityTokenque carga el.ceren base64 (perfil X509v3). - Una
Signaturesobre elTimestampcon digest SHA1 (http://www.w3.org/2000/09/xmldsig#sha1) y firma RSA-SHA1 (http://www.w3.org/2000/09/xmldsig#rsa-sha1). - Un
SecurityTokenReferenceque apunta la firma de vuelta alBinarySecurityToken.
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
def rsa_sha1_sign(signed_info_c14n: bytes) -> str:
signature = private_key.sign(
signed_info_c14n,
padding.PKCS1v15(),
hashes.SHA1(), # RSA-SHA1, como pide el SAT
)
return base64.b64encode(signature).decode()
def sha1_digest_b64(canonical_element: bytes) -> str:
h = hashes.Hash(hashes.SHA1())
h.update(canonical_element)
return base64.b64encode(h.finalize()).decode()
El orden importa: primero canonicalizas el nodo, sacas su digest SHA1, armas el SignedInfo con ese digest, lo canonicalizas, y ese es lo que firmas con RSA-SHA1. Cualquier byte fuera de lugar en la canonicalización te tira la firma.
Paso 1 — Autenticacion: el endpoint real, el SOAPAction y el token de ~5 minutos
Autenticacion firma un Timestamp con tu e.firma y te devuelve un token bearer válido ~5 minutos — sus <u:Created> y <u:Expires> están separados exactamente 5 minutos. Ese token va en el header Authorization de los siguientes tres pasos.
- Endpoint:
https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc - SOAPAction:
http://DescargaMasivaTerceros.gob.mx/IAutenticacion/Autentica
import requests
AUTH_URL = "https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc"
AUTH_ACTION = "http://DescargaMasivaTerceros.gob.mx/IAutenticacion/Autentica"
def autenticacion(signed_auth_envelope: str) -> str:
resp = requests.post(
AUTH_URL,
data=signed_auth_envelope.encode("utf-8"),
headers={
"Content-Type": 'text/xml; charset="utf-8"',
"SOAPAction": AUTH_ACTION,
},
timeout=30,
)
resp.raise_for_status()
# El token sale del nodo <AutenticaResult> de la respuesta.
token = extract_between(resp.text, "<AutenticaResult>", "</AutenticaResult>")
return f"WRAP access_token=\"{token}\""
Esa cadena WRAP access_token="..." es la que pones en el header Authorization de las otras tres llamadas. Como caduca a los 5 minutos, trátalo como efímero: vuelve a autenticar por corrida en lugar de guardarlo en caché durante un bucle largo de consultas.
Paso 2 — SolicitaDescarga: rango de fechas, CFDI vs Metadata, emisor vs receptor y tu IdSolicitud
Con el token ya puedes pedir la descarga. El sobre firmado de SolicitaDescarga lleva el rango de fechas FechaInicial/FechaFinal, el TipoSolicitud (CFDI para los XML completos o Metadata para solo los metadatos), y si buscas emitidos o recibidos vía RfcEmisor/RfcReceptor. En la v1.5 la operación misma se parte por dirección: SolicitaDescargaEmitidos para lo que emitiste, SolicitaDescargaRecibidos para lo que te facturaron — no es solo un campo distinto, es un SOAPAction distinto. Te regresa un IdSolicitud: el identificador que vas a consultar en bucle.
SOLICITA_URL = "https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/SolicitaDescargaService.svc"
# La v1.5 parte esta operacion: SolicitaDescargaEmitidos (emitidos) vs SolicitaDescargaRecibidos (recibidos).
SOLICITA_ACTION = "http://DescargaMasivaTerceros.sat.gob.mx/ISolicitaDescargaService/SolicitaDescargaEmitidos"
def solicita_descarga(signed_envelope: str, token: str) -> str:
resp = requests.post(
SOLICITA_URL,
data=signed_envelope.encode("utf-8"),
headers={
"Content-Type": 'text/xml; charset="utf-8"',
"SOAPAction": SOLICITA_ACTION,
"Authorization": token, # WRAP access_token="..."
},
timeout=30,
)
resp.raise_for_status()
return extract_attr(resp.text, "SolicitaDescargaResult", "IdSolicitud")
La solicitud no puede ser instantánea: fechaInicial tiene que ser estrictamente menor que fechaFinal. Si buscas por lo que emitiste, mandas RfcEmisor con tu RFC; si buscas lo que te facturaron, mandas RfcReceptor. Antes de facturar contra un RFC, conviene validar el RFC sin la Constancia de Situación Fiscal.
Paso 3 — VerificaSolicitud: consultar EnProceso vs Terminada y juntar los ids de paquete
El SAT procesa tu solicitud de forma asíncrona, así que consultas VerificaSolicitud en bucle con el IdSolicitud. Los estados que te importan son EnProceso (sigue trabajando, espera y reintenta) y Terminada (listo, ya te da los ids de los paquetes). También existen Rechazada, Vencida y Error con sus códigos de estatus del SAT; esos son terminales y requieren manejarse aparte.
import time
VERIFICA_URL = "https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/VerificaSolicitudDescargaService.svc"
VERIFICA_ACTION = "http://DescargaMasivaTerceros.sat.gob.mx/IVerificaSolicitudDescargaService/VerificaSolicitudDescarga"
def poll_until_ready(build_envelope, id_solicitud, token, max_wait=1800):
delay, waited = 15, 0
while waited < max_wait:
resp = requests.post(
VERIFICA_URL,
data=build_envelope(id_solicitud).encode("utf-8"),
headers={
"Content-Type": 'text/xml; charset="utf-8"',
"SOAPAction": VERIFICA_ACTION,
"Authorization": token,
},
timeout=30,
)
resp.raise_for_status()
estado = extract_attr(resp.text, "VerificaSolicitudDescargaResult", "EstadoSolicitud")
if estado == "3": # Terminada
return extract_all(resp.text, "IdsPaquetes")
if estado in ("4", "5", "6"): # Error / Rechazada / Vencida
raise RuntimeError(f"Solicitud fallida, EstadoSolicitud={estado}")
time.sleep(delay) # sigue EnProceso
waited += delay
delay = min(delay * 2, 120) # espera creciente entre consultas
raise TimeoutError("La solicitud nunca llego a Terminada")
Deja una espera creciente entre consultas y vuelve a autenticar cuando el token venza. Un resultado grande se parte en varios paquetes, así que la respuesta puede traer más de un id. Los pormenores de EnProceso y los límites reales los desgloso en el post hermano de Descarga Masiva: EnProceso y límites reales.
- EnProcesoEl SAT sigue armando los paquetes; espera y reintenta con espera creciente.
- TerminadaListo: la respuesta trae los ids de paquete para bajar.
- Rechazada / Vencida / ErrorEstados terminales con código del SAT; manéjalos aparte.
Paso 4 — Descarga: bajar cada paquete como un ZIP en base64
Con los ids de paquete que te dio Terminada, llamas Descarga una vez por paquete. Cada respuesta trae el ZIP en base64 dentro del nodo <Paquete>. Lo decodificas, lo escribes a disco y adentro vienen los XML (o la metadata) que pediste.
DESCARGA_URL = "https://cfdidescargamasiva.clouda.sat.gob.mx/DescargaMasivaService.svc"
DESCARGA_ACTION = "http://DescargaMasivaTerceros.sat.gob.mx/IDescargaMasivaTercerosService/Descargar"
def descarga_paquete(build_envelope, id_paquete, token, out_dir="."):
resp = requests.post(
DESCARGA_URL,
data=build_envelope(id_paquete).encode("utf-8"),
headers={
"Content-Type": 'text/xml; charset="utf-8"',
"SOAPAction": DESCARGA_ACTION,
"Authorization": token,
},
timeout=120,
)
resp.raise_for_status()
b64_zip = extract_between(resp.text, "<Paquete>", "</Paquete>") # nodo Paquete en base64
with open(f"{out_dir}/{id_paquete}.zip", "wb") as f:
f.write(base64.b64decode(b64_zip))
Recorre todos los ids de paquete de la solicitud. Los paquetes listos viven 72 horas, así que baja pronto y persiste el IdSolicitud para retomar si tu proceso se cae. Una vez con los XML en disco, las salidas estructuradas de Claude son una forma limpia de extraer los campos de cada CFDI a tu base de datos.
Los límites reales de la v1.5 (y el tope de 2,000 al día que no existe)
Aquí es donde casi todo internet miente. El famoso tope de “2,000 CFDI al día” es falso — no lo repitas. Los límites verificados de la v1.5 son:
- Hasta 200,000 registros (CFDI) por solicitud, y hasta 1,000,000 en metadata.
- No hay tope en el número de solicitudes, mientras no descargues el mismo XML más de dos veces.
- La solicitud no puede ser instantánea:
fechaInicialtiene que ser estrictamente menor quefechaFinal. - Los paquetes/resultados listos están disponibles 72 horas.
- Un resultado grande se parte en varios paquetes.
Diséñalo como máquina de estados: persiste el IdSolicitud, usa espera creciente entre consultas de VerificaSolicitud, maneja Vencida/Error, y solo marca completado en Terminada. Para más matices, revisa el post hermano de EnProceso y límites — ahí el polling disciplinado es todo el juego, como en el tradeoff de webhooks vs polling vs API, porque el SAT no te da webhook.
No armes el XML-DSig a mano: usa phpcfdi/sat-ws-descarga-masiva o python-satcfdi
El código de arriba muestra la mecánica, pero canonicalizar y firmar el XML-DSig a mano es donde se pierde una tarde entera. Un espacio de más, un namespace fuera de orden, y el SAT rechaza el mensaje sin explicarte por qué. Usa una implementación de referencia y salta ese dolor.
La canónica en PHP es phpcfdi/sat-ws-descarga-masiva; en Python tienes python-satcfdi y cfdiclient.
# Con python-satcfdi la maquina de estados queda declarativa:
from satcfdi.pacs.sat import SAT, TipoDescargaMasivaTerceros
from datetime import date
sat = SAT(signer=fiel) # fiel = tu e.firma cargada (.cer + .key)
solicitud = sat.recover_comprobante_request(
fecha_inicial=date(2026, 1, 1),
fecha_final=date(2026, 1, 31),
rfc_emisor=fiel.rfc,
tipo=TipoDescargaMasivaTerceros.CFDI, # o Metadata
)
id_solicitud = solicitud["IdSolicitud"]
# luego recover_comprobante_status(...) hasta Terminada, y recover_comprobante_download(...)
Deja que la librería haga el WS-Security, el digest SHA1 y la firma RSA-SHA1. Tú te quedas con lo que sí es tu negocio: la máquina de estados, la espera creciente y persistir el IdSolicitud.
Ingeniero, no contador: verifica las reglas vigentes del SAT
Yo soy ingeniero y sí he puesto esto en producción contra el SAT — no soy tu contador ni tu abogado. Este post te da el flujo de 4 pasos, la firma con la e.firma y los límites reales verificados de la v1.5. Los endpoints, las versiones del servicio, los estados y los topes cambian: confírmalos contra el SAT y las implementaciones de referencia, nunca contra tu memoria.
Si vas a mandar esos comprobantes a un LLM, primero oculta el PII mexicano (CURP, RFC, CLABE) antes del LLM.
Guías relacionadas de ingeniería CFDI
Verifica siempre contra las fuentes oficiales vigentes, no contra este post:
- SAT — Servicio Web de Descarga Masiva de CFDI (documentación)
- github.com/phpcfdi/sat-ws-descarga-masiva — implementación canónica de referencia
Para el resto del ecosistema, arranca en el hub de facturación CFDI, revisa el outbox de timbrado con la misma e.firma en sentido opuesto, y baja al detalle de EnProceso y los límites reales. El flujo de 4 pasos no es difícil; la firma sí lo es — por eso lo dejas en manos de una implementación de referencia y tú te concentras en la máquina de estados.