How to Download SEC Filing Exhibits at Scale

You want to retrieve a particular exhibit from an SEC filing, but how do you do it programmatically, without experiencing IP blocks, and at scale?

In this article, you will learn:

  • Why SEC exhibit filenames cannot be guessed or constructed from a pattern
  • The rate-limit math that makes brute-forcing EDGAR impractical at scale
  • The complete exhibit code map — EX-21, EX-10, EX-99.1, EX-16 and beyond
  • Worked examples for earnings press releases, subsidiary lists, material contracts and XBRL linkbases
  • How to handle amended filings and the edge cases that silently corrupt a dataset
  • And lastly, I will show you the proper way to tackle this — with a free SEC exhibits API

Picture this: you are building a pipeline to pull data from SEC filings. The 10-K body downloads fine. The 10-Q downloads fine. Then you try to grab an exhibit — a subsidiary list, a material contract, an earnings press release — and discover that the filename is completely arbitrary. There is no naming pattern. You cannot construct the URL. The only way to find it is to fetch and parse an index page for every single filing you want, against a rate limit that will not budge.

This is where most home-grown EDGAR scrapers quietly give up on exhibits. In this article, I will walk you through exactly why that happens, what the highest-value exhibit types are, and how to solve the whole problem with a single API call per filing.

Why exhibit retrieval breaks home-grown EDGAR scrapers

If you have ever built an EDGAR scraper, you probably started with the primary document — the 10-K body, the 10-Q, the 8-K. That part usually works. Primary documents tend to be the first file in the index, and their names often follow something resembling a pattern. Your scraper runs, your tests pass, you feel good about it. Then you try to pull the exhibits, and everything that was working quietly stops.

Filenames are arbitrary. Apple’s EX-21 in the FY2025 10-K is a10-kexhibit21109272025.htm. There is no rule that generates that string — I cannot derive it, you cannot derive it, and no one published a naming convention for it. Another filer calls the same exhibit ex21-1.htm. Another uses exhibit211.txt. Another uses d123456dex211.htm. The consequence is simple and absolute: you cannot construct the URL for an exhibit. You have to look it up.

So you fetch the filing index and parse it. That means an extra HTTP request for every filing, an HTML table parse, and a fragile mapping from a human-written description column to the document type you want. Some filers write “EX-21.1” in that column. Others write “Exhibit 21.1.” Others write “Subsidiaries of the Registrant.” Others leave the description blank. Your classifier needs to handle all of these, and the failure mode is not an error — it is a silent miss, a filing that looks like it has no EX-21 when it actually does.

Or you split the SGML container yourself. Every EDGAR submission is also available as a single monolithic SGML file, with <DOCUMENT>, <TYPE> and <FILENAME> blocks. It is parseable, and plenty of production pipelines take this route — but now you are maintaining an SGML parser, handling uuencoded binary blocks in older filings, and downloading a 30 MB blob to extract an 11 KB subsidiary list. It works, but the ratio of infrastructure to insight is hard to justify once you step back and look at it.

The rate limit that ends the free approach

Suppose you have solved the parsing problem — your index parser handles every description variant, your classifier is airtight, and your error handling is solid. It still will not matter, because the arithmetic catches up before the engineering does.

The SEC caps automated EDGAR access at 10 requests per second, per source IP address, across every EDGAR domain. It counts by IP regardless of how many machines sit behind it. Exceed the limit and you receive a 403 Forbidden and a roughly 10-minute block on the entire address — which behind a corporate NAT or shared VPC gateway takes your colleagues’ services down along with yours. Every request must also declare a User-Agent naming a real contact; library defaults are rejected outright.

Now count the requests. Retrieving one exhibit “properly” from EDGAR means fetching the filing index, parsing it, then fetching the file itself — 3 or more requests per filing. Across the S&P 500, 10 years of 10-K and 10-Q filings, that is roughly 100,000 requests. At a safe 6 per second (leaving headroom so a failed request does not push you over the limit), that is 4.5 hours of continuous, uninterrupted operation. No failures, no retries, no one else on your IP address hitting EDGAR at the same time.

