
'Solicitud Aceptada' but nothing downloads: the SAT Descarga Masiva CodEstatus & EstadoSolicitud reference (5000, 5002, 5005)
'Solicitud Aceptada' (CodEstatus 5000) is only SolicitaDescarga's acknowledgment: it means 'received,' not 'ready.' To know when packages are downloadable, poll VerificaSolicitud with the IdSolicitud and read EstadoSolicitud (1 Aceptada, 2 EnProceso, 3 Terminada, 4 Error, 5 Rechazada, 6 Vencida); only 3, Terminada, hands you the IdsPaquetes to download.
The short answer: “Solicitud Aceptada” (5000) is the ack, not the package
Solicitud Aceptada with CodEstatus 5000 is only the acknowledgment returned by SolicitaDescarga. It means “the SAT received your request,” not “your ZIPs are ready.” Nothing is downloadable yet, and re-calling SolicitaDescarga will not make it ready faster. To learn when packages exist, poll a different operation, VerificaSolicitud, with the IdSolicitud you got back, and read the field EstadoSolicitud; only state 3 (Terminada) returns downloadable IdsPaquetes.
The six lifecycle values are 1 Aceptada, 2 EnProceso, 3 Terminada, 4 Error, 5 Rechazada, 6 Vencida. The whole service is four SOAP operations, and every response carries a numeric CodEstatus plus a Mensaje. Keep the two axes separate in your head: CodEstatus describes this call’s outcome; EstadoSolicitud describes the request’s progress over time.
If you have not wired the four steps yet, start with the 4-step Descarga Masiva walkthrough with e.firma in Python, then come back here to decode the responses.
The “aceptada != ready” trap: why your request can sit for hours
The SAT processes Descarga Masiva asynchronously. SolicitaDescarga queues work and returns immediately with 5000. A wide date range for a high-volume RFC can then sit in EnProceso for hours — sometimes what feels like a whole day — while the SAT builds the packages on its side. That wait is normal and has nothing to do with your code being wrong. (The one documented time constant is the ~72h that results live once ready, not a fixed processing time.)
The trap is what developers do next. Seeing “aceptada” again and again, they assume the request failed and re-call SolicitaDescarga with the same parameters. That does not restart anything. It creates a second identical request, which the SAT answers with 5005 (duplicada), and if you keep hammering it you eventually hit 5002 (se agotaron las solicitudes de por vida). You have now burned a lifetime quota on a request that was fine all along.
The correct pattern is: issue SolicitaDescarga once, persist the IdSolicitud, then poll VerificaSolicitud on that same id with backoff until EstadoSolicitud is 3.
import time
from cfdiclient import SolicitaDescarga, VerificaSolicitudDescarga, DescargaMasiva
# Step 1 — issue the request ONCE, then persist the id.
solicita = SolicitaDescarga(fiel)
res = solicita.solicitar_descarga(
token, rfc, fecha_inicial, fecha_final,
rfc_emisor=rfc, tipo_solicitud="Metadata", # Metadata first: far lighter than CFDI
)
id_solicitud = res["id_solicitud"] # persist this in your DB
save_request(id_solicitud, cod_estatus=res["cod_estatus"])
# Step 2 — poll the SAME id, never re-send SolicitaDescarga.
verifica = VerificaSolicitudDescarga(fiel)
delay = 30
while True:
v = verifica.verificar_descarga(token, rfc, id_solicitud)
estado = int(v["estado_solicitud"])
if estado == 3: # Terminada
paquetes = v["paquetes"]
break
if estado in (4, 5, 6): # Error / Rechazada / Vencida
raise RuntimeError(f"estado={estado} cod={v['cod_estatus']} {v['mensaje']}")
time.sleep(delay)
delay = min(delay * 2, 600) # exponential backoff, cap 10 min
Requesting Metadata first (a lightweight index) instead of full CFDI, and splitting a big range into per-day or per-week windows, is the single biggest cure for long EnProceso waits. The reasons are covered in why Descarga Masiva sits in EnProceso and the real limits — and note there is no “2,000 CFDI per day” cap on the web service; that is a manual-portal myth.
EstadoSolicitud: the six lifecycle states, and why only Terminada (3) hands you the IdsPaquetes
EstadoSolicitud is the field you branch on. VerificaSolicitud also returns a CodigoEstadoSolicitud and a NumeroCFDIs count, both worth logging. The states:
- 1 · Aceptadareceived, nothing to download yet
- 2 · EnProcesoSAT is building packages — keep polling
- 3 · TerminadaONLY here you get the IdsPaquetes
- 4 · Errorprocessing failed — inspect CodigoEstadoSolicitud
- 5 · Rechazadarequest rejected — fix params, re-request
- 6 · Vencidaresult expired (~72h) — re-request
Two consequences matter in production. First, your download code must be unreachable unless EstadoSolicitud is 3 — any earlier state has an empty IdsPaquetes, and a Vencida (6) request has packages that already expired (results live only about 72 hours after Terminada). Second, EnProceso (2) is not an error; treat it as “come back later” and keep polling with backoff. Because this is a long, stateful poll rather than a push, it is a textbook case for the tradeoffs in webhooks vs polling vs API.
CodEstatus reference: authentication and validation codes
These surface on Autenticacion and SolicitaDescarga when the request itself is malformed or your credentials are wrong. They are deterministic — the same input always fails the same way, so fix the input, do not retry.
CodEstatus |
Meaning | The fix |
|---|---|---|
300 |
Usuario no válido | The FIEL/e.firma is not authorized for this RFC, or you signed as the wrong RFC. Confirm the cert belongs to the RFC solicitante. |
301 |
XML mal formado | The request XML is invalid — commonly an invalid RfcReceptor in the query. Validate the RFC and the payload structure. |
302 |
Sello mal formado | The digital signature is malformed. Re-sign; check your canonicalization and that you signed the right node. |
303 |
Sello no corresponde con RfcSolicitante | You signed with a certificate that does not match the RFC in the request. Align cert and RfcSolicitante. |
304 |
Certificado revocado o caduco | The e.firma is revoked or expired. Renew it at the SAT. |
305 |
Certificado inválido | The certificate is not a valid FIEL (e.g. you used a CSD instead). Use the e.firma (FIEL). |
CodEstatus reference: request and download codes
These surface on SolicitaDescarga and Descarga and describe quota, results, and package state.
The fixes, one by one:
5000— nothing to fix. Persist theIdSolicitudand go pollVerificaSolicitud.5002— you sent the same identical query too many times; it is a lifetime cap on that exact request. Change the parameters or split the date range so it is a genuinely different request.5003— you exceeded the max per request. Split the range; a request can cover up to 200,000 CFDI records / 1,000,000 metadata records.5004— there simply were no CFDI in that range, so no package was generated. Widen the dates or verify the RFC actually issued/received in that window.5005— a request with the sameFechaInicial,FechaFinal,RfcEmisor,RfcReceptor, andTipoSolicitudalready exists. Look up and reuse the existingIdSolicitud; do not create a new one.5008— a package may be downloaded a maximum of 2 times. Cache the ZIP after the first successfulDescargaso you never fetch it a third time.5011— the per-folio, per-day download limit. Back off and retry the next day, or dedupe folio requests.404— an uncontrolled error on the SAT side. Retry with backoff; if it persists, it is theirs, not yours.
The two traps devs hit most: 5005 duplicada and 5002 de por vida
These two are the same root cause at different severities: re-sending SolicitaDescarga instead of polling VerificaSolicitud. The moment your acknowledgment says 5000, your job is to stop asking and start checking.
5005 — Solicitud duplicada
- Same params already requested
- FechaInicial/Final, RfcEmisor, RfcReceptor, TipoSolicitud all match
- Fix: reuse the existing IdSolicitud
- Never create a new request for the same query
5002 — Se agotaron las solicitudes de por vida
- Same identical query sent too many times
- A lifetime cap for that exact request
- Fix: change params or split the date range
- Almost always caused by re-sending instead of polling
The design fix is idempotency at the request layer: key each request by its parameter tuple, and if that key already has an IdSolicitud, poll it instead of issuing a new SolicitaDescarga. That is the same durable-job discipline described in the CFDI timbrado outbox and idempotency post, applied to the download side.
The 30-second debugging checklist: log four fields, every time
Most “no sé por qué no baja” tickets get solved in half a minute if you logged the right four fields. Log, on every poll: EstadoSolicitud, CodEstatus, IdSolicitud, and NumeroCFDIs. Together they tell you whether the request is alive, why it stalled, which id to reuse, and whether the range was even non-empty.
import logging
log = logging.getLogger("descarga_masiva")
def log_verifica(id_solicitud, v):
log.info(
"descarga_masiva verifica",
extra={
"id_solicitud": id_solicitud,
"estado_solicitud": v["estado_solicitud"], # 1..6 lifecycle
"cod_estatus": v["cod_estatus"], # numeric CodEstatus
"cod_estado_solicitud": v.get("codigo_estado_solicitud"),
"numero_cfdis": v.get("numero_cfdis"), # 0 -> nothing to download
"mensaje": v.get("mensaje"),
},
)
Reading it: EstadoSolicitud 2 with NumeroCFDIs climbing means “just wait.” EstadoSolicitud 3 with NumeroCFDIs 0 means the range was empty (that is the 5004 story) — stop polling. EstadoSolicitud 5 with a 304/305 CodEstatus points straight at your certificate. When you run this across many RFCs, that structured log becomes the backbone of the production Descarga Masiva pipeline for a despacho.
Stop hand-parsing: use the library state helpers
You do not have to memorize the integer-to-state map or hand-parse CodEstatus. Mature libraries can decode it for you, though the API differs by language. In PHP, phpcfdi/sat-ws-descarga-masiva gives you typed helpers — branch on intent, not on magic numbers:
// phpcfdi/sat-ws-descarga-masiva (PHP) — typed state helpers
$status = $verify->getStatus(); // maps the raw codes to a state
if ($status->isInProgress()) {
scheduleRetryWithBackoff();
} elseif ($status->isFinished()) { // Terminada — packages exist
downloadAllPackages($verify->getPackagesIds());
} elseif ($status->isRejected() || $status->isFailure()) {
alertEngineer($verify->getCodeRequest()); // inspect CodEstatus
} elseif ($status->isExpired()) {
reissueRequest(); // Vencida — result is gone
}
The full set is getStatus()->isAccepted(), isInProgress(), isFinished(), isFailure(), isRejected(), isExpired(), plus getCodeRequest(). In Python the maintained clients — python-satcfdi (SAT-CFDI/python-satcfdi) and cfdiclient — return the raw fields instead, so you branch on the values directly: read EstadoSolicitud, and when it equals "3" iterate the IdsPaquetes and call the download for each. Either way, keep the semantics (Terminada == packages exist) in one audited place instead of scattered if estado == 3 checks.
One caveat, in every post: I am an engineer, not a contador or abogado. Codes and limits do change — verify current behavior against the official SAT site (sat.gob.mx) and the SW knowledge base for VerificaSolicitud before you ship. For the broader picture of where this fits, the Mexican CFDI invoicing hub collects the rest of the stack.