
Why Your SAT Descarga Masiva Sits at "EnProceso" — and Why the 2,000-CFDI/Day Cap Is a Myth
Your request sits at EnProceso because SAT processes descarga masiva asynchronously: you poll VerificaSolicitud (with backoff, not a tight loop) until it flips to Terminada, then download the packages within 72 hours. There is no 2,000-CFDI/day cap on the Web Service — that limit is the manual portal. The real v1.5 limits are 200,000 records per CFDI request, 1,000,000 in metadata.
The Short Answer: EnProceso Is the Async Model Working, Not a Bug
Your Descarga Masiva request sits at EnProceso because the SAT web service is asynchronous: SolicitaDescarga only registers the job and hands you an IdSolicitud — the SAT builds your packages in the background. You poll VerificaSolicitud (with backoff, not a tight loop) until the state flips to Terminada, then you download each package within the 72-hour availability window. Nothing is broken; you’re just watching a queue.
And no, there is no 2,000-CFDI/day cap on the web service — that number people quote is the limit on the manual portal, not the Web Service de Descarga Masiva. The real v1.5 limits are 200,000 records per CFDI request and 1,000,000 in metadata.
I’m an engineer who ships this in production, not your contador or abogado. SAT rules change; verify the fiscal specifics against sat.gob.mx and the canonical phpcfdi/sat-ws-descarga-masiva reference. What follows is the systems layer.
Debunking the “2,000 CFDI/Day” Myth
If you searched this problem, you’ve seen the claim that Descarga Masiva is capped at 2,000 CFDI per day. It gets repeated in forums, blog comments, and even some vendor KBs. It is wrong for the web service.
That cap belongs to the manual portal — the browser flow where a human clicks through the SAT site to pull invoices. The Servicio Web de Descarga Masiva, authenticated with your e.firma (FIEL), has no such per-day limit. What it does have is a per-request record ceiling and a rule against re-downloading the same file endlessly.
The myth (manual portal)
- "2,000 CFDI per day"
- Applies to the browser portal only
- Not a Web Service limit
- Leads engineers to build fake daily throttles
The real web service (v1.5)
- 200,000 records per CFDI request
- 1,000,000 records per metadata request
- No cap on number of requests
- Rule: don't download the same XML more than twice
Build against the real limits below, not the phantom one. If you throttle your worker to 2,000/day you will take weeks to pull a year of a busy RFC that you could pull in an afternoon.
The Four-Step Flow as a State Machine
The service is four operations, and it’s cleanest to model them as a state machine you drive forward. Version v1.5 (2025-05-30) covers CFDI and Retenciones e información de pagos.
- AutenticaciónSign a SOAP request with your FIEL, get a bearer token (~5 min TTL)
- SolicitaDescargaSubmit date range + tipoSolicitud (CFDI or Metadata), get back IdSolicitud
- VerificaSolicitudPoll with IdSolicitud until EnProceso becomes Terminada; get package ids
- DescargaDownload each paquete as a base64-encoded ZIP within 72h
- Autenticación — POST a WS-Security-signed SOAP envelope to obtain a token. Endpoint:
https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc, SOAPActionhttp://DescargaMasivaTerceros.gob.mx/IAutenticacion/Autentica. The response token is a bearer valid ~5 minutes — its<u:Created>and<u:Expires>are five minutes apart. - SolicitaDescarga — submit
fechaInicial/fechaFinal,tipoSolicitud(CFDIorMetadata), and issued-vs-received viaRfcEmisor/RfcReceptor. You get back anIdSolicitud. - VerificaSolicitud — poll with the
IdSolicitud. WhenTerminada, the response carries the ids of the ready paquetes. - Descarga — pull each package by id as a base64-encoded ZIP.
The signing detail that trips people up: WS-Security uses SHA1 digest and RSA-SHA1 signature (http://www.w3.org/2000/09/xmldsig#rsa-sha1), a Timestamp, and a BinarySecurityToken carrying the base64 .cer (X509v3 profile). The .key does the signing. A malformed envelope is rejected outright — no partial acceptance.
Why It’s EnProceso, and How Long
SolicitaDescarga returns almost immediately, but it has done nothing except enqueue your job. The SAT then walks its own storage, materializes the matching CFDI or metadata, and splits large result sets into multiple packages. That work happens in the background, which is exactly why VerificaSolicitud reports EnProceso for a while.
How long? Usually minutes to a couple of hours. Occasionally — big date ranges, busy RFCs, or SAT load — it can take longer, and packages remain available for 72 hours once ready. There is no SLA you can rely on, so design for it being slow, not fast.
The critical implementation rule: poll with backoff, don’t tight-loop. Hammering VerificaSolicitud every second wastes your 5-minute token, adds nothing, and looks abusive. Poll on an increasing interval.
import time
def poll_intervals(max_wait_s=6 * 3600):
"""Yield sleep seconds with exponential backoff, capped at 5 min."""
delay, waited = 30, 0
while waited < max_wait_s:
yield delay
waited += delay
delay = min(delay * 2, 300) # 30s, 60s, 120s, 240s, then 300s
The Real v1.5 Limits — and Nothing You Can’t Verify
Here are the numbers that actually govern the web service. Do not invent others.
Read those carefully:
- A single CFDI request tops out at 200,000 records. A single Metadata request tops out at 1,000,000.
- There is no cap on how many requests you make — the only constraint is that you must not download the same XML more than twice. Treat every package as download-once; persist it and never re-pull.
- Ready results live for 72 hours. Miss the window and you re-request from scratch.
- A request can’t be instantaneous:
fechaInicialmust be strictly less thanfechaFinal.
There is no per-day quota, and no folio-based flow that skips the date range — a UUID is only an optional filter on the standard by-date request, never a standalone download-by-UUID operation. If a snippet you found references a daily limit or treats SolicitudDescargaFolio as a way around the date range, it’s off the verified path — stick to the by-date-range flow.
Metadata vs CFDI Requests: Scope First, Then Pull
Because a CFDI request is capped at 200,000 records but metadata reaches 1,000,000, the pragmatic pattern is metadata-first. Pull metadata for your date range to learn how many CFDI exist and when they cluster, then issue CFDI requests for the windows you actually need.
Metadata is small — UUID, RFCs, totals, dates — so it’s cheap to scope a whole year in one or a few requests. Once you know a given month holds, say, 340,000 CFDI, you already know it exceeds the 200,000 single-request ceiling and must be split. You’ve turned a guessing game into arithmetic before spending a single heavy CFDI request.
# 1) Scope with metadata (up to 1,000,000 records/request)
meta_req = solicita_descarga(
token, rfc,
fecha_inicial="2026-01-01T00:00:00",
fecha_final="2026-12-31T23:59:59",
tipo_solicitud="Metadata",
rol="emisor", # RfcEmisor = your RFC → issued CFDI
)
# 2) Read the counts, decide your CFDI windows, then request CFDI per window.
Engineering Around the Real Limits: Split the Range Into Windows
Since there’s no daily cap, you don’t throttle by day — you split by volume. When a window would exceed 200,000 CFDI, cut it into smaller date sub-windows until each fits. Monthly is a sane default for most RFCs; drop to weekly or daily for high-volume issuers.
from datetime import date, timedelta
def month_windows(start: date, end: date):
"""Yield (inicio, fin) month sub-ranges; fechaInicial < fechaFinal always."""
cur = start.replace(day=1)
while cur <= end:
nxt = (cur.replace(day=28) + timedelta(days=4)).replace(day=1)
win_end = min(nxt - timedelta(days=1), end)
yield (
f"{cur.isoformat()}T00:00:00",
f"{win_end.isoformat()}T23:59:59",
)
cur = nxt
Each window becomes its own independent SolicitaDescarga job with its own IdSolicitud. Because there’s no request cap, you can have many in flight at once — just remember every package is download-at-most-twice, so persist aggressively.
The States You Must Handle: A Decision Table
VerificaSolicitud returns a state, and your worker needs a branch for each. Treat unknown states as retryable-with-ceiling, never as success.
EnProceso— normal; sleep per your backoff and poll again.Terminada— download every listed package, then mark the job done.Rechazada— the SAT refused the request; fix the cause and submit a new one.Vencida— the 72-hour window elapsed; the packages are gone, re-request the range.Error— capture the SAT status code, back off, and retry with a ceiling before alerting.
Why Requests Come Back Rechazada
Rechazada almost always traces to one of three self-inflicted causes:
- Bad signature. WS-Security must use RSA-SHA1/SHA1 with a valid
Timestampand aBinarySecurityTokenholding your base64 .cer. A wrong digest algorithm, an expired cert, or a clock-skewed timestamp gets you rejected outright. fechaInicial >= fechaFinal. The range must be strictly increasing; an equal or inverted range is invalid.- Expired 5-minute token. If you authenticate, then dawdle before
SolicitaDescarga, the bearer’s<u:Expires>passes and the call fails. Authenticate immediately before you need the token, and re-auth per job rather than caching one across a long batch.
# Guard the two you control before you ever hit the wire.
assert fecha_inicial < fecha_final, "fechaInicial must be strictly before fechaFinal"
token = autenticar(cer, key) # fresh token, ~5 min TTL
solicitud = solicita_descarga(token, ...) # use it right away
Idempotent, Resumable Jobs
Descarga Masiva is a long-running distributed job, so make it crash-safe. Persist the IdSolicitud the moment SolicitaDescarga returns — it’s the only handle to your queued work, and losing it means re-requesting the whole range. Store each job’s state and only mark it done on Terminada. This is the same discipline as the outbound side in the CFDI timbrado outbox: a durable row, an idempotent worker, exactly-once effect.
CREATE TABLE descarga_job (
id_solicitud TEXT PRIMARY KEY, -- from SolicitaDescarga; persist immediately
rfc TEXT NOT NULL,
tipo TEXT NOT NULL, -- 'CFDI' | 'Metadata'
fecha_inicial TIMESTAMPTZ NOT NULL,
fecha_final TIMESTAMPTZ NOT NULL,
estado TEXT NOT NULL DEFAULT 'EnProceso',
next_poll_at TIMESTAMPTZ NOT NULL,
paquetes_done TEXT[] NOT NULL DEFAULT '{}', -- ids already downloaded (never re-pull)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
On restart, the worker re-reads open jobs, resumes polling from next_poll_at, and skips any package id already in paquetes_done — which enforces the never-download-the-same-XML-more-than-twice rule for free.
Python Skeleton: The Poll-and-Download Worker
The full loop, end to end. Signing and transport are elided to a client (github.com/SAT-CFDI/python-satcfdi or cfdiclient do the WS-Security work); the state machine is yours.
import time
def run_job(client, job, store):
"""Drive one Descarga Masiva job from EnProceso to downloaded."""
for sleep_s in poll_intervals():
token = client.autenticar(job.cer, job.key) # fresh ~5-min token per poll
res = client.verifica_solicitud(token, job.id_solicitud)
if res.estado == "Terminada":
token = client.autenticar(job.cer, job.key) # re-auth before downloads
for pid in res.paquetes:
if pid in job.paquetes_done:
continue # never download the same XML twice
zip_bytes = client.descarga(token, pid) # base64 ZIP -> bytes
store.save(job.id_solicitud, pid, zip_bytes)
job.paquetes_done.append(pid)
store.update(job)
store.mark_done(job)
return "done"
if res.estado in ("Rechazada", "Vencida", "Error"):
store.mark_failed(job, res.estado, res.codigo_estatus)
return res.estado # re-request or fix upstream
# EnProceso (or unknown) -> back off and poll again
store.set_next_poll(job, seconds=sleep_s)
time.sleep(sleep_s)
store.mark_timed_out(job)
return "timeout"
Notice the two auth calls: one to verify, a fresh one before downloading, because the token’s ~5-minute life can lapse while packages are large. Persist after every package. Only Terminada with all packages saved is success.
Where This Fits
This is the inbound companion to the outbound stamping pipeline. Read it alongside the four-step Descarga Masiva deep dive for the WS-Security signing details, the CFDI timbrado outbox for the exactly-once discipline on the way out, and webhooks vs polling vs API for why an async poll-with-backoff loop is the right shape here — SAT gives you no webhook, so polling is the contract. It all hangs off the Mexican CFDI invoicing hub, which sits under the broader LATAM payments work.
Ground truth for every number above: the SAT WS spec on sat.gob.mx and the canonical phpcfdi/sat-ws-descarga-masiva implementation. When in doubt, verify there — not against a forum post quoting a 2,000/day cap that never applied to the web service.