
Bulk-download your CFDI from the SAT web service with your e.firma, in Python
It is a 4-step SOAP flow — Autenticacion, SolicitaDescarga, VerificaSolicitud, Descarga — signed with your e.firma (.cer/.key) using SHA1 digest + RSA-SHA1 (XML-DSig) inside WS-Security. Auth returns a bearer token good for ~5 minutes; you poll VerificaSolicitud until Terminada, then pull each package as a base64 ZIP.
Bulk-downloading your CFDI from the SAT: the short answer
It is a 4-step SOAP flow — Autenticacion, SolicitaDescarga, VerificaSolicitud, Descarga — signed with your e.firma (.cer/.key) using a SHA1 digest and an RSA-SHA1 signature (XML-DSig) wrapped in WS-Security. The Autenticacion call returns a bearer token good for about 5 minutes. You submit a date range, get back an IdSolicitud, poll VerificaSolicitud until the state is Terminada, then pull each ready package as a base64-encoded ZIP. No PAC, no portal scraping — you talk to the SAT directly in Python.
If you already read my CFDI timbrado outbox post, this is the mirror image: same crypto (your FIEL, SHA1, RSA-SHA1, XML-DSig), opposite direction. There you sign and send an invoice out; here you authenticate and pull invoices back in.
What the Descarga Masiva web service is (and why skip the PAC and the portal)
The Servicio Web de Descarga Masiva lets a taxpayer — or an authorized third party — bulk-download their issued (emitidas) or received (recibidas) CFDI, or just the metadata, straight from the SAT, authenticated with the e.firma (FIEL). The current version is v1.5 (2025-05-30), and it covers both CFDI and Retenciones e información de pagos.
Why use it instead of a PAC or the portal? Three reasons an engineer cares about:
- No PAC in the loop. Timbrado needs a PAC because only an authorized certifier can stamp. Retrieval does not — the SAT hands your own documents back to you directly.
- No scraping. The
buzontributario/portal download flow is a human clicking through pages. The web service is a stable, documented SOAP contract you can automate and monitor. - Volume. The portal caps you hard; the web service is built for tens of thousands of records per request.
Your e.firma: the .cer public cert and the .key private key
Your e.firma (FIEL) is two files plus a password:
.cer— your X.509v3 public certificate. It goes into the request, base64-encoded, inside aBinarySecurityTokenso the SAT knows which certificate signed the message..key— your encrypted RSA private key. It never leaves your machine; it signs the request.- The password decrypts the
.key.
In Python, load them once with cryptography:
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"YOUR_FIEL_PASSWORD")
with open("fiel.cer", "rb") as f:
cer_der = f.read()
certificate = load_der_x509_certificate(cer_der)
# The base64 blob that goes into BinarySecurityToken:
cer_b64 = base64.b64encode(cer_der).decode()
Note the SAT ships FIEL files in DER, not PEM — use load_der_*, not load_pem_*. If you get a “could not deserialize key” error, that mismatch is almost always why.
The signing you must get right: WS-Security, SHA1 digest, RSA-SHA1, BinarySecurityToken and Timestamp
Every call except the raw Descarga body is a SOAP envelope carrying a WS-Security header, and the SAT rejects a malformed message outright — no helpful error, just a fault. The pieces you must produce exactly:
- A
Timestampwith<u:Created>and<u:Expires>(I use a 5-minute window). - A
BinarySecurityTokencarrying the base64.cerunder the X509v3 value type. - A
Signatureover theTimestampusing a SHA1 digest (http://www.w3.org/2000/09/xmldsig#sha1) and an RSA-SHA1 signature (http://www.w3.org/2000/09/xmldsig#rsa-sha1). - A
SecurityTokenReferencepointing the signature back at theBinarySecurityToken.
The mechanics of the digest-then-sign are identical to timbrado: canonicalize the signed element, SHA1 it, base64 the digest, then RSA-SHA1-sign the SignedInfo block.
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(),
)
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()
Getting canonicalization (C14N) byte-exact is the part everyone loses days on. Which is exactly why the last section says: don’t hand-roll this.
Step 1 — Autenticacion: the real endpoint, the SOAPAction, and the ~5-minute token
You POST a signed, empty-bodied SOAP request to the authentication endpoint. On success the SAT returns a token whose <u:Created>/<u:Expires> are ~5 minutes apart — that is your working window for the whole flow.
- 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()
# The token lives in <AutenticaResult> in the response body.
token = extract_between(resp.text, "<AutenticaResult>", "</AutenticaResult>")
return f"WRAP access_token=\"{token}\""
That WRAP access_token="..." string is what you put in the Authorization header of the next three calls. Because the token expires in ~5 minutes, treat it as short-lived: re-authenticate per run rather than caching it across a long-poll loop.
Step 2 — SolicitaDescarga: date range, CFDI vs Metadata, emisor vs receptor, and your IdSolicitud
Now you request a batch. The signed SolicitaDescarga envelope carries:
FechaInicialandFechaFinal— the date range. It cannot be instantaneous:FechaInicialmust be strictly earlier thanFechaFinal.TipoSolicitud—CFDI(the full XML) orMetadata(just the index fields, far lighter).RfcEmisorvsRfcReceptor— issued-vs-received. SetRfcReceptorto your RFC to pull invoices received; setRfcEmisorto your RFC to pull invoices you issued.
In v1.5 the operation itself splits by direction: SolicitaDescargaEmitidos for what you issued, SolicitaDescargaRecibidos for what you received — so it is not just a different field, it is a different SOAPAction. The response gives you an IdSolicitud — persist it. That id is the handle for everything after this point.
SOLICITA_URL = "https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/SolicitaDescargaService.svc"
# v1.5 splits this operation: SolicitaDescargaEmitidos (issued) vs SolicitaDescargaRecibidos (received).
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")
Design tip: Metadata is the right first call when you just need to reconcile which UUIDs exist in a period — you get 1,000,000 records per request instead of 200,000, and you can request the full CFDI XML only for the ones you actually need to store.
Step 3 — VerificaSolicitud: polling EnProceso vs Terminada and collecting the package ids
The SAT does not build your batch synchronously. You poll VerificaSolicitud with your IdSolicitud until it is ready. The states you care about:
EnProceso— still building; wait and poll again.Terminada— done; the response now contains one or moreIdsPaquetes(package ids).- Terminal failures —
Rechazada,Vencida,Error— each with a SAT status code. Stop and log these; do not retry blindly.
Back off between polls — the batch can take minutes on a large range. A fixed short interval just hammers the endpoint.
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 failed, EstadoSolicitud={estado}")
time.sleep(delay) # still EnProceso (1/2)
waited += delay
delay = min(delay * 2, 120)
raise TimeoutError("Solicitud never reached Terminada")
- EnProcesoBatch still building — back off and poll again
- TerminadaReady — response carries the IdsPaquetes
- Rechazada / Vencida / ErrorTerminal — log the SAT status code and stop
Step 4 — Descarga: pulling each package as a base64 ZIP
For each package id from step 3, call Descarga. The response body carries the package as a base64-encoded ZIP — decode it, write it out, and unzip. A large result set is split across multiple packages, so loop over every id, not just the first.
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>")
zip_bytes = base64.b64decode(b64_zip)
path = f"{out_dir}/{id_paquete}.zip"
with open(path, "wb") as f:
f.write(zip_bytes)
return path
Ready packages are only available for 72 hours after the solicitud completes, so download promptly and persist the ZIPs somewhere durable. Once you have the XML, Claude structured outputs is a clean way to extract fields from each CFDI into your database.
The real v1.5 limits (and the 2,000-per-day cap that does not exist)
You will see a “2,000 CFDI per day” cap repeated on blogs and forum threads. It is not a real v1.5 limit — do not build around it. The actual constraints from the current SAT spec:
- Up to 200,000 records per request for
CFDI, and up to 1,000,000 forMetadata. - No cap on the number of requests, as long as you do not download the same XML more than twice.
- A request cannot be instantaneous —
FechaInicialmust be strictly beforeFechaFinal. - Ready packages are available for 72 hours.
- Large result sets are split across multiple packages.
I go deeper on the failure states and how to design around them — EnProceso timing, Vencida, splitting date ranges — in the sibling post on Descarga Masiva EnProceso and the real limits.
Don’t hand-build the XML-DSig: use phpcfdi/sat-ws-descarga-masiva or python-satcfdi
Everything above is correct, and I still would not ship a from-scratch XML-DSig signer to production. Byte-exact C14N canonicalization, the WS-Security node ordering, and the SecurityTokenReference wiring are exactly the things that fail silently against the SAT. Use a maintained library and spend your time on the state machine instead:
- phpcfdi/sat-ws-descarga-masiva — the canonical, well-tested PHP implementation. Even if you write Python, read its code; it is the reference for what a correct envelope looks like.
- python-satcfdi and cfdiclient — Python implementations of the same flow.
Wrap whichever you pick as a state machine: persist the IdSolicitud, back off between VerificaSolicitud polls, handle Vencida/Error as terminal, and only mark a run done on Terminada. That is the same discipline as the webhooks vs polling tradeoff — here the SAT gives you no webhook, so disciplined polling is the whole game.
Engineer, not contador: verify the current SAT rules
I am an engineer, not a contador or abogado. Endpoints, versions, and limits on the SAT side change, so verify the current rules against the SAT web service documentation and the phpcfdi reference implementation before you ship — do not trust a blog post (including this one) over the source of truth.
Related CFDI engineering guides
- CFDI timbrado outbox and idempotency — the outbound crypto companion to this post.
- Descarga Masiva: EnProceso and the real limits — failure states and batching in depth.
- Validate an RFC without the Constancia — another FIEL-adjacent SAT integration.
- The Mexican CFDI invoicing hub — every CFDI guide in one place.