In practice, a multi-day backfill on a shared address hits a block somewhere around hour 9. If your retry logic is not perfect — and over a hundred thousand requests, imperfect retry logic is the norm rather than the exception — you end up with a corpus that has holes in it, and no obvious way to know which filings are missing until downstream analysis produces numbers that do not add up. The 10-requests-per-second ceiling is not something you can engineer around with better code. More threads do not help. More machines behind the same NAT do not help. Once the corpus is large enough, the rate limit is your architecture.

Getting a typed manifest of every file in a filing

This is what the Documents and Exhibits API was built for. Business Quant pre-splits every filing at ingestion — across 18M+ filings since 1993 — and exposes a typed manifest of every file inside. Pass an accession number, get back a structured list of every document and exhibit with its type, filename and sequence number.

curl "https://data.businessquant.com/secfilings/0000320193-25-000079/documents?api_key=YOUR_API_KEY"
{
  "data": [
    { "filesno": 1,  "document_type": "10-K",       "filename": "aapl-20250927.htm" },
    { "filesno": 3,  "document_type": "EX-21.1",    "filename": "a10-kexhibit21109272025.htm" },
    { "filesno": 5,  "document_type": "EX-31.1",    "filename": "a10-kexhibit31109272025.htm" },
    { "filesno": 9,  "document_type": "EX-101.CAL", "filename": "aapl-20250927_cal.xml" },
    { "filesno": 13, "document_type": "GRAPHIC",    "filename": "aapl-20250927_g1.jpg" }
  ]
}

Once you have the manifest, streaming any individual file is a second call. The response is the file itself, delivered with the correct Content-Type — text/html for HTML exhibits, application/xml for XBRL linkbases, image/jpeg for graphics. Here is a small helper that pulls every file matching a set of type prefixes:

def files_of_type(accession, *prefixes):
    docs = requests.get(
        f"https://data.businessquant.com/secfilings/{accession}/documents",
        params={"api_key": API_KEY},
    ).json()["data"]

    return {
        d["document_type"]: requests.get(
            f"https://data.businessquant.com/secfilings/{accession}/content",
            params={"filename": d["filename"], "api_key": API_KEY},
        ).content
        for d in docs if d["document_type"].startswith(prefixes)
    }

subsidiaries = files_of_type("0000320193-25-000079", "EX-21")

One rule worth internalizing early: filter on document_type, never on description or filename. The document_type field is normalized by Business Quant and always populated. The description field is filer-submitted, inconsistently formatted and frequently empty. The filename is arbitrary — that was the whole problem. If you write your pipeline against document_type from the start, it stays stable across every filer, every period and every filing form.

No index fetching. No SGML parsing. No filename guessing. And no 10-requests-per-second ceiling standing between you and the corpus.

The complete SEC exhibit code map

Before diving into specific workflows, it helps to know what you are looking at. Exhibits are governed by Item 601 of Regulation S-K, and the range of document types is wider than most people realize on first encounter. I have ranked these by research value rather than by number, with the highest-signal exhibit types in bold:

TypeWhat it isWhat it is used for
EX-10Material ContractsSupply agreements, credit facilities, leases, executive employment terms. Covenant screening, customer and supplier concentration, credit analysis. The richest alternative-data target in EDGAR.
EX-21Subsidiaries of the RegistrantFull domestic and foreign subsidiary list with jurisdictions. Corporate-structure mapping, tax-jurisdiction analysis, M&A integration tracking.
EX-99 / EX-99.1Additional exhibitsOn an 8-K, almost always the earnings press release or investor deck. The numbers live here, not in the 8-K body.
EX-16Letter from former accountantFiled when an auditor is terminated or resigns. States whether the auditor disputes the company’s account. Short document, outsized signal.
EX-2Plans of acquisition and mergerThe merger agreement. Break fees, conditions, MAC clauses.
EX-31 / EX-32SOX 302 and 906 certificationsCEO and CFO personal certifications. Absence or unusual qualification is a flag.
EX-1Underwriting agreementsIPO and offering syndicate terms
EX-3Articles of incorporation and bylawsGovernance, takeover defenses
EX-4Instruments defining security-holder rightsIndentures, debt covenants
EX-5Legal opinionValidity of registered securities
EX-14Code of ethicsESG and governance screens
EX-23Auditor and counsel consentsTrack audit-firm relationships across years.
CORRESPSEC correspondenceLetters between the SEC and the filer during filing reviews. Comment letter analysis and regulatory risk screening.
INFORMATION TABLE13F quarterly holdingsThe XML table of an institution’s reported equity positions, specific to 13F filings.
GRAPHICEmbedded charts, logos and graphicsBinary image files (JPEG, GIF). Performance graphs, corporate logos, embedded diagrams.
XMLGeneral XML payloadsStructured data including Form 4 insider transaction matrices.
EX-101.*XBRL linkbase setInstance, schema, calculation, definition, label and presentation linkbases. Covered in detail below.

