
How to read a SAT bulk-download package: base64 to ZIP, the tilde-delimited Metadata TXT, and CFDI XML, in Python
The Descarga `Paquete` arrives base64-encoded: decode it and unzip it as a ZIP — in memory is fine. A Metadata request gives a tilde (`~`) delimited `.txt` index (UUID, RFCs, dates, Monto, EfectoComprobante, Estatus); a CFDI request gives the signed XML instead. The one trap: names contain `~`, line breaks and quotes, so `line.split("~")` misaligns the columns — parse defensively.
The short answer: the Paquete is base64 → decode it to a ZIP; Metadata is a tilde-delimited TXT index
The Descarga Paquete never arrives as a file — it comes back base64-encoded inside the Descarga response. Decode it with base64.b64decode and open the resulting bytes as a ZIP; in memory is fine. What is inside depends on your request: a Metadata request returns one tilde (~) delimited .txt index, while a CFDI request returns the signed XML comprobantes instead.
This post picks up where the 4-step e.firma download flow leaves off: you already have the Paquete string in hand — now you actually read it.
From base64 to ZIP: b64decode the Paquete and unzip it in memory
Only call Descarga once VerificaSolicitud reports EstadoSolicitud = 3 (Terminada). The Paquete element is a base64 string. Decode it to bytes and hand those bytes to zipfile — no temp file required:
import base64
import io
import zipfile
# `paquete` is the base64 string from the <Paquete> element of the Descarga response
zip_bytes = base64.b64decode(paquete)
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
names = zf.namelist()
if not names:
raise ValueError("empty ZIP: check NumeroCFDIs in VerificaSolicitud")
for name in names: # Metadata -> one .txt; CFDI -> many .xml
with zf.open(name) as f:
member = f.read() # bytes for this member
If that ZIP opens empty or raises BadZipFile, do not assume a corrupt package. Two causes dominate. First: you saved the base64 string to a .zip instead of the decoded bytes — the file is valid base64 but not a valid archive. Second: the package really is empty because NumeroCFDIs came back as 0, which is a query problem (wrong direction or a timezone-clipped date range), not a decode problem. Both are covered in the sibling post, portal shows records but Python downloads an empty ZIP. Log NumeroCFDIs and EstadoSolicitud before you ever touch b64decode and you will know which world you are in.
Metadata vs CFDI: the light index vs the full signed XML — which request to send when
You choose Metadata or CFDI when you request the download, and the choice decides what the ZIP contains. Metadata is roughly a thousand times lighter than the full comprobantes, so the production pattern is: pull Metadata first to scope the range, then pull CFDI for only the UUIDs you actually need.
Metadata request → TXT index
- One tilde-delimited .txt per package
- UUID, RFCs, names, dates, Monto, EfectoComprobante, Estatus
- About 1000x lighter than the full XML
- Use it to SCOPE: count, filter, decide
- Cannot validate sello or vigencia from it
CFDI request → signed XML
- One signed XML per comprobante
- Full comprobante: conceptos, impuestos, sello
- Heavy — pull only what you scoped
- Use it to VALIDATE and archive
- Feeds sello / vigencia / 69-B checks
Rule of thumb: reach for Metadata when you need to know what exists (reconciliation, counts, a monthly manifest), and for CFDI when you need the legally signed document to validate and store. If your date range is broad, Metadata first is not an optimization — it is the difference between one lightweight request and thousands of megabytes of XML you did not need.
The Metadata TXT format: the <UUID>-N.txt filename, the ~ delimiter, the header row, and the 12 columns
A Metadata ZIP contains one text file named <UUID>-<consecutivo>.txt, for example 622CEE0C-8BBA-4273-B02A-B8789FD27F62-0000.txt. It is tilde-delimited and carries a header row. There are 12 columns, in this fixed order:
Two columns are enumerations you will branch on constantly. Keep the mapping in code, not in your head:
EfectoComprobante
I = Ingreso (income / a normal sales invoice)
E = Egreso (credit note)
N = Nomina (payroll)
P = Pago (payment complement / REP)
T = Traslado (transfer)
Estatus
Vigente = live
Cancelado = cancelled (FechaCancelacion will be populated)
Never hardcode a column by position without asserting the header first — the safest contract is “there is a header row and there are 12 fields per data row,” and you verify both at parse time.
The parsing gotcha: ~, CR/LF and quotes inside names break line.split("~")
Here is the failure that fills GitHub issues (see phpcfdi/sat-ws-descarga-masiva #23). NombreEmisor and NombreReceptor are free text straight from the taxpayer registry, and they can legally contain the ~ delimiter, embedded carriage returns and line feeds, and stray quotes. A row where a name contains a ~ splits into 13 or 14 fields, so Monto, EfectoComprobante and Estatus all shift one slot to the right and you silently book garbage.
The defensive contract is simple: trust no row that does not yield exactly 12 fields, and repair the ones that do not by anchoring from both ends.
import re
COLUMNS = [
"Uuid", "RfcEmisor", "NombreEmisor", "RfcReceptor", "NombreReceptor",
"RfcPac", "FechaEmision", "FechaCertificacionSat", "Monto",
"EfectoComprobante", "Estatus", "FechaCancelacion",
]
N = len(COLUMNS) # 12
RFC = re.compile(r"^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$")
def clean(v: str) -> str:
# CR/LF and quotes leak in from the Nombre* fields
return v.replace("\r", " ").replace("\n", " ").strip().strip('"')
def parse_metadata(text: str):
good, suspect = [], []
for line in text.splitlines()[1:]: # skip the header row
if not line.strip():
continue
fields = line.split("~")
if len(fields) == N:
good.append(dict(zip(COLUMNS, (clean(x) for x in fields))))
else:
suspect.append(fields) # a name leaked a delimiter
return good, suspect
For the suspect rows, exploit the fact that only the two name fields are dirty. The first two columns (Uuid, RfcEmisor) are clean, and the last seven (RfcPac through FechaCancelacion) are clean; everything between is NombreEmisor ~ RfcReceptor ~ NombreReceptor, and RfcReceptor is a well-formed RFC you can find:
def repair(fields: list[str]) -> dict:
if len(fields) <= N:
raise ValueError("too few fields — likely an embedded newline, merge lines first")
uuid, rfc_emisor = fields[0], fields[1]
tail = fields[-7:] # RfcPac ... FechaCancelacion
middle = fields[2:-7] # NombreEmisor ~ RfcReceptor ~ NombreReceptor
idx = next(i for i, v in enumerate(middle) if RFC.match(v.strip()))
row = [uuid, rfc_emisor,
"~".join(middle[:idx]), # NombreEmisor (rejoined)
middle[idx], # RfcReceptor
"~".join(middle[idx + 1:]), # NombreReceptor (rejoined)
*tail]
return dict(zip(COLUMNS, (clean(x) for x in row)))
One more edge: an embedded CR/LF inside a name splits one logical record across two physical lines, so a suspect row can come up with fewer than 12 fields. When that happens, buffer the short line and join it with the next before splitting, then re-run the field count. Handle it explicitly — do not let a short row poison the offset of the row after it.
Load the rows into a list or DataFrame to filter, count and scope before you pull XML
Once parse_metadata hands you clean dicts, drop them into a DataFrame (or just a list of dicts) and answer the scoping questions before requesting a single CFDI:
import pandas as pd
from decimal import Decimal
good, suspect = parse_metadata(member.decode("utf-8"))
df = pd.DataFrame(good)
df["Monto"] = df["Monto"].map(Decimal) # never float for money
vigentes = df[df["Estatus"] == "Vigente"]
ingresos = vigentes[vigentes["EfectoComprobante"] == "I"]
uuids_to_pull = ingresos["Uuid"].tolist()
print(len(uuids_to_pull), "UUIDs to request as CFDI")
This is exactly the manifest you want to persist per RFC in a multi-RFC production pipeline: a lightweight, deduplicated index that tells you what to fetch, what already lives in your archive, and what changed status since the last run.
The CFDI XML side: parse each signed XML in Python — pull UUID/RFC/Total from the TimbreFiscalDigital
A CFDI request returns a ZIP of signed XML — one comprobante per file — unzipped the same way as above. For a quick manifest, parse the identifying fields with ElementTree. The Total and RFCs live on the Comprobante, Emisor and Receptor nodes; the UUID lives in the TimbreFiscalDigital complement:
import xml.etree.ElementTree as ET
NS = {
"cfdi": "http://www.sat.gob.mx/cfd/4",
"tfd": "http://www.sat.gob.mx/TimbreFiscalDigital",
}
def read_cfdi(xml_bytes: bytes) -> dict:
root = ET.fromstring(xml_bytes) # <cfdi:Comprobante ...>
emisor = root.find("cfdi:Emisor", NS)
receptor = root.find("cfdi:Receptor", NS)
tfd = root.find("cfdi:Complemento/tfd:TimbreFiscalDigital", NS)
return {
"uuid": tfd.get("UUID") if tfd is not None else None,
"rfc_emisor": emisor.get("Rfc"),
"rfc_receptor": receptor.get("Rfc"),
"total": root.get("Total"), # keep as string -> Decimal
"fecha": root.get("Fecha"),
}
Reading a few attributes is not validation. Extracting UUID, Rfc and Total tells you what the document claims — it says nothing about whether the sello verifies, whether the CSD was vigente at stamping, or whether the emisor sits on the SAT 69-B list. That is a separate job with its own post: validate a received CFDI — XML, sello, vigencia and 69-B. Read the fields here; validate there. Do not re-implement the crypto in your download script.
The clean flow: Metadata index → decide → pull and validate the CFDI
Put it together and the whole read path is five stages — light index in, decision in the middle, heavy signed documents out only for what survived the filter:
If your index comes back empty when the portal clearly shows invoices, that is upstream of parsing — check the direction and date range against the status and error-code guide and the empty-package fix before you suspect your reader. Everything here sits under the Mexican CFDI invoicing hub if you want the wider map.
Caveat: I am an engineer, not a contador or abogado. The ~ format, the column order and the enumeration codes are what the packages return in practice, but the SAT changes specifications; verify the current web-service layout against sat.gob.mx and cross-check the reference implementations at phpcfdi/sat-ws-descarga-masiva and SAT-CFDI/python-satcfdi before you ship anything that books money.