Bulk-download your CFDI from the SAT web service with your e.firma, in Python — Cesar Ayala
← All posts

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.
Autenticacion → token
SolicitaDescarga → IdSolicitud
VerificaSolicitud → package ids
Descarga → base64 ZIPs

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 a BinarySecurityToken so 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 Timestamp with <u:Created> and <u:Expires> (I use a 5-minute window).
  • A BinarySecurityToken carrying the base64 .cer under the X509v3 value type.
  • A Signature over the Timestamp using 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 SecurityTokenReference pointing the signature back at the BinarySecurityToken.

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.

Endpointcfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc
SignatureSHA1 digest + RSA-SHA1 over the Timestamp
ReturnsBearer token, ~5 min lifetime

Step 2 — SolicitaDescarga: date range, CFDI vs Metadata, emisor vs receptor, and your IdSolicitud

Now you request a batch. The signed SolicitaDescarga envelope carries:

  • FechaInicial and FechaFinal — the date range. It cannot be instantaneous: FechaInicial must be strictly earlier than FechaFinal.
  • TipoSolicitudCFDI (the full XML) or Metadata (just the index fields, far lighter).
  • RfcEmisor vs RfcReceptor — issued-vs-received. Set RfcReceptor to your RFC to pull invoices received; set RfcEmisor to 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 more IdsPaquetes (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")
  1. EnProcesoBatch still building — back off and poll again
  2. TerminadaReady — response carries the IdsPaquetes
  3. 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 for Metadata.
  • No cap on the number of requests, as long as you do not download the same XML more than twice.
  • A request cannot be instantaneousFechaInicial must be strictly before FechaFinal.
  • Ready packages are available for 72 hours.
  • Large result sets are split across multiple packages.
CFDI per request200,000
Metadata per request1,000,000
Mythical daily capdoes not exist

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.