Downloading EX-99.1 earnings press releases

If you are looking for a place to start, this is the workflow with the fastest payback. When a company reports earnings, it files an 8-K with Item2_02 (Results of Operations and Financial Condition) in its items array, and attaches the press release as an EX-99.1 exhibit. The actual numbers — revenue, EPS, guidance — live in that exhibit, not in the 8-K body itself.

The SEC Filings API makes this particularly efficient because it returns exhibit URLs directly in the search response. You do not need a separate manifest call — the filing record already includes a filing_exhibits array with the type and download URL for each exhibit.

r = requests.get("https://data.businessquant.com/secfilings", params={
    "ticker": "AAPL", "formtype": "8-K", "from_date": "01-01-2025", "api_key": API_KEY,
})

for filing in r.json()["data"]:
    if "Item2_02" not in filing["items"]:
        continue                                   # not an earnings 8-K
    for ex in filing["filing_exhibits"]:
        if ex["type"].startswith("EX-99"):
            press_release = requests.get(ex["url_doc_bq"]).text
            print(filing["publishdatetime"], ex["type"], len(press_release))

The pattern is straightforward: items[] tells you whether the 8-K is an earnings filing, filing_exhibits[] tells you where the press release lives. One API call gives you both, and you never need to touch an EDGAR index page. This makes it practical to build an earnings press-release archive across a full universe — something that would otherwise require tens of thousands of index-page lookups to assemble.

Mapping corporate structure from EX-21 subsidiary lists

Earnings press releases are the fastest payback, but the most underrated exhibit type is the subsidiary list. EX-21 lists every subsidiary and its jurisdiction of organization. On its own, a single EX-21 is a useful reference — but the real value shows up when you pull it across 10 years of 10-K filings and look at what accumulates. You get a time series of a company’s legal footprint: entities appearing, entities quietly disappearing, jurisdictions shifting. I think this is one of the most underused datasets in public filings, and it costs one API call per filing to collect.

What it surfaces, once you have the longitudinal view: entity churn — acquisitions show up as newly listed subsidiaries, often the first public trace of a completed deal, while divested units vanish between annual filings. Tax-jurisdiction migration — you can watch Ireland, Luxembourg, Singapore and Cayman entity counts rise or fall over time. And structural complexity as a governance signal — a company that goes from 40 subsidiaries to 200 in 5 years is telling you something about integration discipline, even if the investor presentations never mention it.

history = []
for filing in ten_years_of_10ks:                        # from the SEC Filings API
    ex21 = files_of_type(filing["accession"], "EX-21")
    if not ex21:
        continue
    soup = BeautifulSoup(next(iter(ex21.values())), "html.parser")
    for tr in soup.find_all("tr"):                       # entity | jurisdiction
        cells = [td.get_text(" ", strip=True) for td in tr.find_all("td") if td.get_text(strip=True)]
        if len(cells) >= 2:
            history.append({"fy": filing["reportperiod"][:4],
                            "entity": cells[0], "jurisdiction": cells[-1]})

One honest note about what this code does and does not do. The API gives you the EX-21 file, cleanly, with a stable filename you did not have to hunt for. But turning a 2-column HTML table into structured entity records is still your work, and filers format these inconsistently — some use proper HTML tables, some use indented text, some nest subsidiaries in hierarchies that imply ownership chains. What the API eliminates is the retrieval problem, which accounted for the overwhelming majority of the engineering effort in every EX-21 project I have seen. The parsing problem is real, but it is bounded, and it is yours to solve with the tools you already have.

Screening EX-10 material contracts

