
The SAT portal shows your CFDI but your Descarga Masiva returns an empty package: diagnose it with NumeroCFDIs, in Python
The deciding value is NumeroCFDIs in VerificaSolicitud — log it with EstadoSolicitud. If it's 0, your web-service query isn't the portal query: rfcSolicitante must match RfcEmisor for emitidas (issued) or RfcReceptor for recibidas (received), with full-day dates in Mexico central time. If the count matches but the ZIP is empty, you never base64-decoded the Paquete.
The portal has your CFDI but the ZIP is empty: the short answer
If the SAT portal shows CFDI for a date range but your Python download returns an empty ZIP, one logged value tells you why: NumeroCFDIs in the VerificaSolicitud response. If it is 0, your web-service query is not the portal’s query — the wrong RFC role and direction, or a date range shifted by timezone. If the count matches the portal but the ZIP still opens empty, you never base64-decoded the Paquete. Same symptom, two different fixes, and one integer tells you which.
The symptom: the portal has records, the web service returns nothing
Here is the report I keep seeing, almost word for word: “The SAT portal shows my CFDI for that date range. I run the same range through the Descarga Masiva web service in Python and I get an empty package — or the request just never finishes.” It feels like the SAT is lying to you, or the library is broken.
It is neither. This is a documented, recurring failure mode — python-satcfdi issue #49 is exactly this: the portal has records, yet VerificaSolicitud comes back with NumeroCFDIs: 0, IdsPaquetes: [], and EstadoSolicitud: 1. The web service and the portal are answering two different questions, and the portal’s answer is not the one your code asked for.
The mistake most people make here is to start rewriting the SOAP envelope, swapping libraries, or filing a bug. Don’t. Before you touch anything, log one number.
If you have not built the 4-step flow yet, start with the SAT Descarga Masiva walkthrough with e.firma — this post assumes you already have Autenticacion, SolicitaDescarga, VerificaSolicitud, and Descarga wired up and are debugging why the result is empty.
The one-value diagnosis: log NumeroCFDIs and EstadoSolicitud on every poll
VerificaSolicitud returns two fields that, together, tell you everything: EstadoSolicitud (where the request is in its lifecycle) and NumeroCFDIs (how many comprobantes the SAT actually matched for your query). Most people log the state and ignore the count. The count is the diagnostic.
def log_verifica(resp_text):
estado = extract_attr(resp_text, "VerificaSolicitudDescargaResult", "EstadoSolicitud")
num = extract_attr(resp_text, "VerificaSolicitudDescargaResult", "NumeroCFDIs")
paquetes = extract_all(resp_text, "IdsPaquetes")
print(f"EstadoSolicitud={estado} NumeroCFDIs={num} IdsPaquetes={paquetes}")
return estado, int(num or 0), paquetes
Now read the split:
NumeroCFDIsis 0 — the SAT matched nothing. Your query is not the portal’s query. There is no package to download because there is nothing to package. Fix the request.NumeroCFDIsmatches the portal (say, 15) but the ZIP you save is empty or corrupt — the SAT built the package correctly and you mishandled the download. Fix the decode, not the query.
NumeroCFDIs = 0
- The SAT matched zero comprobantes
- Your WS query is not the portal query
- RFC role/direction or date/timezone is wrong
- There is nothing to download — fix the request
NumeroCFDIs matches, ZIP empty
- The SAT built the package correctly
- You mishandled the download
- You never base64-decoded the Paquete
- Fix the decode, not the query
Case 1, NumeroCFDIs = 0: the RFC role and direction trap
This is the common one, and it is nasty because it fails silently — no SOAP fault, no error code, just a legitimate-looking Terminada response with a count of 0.
The web service is directional. There are two separate operations: SolicitaDescargaEmitidos for what you issued, and SolicitaDescargaRecibidos for what you received. And the RFC you sign with — rfcSolicitante, your FIEL’s RFC — has to land in the right slot for that direction:
- For emitidas (issued),
rfcSolicitantemust matchRfcEmisor. You are the emisor of your own invoices. - For recibidas (received),
rfcSolicitantemust matchRfcReceptor. You are the receptor.
Get this backwards and the SAT dutifully answers a question you did not mean to ask. A recibidas query filtered by RfcReceptor = your RFC will never return a CFDI you issued to a customer — because on that invoice, you are the emisor, not the receptor. The filter is valid, the result is empty, and nobody tells you it was the wrong direction.
# Issued (emitidas): you are the emisor.
solicitud_emitidas = {
"RfcSolicitante": MY_RFC,
"RfcEmisor": MY_RFC, # <-- your RFC goes here for issued
"TipoSolicitud": "Metadata",
}
# Received (recibidas): you are the receptor.
solicitud_recibidas = {
"RfcSolicitante": MY_RFC,
"RfcReceptor": MY_RFC, # <-- your RFC goes here for received
"TipoSolicitud": "Metadata",
}
The portal defaults to showing you one direction with your RFC already in the right role, so it “just works” there. Your code has to be explicit about it.
Case 1, the other half: full-day dates and Mexico central time
The second way to silently match zero is the date range. Two traps live here:
Send full-day datetimes, not bare dates. The range fields want a datetime. If you send a date-only value, or a range that collapses the day, you can exclude comprobantes stamped later in the day. Anchor the range to the full day:
fecha_inicial = "2026-06-01T00:00:00"
fecha_final = "2026-06-30T23:59:59"
Watch the timezone. The SAT works in Mexico central time. If your process runs in UTC (most servers and most CI do), a naive datetime.now() or a .isoformat() that carries a UTC offset can shift your window by hours — enough to drop the first or last records of a period, or, on a single-day query, to miss everything. Build the range explicitly in the SAT’s timezone and send it without a trailing offset the service does not expect:
from datetime import datetime
from zoneinfo import ZoneInfo
MX = ZoneInfo("America/Mexico_City")
start = datetime(2026, 6, 1, 0, 0, 0, tzinfo=MX)
end = datetime(2026, 6, 30, 23, 59, 59, tzinfo=MX)
fecha_inicial = start.strftime("%Y-%m-%dT%H:%M:%S")
fecha_final = end.strftime("%Y-%m-%dT%H:%M:%S")
Between the RFC role and the date range, one of these two is almost always why NumeroCFDIs came back 0. Neither raises an error — which is exactly why logging the count is the fastest path to the truth.
Case 2, NumeroCFDIs matches but the ZIP is empty: decode the Paquete
Now the other branch. NumeroCFDIs says 15, matching the portal — the SAT found your records and built the package. You download it, open the .zip, and it is empty or “not a valid archive.” This is a decode bug, not a query bug.
The Descarga response returns the Paquete as a base64-encoded string. You have to decode it to bytes and write the bytes to disk. The single most common mistake is saving the base64 string directly — the file then contains text, not a ZIP, and every unzip tool reports it as empty or corrupt.
import base64
def save_paquete(resp_text, id_paquete, out_dir="."):
b64 = extract_between(resp_text, "<Paquete>", "</Paquete>")
zip_bytes = base64.b64decode(b64) # string -> bytes. Do NOT skip this.
path = f"{out_dir}/{id_paquete}.zip"
with open(path, "wb") as f: # "wb": write BYTES, not text
f.write(zip_bytes)
return path
Two more things that produce the same empty-ZIP symptom:
- Only call
DescargaonceEstadoSolicitudis 3 (Terminada). If you download while the request is stillEnProceso, there is no package yet. - Loop over every id in
IdsPaquetes. A large result set is split across multiple packages; grabbing only the first looks “half empty.”
- Wait for EstadoSolicitud = 3Terminada — never call Descarga before this
- Read the Paquete nodeIt is a base64 string, not bytes
- base64.b64decode(paquete)Turn the string into real ZIP bytes
- Write bytes to a .zipSave the string instead and the zip opens empty
Once the ZIP opens, the contents depend on TipoSolicitud: Metadata gives you a tilde-delimited .txt index, CFDI gives you the signed XML files. Parsing both — including the ~ delimiter gotcha where NombreEmisor can contain control characters — is its own post: read the Descarga Masiva package in Python.
The 30-second test: replicate the exact portal query, in Metadata
Here is the drill I run before debugging anything. It turns a vague “it’s empty” into a precise answer:
- Replicate the portal query exactly. Same RFC, same direction (issued vs received), same date range. Do not “improve” it.
- Request
Metadata, notCFDI. Metadata is roughly 1000x lighter than the full CFDI, so the round trip is fast and you are testing the filter, not moving megabytes. You only need the count to diagnose. - Log
EstadoSolicitudandNumeroCFDIs. That is the whole test.
If NumeroCFDIs is 0, you are in Case 1 — go fix the RFC role or the date range. If it matches the portal, you are in Case 2 — go fix the decode. You will know which within one poll, without rewriting a single line of your SOAP signer.
Scope with Metadata as a habit, not just when debugging — it is how you confirm which UUIDs exist in a period before you pull the heavy XML. If your requests are stuck in EnProceso or hitting real caps rather than returning 0, that is a different problem, covered in EnProceso and the real limits, and the SAT status and error codes are catalogued in the status and error-code post.
The mindset: a shared symptom is usually a shared mistake, not a library bug
The most useful thing I can leave you with is a debugging heuristic. When you search this symptom, you will find the same issue filed against multiple libraries by different people — the same NumeroCFDIs: 0, the same empty ZIP. When many independent users hit an identical failure against well-worn code like phpcfdi/sat-ws-descarga-masiva and python-satcfdi, the base rate says it is a shared mistake in how the service is being called — the direction filter, the date range, the missing base64 decode — not a shared bug in libraries that thousands of taxpayers download from successfully every day.
So resist the urge to fork the library. Log the two values, reproduce the exact portal query in Metadata, and let NumeroCFDIs route you to the real cause. It almost always does.
Engineer, not contador: verify against the source
I am an engineer, not a contador or abogado. Endpoints, the web-service version, the field roles, and the limits on the SAT side change, so verify the current behavior against the SAT web-service documentation and the phpcfdi reference implementation before you ship — don’t trust a blog post (including this one) over the source of truth. Every CFDI guide I have is collected in the Mexican CFDI invoicing hub.