Why Your SAT Descarga Masiva Sits at "EnProceso" — and Why the 2,000-CFDI/Day Cap Is a Myth — Cesar Ayala
← All posts

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.

  1. AutenticaciónSign a SOAP request with your FIEL, get a bearer token (~5 min TTL)
  2. SolicitaDescargaSubmit date range + tipoSolicitud (CFDI or Metadata), get back IdSolicitud
  3. VerificaSolicitudPoll with IdSolicitud until EnProceso becomes Terminada; get package ids
  4. DescargaDownload each paquete as a base64-encoded ZIP within 72h
  1. Autenticación — POST a WS-Security-signed SOAP envelope to obtain a token. Endpoint: https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc, SOAPAction http://DescargaMasivaTerceros.gob.mx/IAutenticacion/Autentica. The response token is a bearer valid ~5 minutes — its <u:Created> and <u:Expires> are five minutes apart.
  2. SolicitaDescarga — submit fechaInicial/fechaFinal, tipoSolicitud (CFDI or Metadata), and issued-vs-received via RfcEmisor/RfcReceptor. You get back an IdSolicitud.
  3. VerificaSolicitud — poll with the IdSolicitud. When Terminada, the response carries the ids of the ready paquetes.
  4. 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.

CFDI per requestup to 200,000 records
Metadata per requestup to 1,000,000 records
Package availability72 hours after Terminada
Same XMLnever download it more than twice

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: fechaInicial must be strictly less than fechaFinal.

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 — keep polling with backoffretry
Terminada — download the paquetes nowdone
Rechazada — fix inputs, re-requestfix
Vencida / Error — re-request or inspect SAT coderecover
  • 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:

  1. Bad signature. WS-Security must use RSA-SHA1/SHA1 with a valid Timestamp and a BinarySecurityToken holding your base64 .cer. A wrong digest algorithm, an expired cert, or a clock-skewed timestamp gets you rejected outright.
  2. fechaInicial >= fechaFinal. The range must be strictly increasing; an equal or inverted range is invalid.
  3. 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.