Subsidiary lists are structured and relatively compact. Material contracts are neither. EX-10 exhibits are the contracts themselves — the actual signed agreements a company is required to disclose because they are material to its business. These are long documents (a credit facility can run 200 pages), they are numerous (a single 10-K might attach a dozen), and they are where covenant terms, customer concentration and supplier dependency actually live. Not in the summary a company gives you in its risk factors, but in the language its counterparty made it sign.

The practical challenge with EX-10s is not finding them — there are a lot of them. The challenge is triage. If you download every EX-10 across a hundred-company universe before knowing which ones you need, you have turned a research project into a storage bill and a reading backlog with no prioritization. The manifest gives you the document type and description for free, so the better approach is to build the catalog first, then fetch selectively:

catalog = [
    {"accession": acc, "exhibit": d["document_type"], "description": d["description"]}
    for acc in accessions
    for d in manifest(acc)
    if d["document_type"].startswith("EX-10")
]

Once you have the catalog, you can filter on description keywords (look for “credit,” “supply,” “lease,” “employment”), cross-reference against filing dates and 8-K item codes to spot newly disclosed contracts, and fetch only the documents that survive your screen. This 2-phase approach — catalog everything, download selectively — keeps the pipeline fast and the storage costs proportional to what you actually need.

Fetching raw XBRL files and clean filing HTML

The exhibit types above are all human-readable documents. The manifest also carries the machine-readable layer. Every interactive filing since 2009 carries the XBRL linkbase set — the structured data layer that makes financial statements machine-readable. These files are individually addressable in the manifest, each serving a different purpose:

TypeDefines
EX-101.INSInstance document — the raw tagged facts, values and reporting contexts
EX-101.SCHTaxonomy extension schema
EX-101.CALCalculation linkbase — the arithmetic relationships
EX-101.DEFDefinition linkbase — dimensional structure, including segment breakdowns by geography and product line
EX-101.LABLabel linkbase — human-readable concept labels
EX-101.PREPresentation linkbase — ordering and nesting for rendering

The distinction matters if you are building custom financial datasets rather than consuming pre-built ones. A pre-converted facts feed — the kind most data vendors sell — flattens all of this into a table of concept-value pairs. That is convenient for pulling consolidated revenue or net income, but the calculation relationships disappear, and so does the definition linkbase’s dimensional structure. The definition linkbase is precisely how you reconstruct segment detail by geography and product line rather than settling for consolidated totals. If that granularity matters to your analysis, you need the raw linkbases, and the manifest gives you each file individually.

That said, not every use case needs raw XBRL. If all you want is a normalized income statement or balance sheet, a financials endpoint is the right tool. Different jobs, different tools — and the manifest gives you the raw substrate for the jobs that require it.

One thing the API does solve outright is inline XBRL. Modern filings embed XBRL tags directly in the primary HTML body, which creates a practical problem for anyone trying to extract text:

Total net sales were <ix:nonFraction contextRef="c-4" name="us-gaap:Revenues"
   scale="6" unitRef="usd">394,328</ix:nonFraction> million in fiscal 2025.

Run a standard text extractor over that and you get sentence fragments split at every tag boundary, plus hidden fact blocks dumping thousands of invisible characters into your narrative sections. The primary HTML from the content endpoint arrives with the ix: namespaces stripped — clean HTML that standard parsers and NLP pipelines consume without special handling. If you are feeding filings into a language model or a summarization pipeline, that cleanup alone is worth the call.

Handling amended filings correctly

If you skim everything else on this page, read this section. Amended filings are the edge case that corrupts a dataset silently, and by the time the numbers look wrong downstream, the run that produced them is 3 weeks old and nobody remembers what went in.

An amended filing (10-K/A, 10-Q/A) carries a brand-new accession number, and its manifest contains only the files that changed or were newly added. It does not repeat the original exhibits. This is by design — the amendment is a patch, not a replacement.

So if a company files a 10-K with an EX-21 subsidiary list, then files a 10-K/A 3 weeks later to correct a typo in Item 9A, the amendment’s manifest contains no EX-21 at all. A pipeline that treats the amendment as “the current version of the 10-K” now believes the company has no subsidiaries. No error, no warning — just a missing exhibit and a hole in whatever analysis depends on it.

