Home / Guides / Downloading documents
Downloading documents

Bulk download every 10-K for a list of companies

Loop over a list of tickers, find each company's latest Form 10-K and save the main document to disk with the SecFeed API. Scales to an index list of thousands.

A common first step in text research is a folder with one annual report per company. This tutorial builds that folder: for each ticker in a list it finds the latest Form 10-K, picks the main document out of the filing and saves it.

Setup

import os
import time
import requests

BASE = "https://secfeedapi.com"
HEADERS = {"Authorization": f"Bearer {os.environ['SECFEED_KEY']}"}

def get(path, **params):
    r = requests.get(f"{BASE}{path}", params=params, headers=HEADERS, timeout=60)
    r.raise_for_status()
    return r

Step 1: find the latest 10-K for a ticker

/v1/filings takes a Lucene query and returns newest first, so size=1 gives the latest filing:

hits = get("/v1/filings", query='ticker:AAPL AND formType:"10-K"', size=1).json()["filings"]

Step 2: pick the main document

A 10-K filing is a folder of files: the report itself, exhibits (Ex 21 subsidiaries, Ex 23 auditor consent, Ex 31 and 32 certifications), XBRL files and index pages. /v1/files lists them with sizes. In an inline XBRL 10-K the report is by far the largest HTML file, so take the biggest .htm:

files = get("/v1/files", cik=f["cik"], accessionNo=f["accessionNo"]).json()
docs = [x for x in files if x["name"].endswith(".htm") and "index" not in x["name"]]
main = max(docs, key=lambda x: int(x["size"] or 0))

Do not take the first .htm in the list. Files are sorted by name, and exhibits often sort first: for Apple's latest 10-K the first HTML file is a10-kexhibit21109272025.htm (the subsidiaries list, 11 KB), while the report is aapl-20250927.htm (1.5 MB).

Step 3: the full loop

TICKERS = ["AAPL", "MSFT", "NVDA", "AMZN", "GOOGL"]
os.makedirs("10k", exist_ok=True)

for t in TICKERS:
    hits = get("/v1/filings", query=f'ticker:{t} AND formType:"10-K"', size=1).json()["filings"]
    if not hits:
        print(t, "no 10-K found")
        continue
    f = hits[0]
    files = get("/v1/files", cik=f["cik"], accessionNo=f["accessionNo"]).json()
    docs = [x for x in files if x["name"].endswith(".htm") and "index" not in x["name"]]
    main = max(docs, key=lambda x: int(x["size"] or 0))  # the 10-K itself is the largest HTML file
    html = get("/v1/download", url=main["url"]).content
    path = f"10k/{t}_{f['filedAt']}.htm"
    open(path, "wb").write(html)
    print(f"{t:6} {f['filedAt']}  {main['name']:32} {len(html)/1e6:5.1f} MB -> {path}")
    time.sleep(0.2)

Output:

AAPL   2025-10-31  aapl-20250927.htm                  1.5 MB -> 10k/AAPL_2025-10-31.htm
MSFT   2026-07-29  msft-20260630.htm                  8.6 MB -> 10k/MSFT_2026-07-29.htm
NVDA   2026-02-25  nvda-20260125.htm                  2.0 MB -> 10k/NVDA_2026-02-25.htm
AMZN   2026-02-06  amzn-20251231.htm                  2.0 MB -> 10k/AMZN_2026-02-06.htm
GOOGL  2026-02-05  goog-20251231.htm                  2.6 MB -> 10k/GOOGL_2026-02-05.htm

Five annual reports, 16.7 MB, in about three seconds. The file names show each company's fiscal year end: Apple's year ends in late September, Microsoft's on June 30, NVIDIA's in late January.

Scaling to an index list

The loop is the same for 3,000 tickers; only the input changes.

  • Read the list from a file. Put one ticker per line in tickers.txt and use TICKERS = open("tickers.txt").read().split(). Index constituent lists are published by the index providers; SecFeed does not redistribute them. To work from exchange listings instead, see List companies by exchange.
  • Several years per company. Raise size (for example size=5) and loop over hits. Add AND filedAt:[2021-01-01 TO 2025-12-31] to bound the period.
  • Count your calls. Each company costs three calls (search, file list, download). Three thousand companies is about 9,000 calls, inside the Developer plan's monthly quota. The free plan's 500 calls covers about 160 companies.
  • Resume after a failure. Skip tickers whose file already exists, so a rerun picks up where it stopped:
if any(name.startswith(f"{t}_") for name in os.listdir("10k")):
    continue
  • Text instead of HTML. If you only need specific sections, the section extractor returns Item 1A or Item 7 as clean text and saves you from parsing HTML.

Amendments and foreign filers

formType:"10-K" matches only original annual reports. Add "10-K/A" to the list to include amendments, which are often only Part III. Foreign private issuers file 20-F and Canadian issuers 40-F instead of a 10-K, so a ticker such as TSM returns no 10-K.

Next steps

More downloading documents guides

Run this guide with your own key

500 free API calls a month. Every example on this page works on the free plan.

Get free API key →