The fix is to merge the manifests — original first, later amendments winning on any overlapping document type:

def merged_manifest(original, *amendments):
    merged = {}
    for acc in (original, *amendments):
        for doc in manifest(acc):
            merged[doc["document_type"]] = {**doc, "accession": acc}
    return merged

Match amendments to originals on reportperiod, not on filing date. The amendment covers the same fiscal period as the original — that is the join key. 2 filings with the same reportperiod and the same form type (one plain, one amended) belong together, regardless of when the amendment was filed.

Other edge cases

Pre-2003 filings are often a single monolithic text file. Before widespread HTML adoption, filers submitted one blob with no discrete attached exhibits. A manifest returning exactly one record for a 1998 10-K is correct, not a coverage gap. Do not treat it as an error and do not retry it — you will loop indefinitely against a truthful response.

Graphics are binary. GRAPHIC files return image/jpeg or image/gif bytes. If your download client is configured to decode everything as UTF-8, it will raise a UnicodeDecodeError a few thousand files into an otherwise clean run. Write the bytes directly and handle text decoding only for file types you know are text.

description is frequently empty, particularly for auto-generated XBRL files and graphics. This is normal, not an ingestion failure. document_type and the file extension are always populated — build your filters against those fields, and treat description as supplementary context when it happens to be present.

filesno is not contiguous. In the Apple example above, it jumps from 14 to 94. The number is the sequence index within the EDGAR submission package, not an array position in the manifest. Do not assume you can iterate from 1 to N.

Store url_sec alongside whatever you download. Retrieve files through the API for speed and convenience; cite the SEC for provenance. For any compliance workflow, audit trail or client-facing footnote, the authoritative URL is the one on sec.gov — and it arrives in the same filing record, so there is no additional lookup.

The short version

Exhibits are the highest-value, least-accessible content in EDGAR. The barrier to working with them was never analytical — nobody was short of ideas for what to do with a subsidiary list, a material contract or an earnings press release. The barrier was retrieval: getting one exhibit out of a filing means knowing an arbitrary filename you can only learn by fetching and parsing an index page, hundreds of thousands of times, against a rate limit that fails closed and cannot be raised.

The Documents and Exhibits API pre-splits every filing at ingestion. Pass an accession number, receive a typed manifest of every file inside, stream any of them by name. 18M+ filings since 1993, primary HTML delivered with inline XBRL stripped, and there is no cost to use it.

curl "https://data.businessquant.com/secfilings/0000320193-25-000079/documents?api_key=YOUR_API_KEY"
curl "https://data.businessquant.com/secfilings/0000320193-25-000079/content?filename=a10-kexhibit21109272025.htm&api_key=YOUR_API_KEY"

The SEC Filings API supplies accessions and exhibit URLs directly in the search response, and the Section Extraction API covers narrative sections when you want text rather than files.

Frequently asked questions

Why can't I construct the URL for an SEC filing exhibit from its type?

Exhibit filenames are arbitrary and filer-specific. Apple's EX-21 might be named a10-kexhibit21109272025.htm while another company calls the same exhibit type ex21-1.htm. There is no naming rule, so the filename has to be looked up from a manifest rather than constructed.

What is in the EX-21 exhibit of a 10-K?

The Subsidiaries of the Registrant list — every subsidiary a company owns and the jurisdiction it is organized in. Pulled across years, it becomes a time series of corporate structure, useful for tracking acquisitions, divestitures and tax-jurisdiction migration.

Where is the earnings press release attached to an 8-K filing?

In the EX-99.1 exhibit, not the 8-K body itself. The 8-K carries Item2_02 in its items array when it reports earnings, and the exhibit — usually the press release or investor deck — contains the actual figures.

Why is my exhibit pipeline missing files from an amended 10-K/A?

An amendment carries a brand-new accession number, and its manifest contains only the files that changed or were newly added — it does not repeat the original exhibits. Merge the original filing's manifest with the amendment's, letting the amendment win on any overlapping document type, matched on reportperiod.

Is the Business Quant Documents and Exhibits API free to use?

Yes. It returns a typed manifest of every file in a filing and streams any file by name, across 18M+ filings since 1993, with a free API key and no per-second rate limit.