๐ŸŽ‰ Premium Proxies ยท 3-Day Free TrialClaim Now
Guides

How to Scrape SEC EDGAR Filings (10-K, 10-Q, 8-K)

Learn how to scrape SEC EDGAR filings with the free official SEC APIs: submissions, company facts, XBRL frames, and full-text search, plus fair-access rules.

S SparkProxy 13 19 min read
Share
How to Scrape SEC EDGAR Filings (10-K, 10-Q, 8-K)

You can scrape SEC EDGAR filings without paying for a single wrapper service, because the U.S. Securities and Exchange Commission publishes everything through free, official JSON endpoints. The catch is never access, it's discipline. Send the wrong header and you get a flat 403. Hammer the servers past their published limit and your IP gets throttled for a while. This guide gives you the exact endpoints for 10-K, 10-Q, and 8-K filings, structured company financials, and full-text search, plus the compliance rules that keep you from getting blocked.

Why EDGAR is different from every other scraping target

Most scraping guides fight a website that does not want to be scraped. EDGAR is the opposite. The SEC runs EDGAR (Electronic Data Gathering, Analysis, and Retrieval) as a public utility, and it actively publishes machine-readable APIs so you do not have to parse HTML at all. Every disclosure a U.S. public company files, the annual 10-K, the quarterly 10-Q, the ad-hoc 8-K, proxy statements, insider trades, is free and in the public domain.

That changes your whole strategy. You are not bypassing anti-bot systems here. You are consuming an official data feed, and the only thing standing between you and a clean pipeline is following the SEC's stated access rules. Get those right and EDGAR is one of the most reliable data sources on the internet. Get them wrong and you get blocked, not because you outsmarted a defense, but because you ignored a posted sign.

A quick note on scope. If you want live share prices or intraday quotes, EDGAR is the wrong tool, and our guide to scraping stock market data covers that. EDGAR is for regulatory filings and the financial statements inside them.

The four official SEC data APIs

The SEC exposes four REST endpoints on the data.sec.gov host. None of them need an API key. All of them return JSON. Learn these four and you rarely need to parse a filing by hand.

APIEndpoint templateWhat it returns
Submissions`https://data.sec.gov/submissions/CIK##########.json`A company's metadata plus its full filing history (accession numbers, form types, dates)
Company Facts`https://data.sec.gov/api/xbrl/companyfacts/CIK##########.json`Every XBRL financial fact a company has ever reported, grouped by concept
Company Concept`https://data.sec.gov/api/xbrl/companyconcept/CIK##########/us-gaap/{Tag}.json`The full time series for one financial concept at one company
Frames`https://data.sec.gov/api/xbrl/frames/us-gaap/{Tag}/{Unit}/{Period}.json`One concept across every filer for a single period

The ########## is a 10-digit zero-padded CIK, which we cover in a moment. The submissions and company facts APIs are your workhorses. Frames is the one people forget exists, and it is the fastest way to pull the same line item from thousands of companies at once.

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

The one rule that gets everyone blocked

Before you write a single request, understand the SEC's fair-access policy, because it is the difference between a pipeline that runs for years and one that dies in an afternoon.

You must send a descriptive User-Agent header. The SEC requires a User-Agent that identifies your application and a contact email, formatted like Sample Company AdminContact@sample.com. If you send the default Python or curl User-Agent, or none at all, EDGAR returns 403 Forbidden. This is not a rumor. When we tested plain automated fetches against sec.gov while writing this guide, every request without a proper User-Agent came back 403. The header is the price of entry.

You must stay under 10 requests per second. The SEC caps automated traffic at a maximum of 10 requests per second per source. Cross that line and your IP can be blocked for a period. Treat 10 as a ceiling you never touch, not a target. In practice, cap yourself at 8 or fewer and cache aggressively so you never re-fetch the same document.

Here is a session and a small rate limiter that bakes both rules in. Reuse this session for every call in the rest of the guide.

import time
import threading
import requests

# A descriptive User-Agent is mandatory. Use a real contact address.
HEADERS = {
    "User-Agent": "SparkProxy Research research@sparkproxy.io",
    "Accept-Encoding": "gzip, deflate",
}

session = requests.Session()
session.headers.update(HEADERS)

class RateLimiter:
    """Cap outgoing requests below the SEC's 10 req/sec ceiling."""
    def __init__(self, max_per_sec=8):
        self.min_interval = 1.0 / max_per_sec
        self.lock = threading.Lock()
        self.last = 0.0

    def wait(self):
        with self.lock:
            gap = self.min_interval - (time.monotonic() - self.last)
            if gap > 0:
                time.sleep(gap)
            self.last = time.monotonic()

limiter = RateLimiter(max_per_sec=8)

One more point that matters for the compliance section later. Rotating through proxies to push past 10 requests per second is a violation of the SEC's fair-access policy, not a clever workaround. Proxies have a legitimate role with EDGAR, covered below, but evading the published rate limit is not it.

Find the CIK and zero-pad it

Every filer on EDGAR has a Central Index Key, or CIK. It is the primary key for all four APIs. You almost never know a CIK by heart, so you map a ticker or company name to it first.

The SEC publishes a single file with every ticker and its CIK: https://www.sec.gov/files/company_tickers.json. Download it once, cache it, and build a lookup.

def load_cik_map():
    url = "https://www.sec.gov/files/company_tickers.json"
    limiter.wait()
    data = session.get(url, timeout=30).json()
    # data is keyed by row index: {"0": {"cik_str": 320193, "ticker": "AAPL", ...}, ...}
    return {row["ticker"].upper(): row["cik_str"] for row in data.values()}

def cik_for(ticker, cik_map):
    cik = cik_map[ticker.upper()]
    return f"{cik:010d}"   # 320193 -> "0000320193"

cik_map = load_cik_map()
print(cik_for("AAPL", cik_map))   # 0000320193

The zero-padding is the part people miss. The data.sec.gov APIs want a 10-digit CIK, so Apple's raw CIK 320193 becomes 0000320193. Skip the padding and you get a 404. Here is the trap that follows: the filing archive path (next section) uses the un-padded integer CIK instead. Same company, two formats, depending on which host you hit.

Pull a company's filing history

The submissions API returns a company's recent filings as a set of parallel arrays. You zip them together and filter by form type.

def get_submissions(cik10):
    url = f"https://data.sec.gov/submissions/CIK{cik10}.json"
    limiter.wait()
    return session.get(url, timeout=30).json()

def recent_filings(subs, forms=("10-K", "10-Q", "8-K")):
    recent = subs["filings"]["recent"]
    rows = zip(
        recent["accessionNumber"],
        recent["form"],
        recent["filingDate"],
        recent["primaryDocument"],
    )
    for accession, form, filed, doc in rows:
        if form in forms:
            yield {
                "accession": accession,     # 0000320193-23-000106
                "form": form,               # 10-K
                "filed": filed,             # 2023-11-03
                "primary_document": doc,    # aapl-20230930.htm
            }

subs = get_submissions("0000320193")
for f in recent_filings(subs, forms=("10-K",)):
    print(f["filed"], f["form"], f["accession"])

The filings.recent block holds roughly the last 1,000 filings. For a company with a longer history, the JSON includes a filings.files array pointing to additional pages, each a separate JSON file you fetch the same way. Loop through those only if you actually need filings older than what recent returns.

Here is a cheat sheet of the form types you will filter on most:

FormWhat it is
10-KAnnual report, the deepest disclosure a company files
10-QQuarterly report, lighter than the 10-K
8-KMaterial event filed ad hoc (earnings, M&A, leadership changes)
10-K/AAmended annual report (the `/A` suffix means amendment)
DEF 14AProxy statement, where executive pay lives
S-1Registration statement for an IPO
13F-HRInstitutional investment manager holdings
4Insider transaction (a director or officer bought or sold shares)

Download the actual 10-K, 10-Q, and 8-K documents

The submissions API tells you a filing exists and gives you its accession number and primary document name. To fetch the document itself, you build a URL into the EDGAR archive on www.sec.gov.

def document_url(cik10, accession, primary_document):
    cik = int(cik10)                       # archive path uses the UN-padded CIK
    folder = accession.replace("-", "")    # 0000320193-23-000106 -> 000032019323000106
    return (
        f"https://www.sec.gov/Archives/edgar/data/"
        f"{cik}/{folder}/{primary_document}"
    )

# https://www.sec.gov/Archives/edgar/data/320193/000032019323000106/aapl-20230930.htm
url = document_url("0000320193", "0000320193-23-000106", "aapl-20230930.htm")
limiter.wait()
html = session.get(url, timeout=60).text

Two format quirks live in that one function. The CIK in the archive path drops its zero-padding. The accession number drops its dashes to form the folder name but keeps them everywhere else. Modern filings are inline XBRL (iXBRL), which means the .htm document is human-readable HTML with machine-readable financial tags embedded in it. You can parse the visible tables with a library like BeautifulSoup, but if all you want is the numbers, the XBRL APIs below skip the parsing entirely.

Some older filings and exhibits ship as PDFs rather than HTML. If you need to pull text out of those, our guide to extracting data from PDFs walks through the tooling.

Get structured financials with company facts and XBRL

This is the part that makes EDGAR special. Because filings are tagged in XBRL, the SEC can hand you clean numbers without you touching the document. XBRL (eXtensible Business Reporting Language) tags each financial value with a standard concept name, a unit, and a reporting period.

The Company Facts API returns every fact a company has reported, organized by taxonomy (us-gaap for standard accounting concepts, dei for entity metadata) and then by concept.

def company_facts(cik10):
    url = f"https://data.sec.gov/api/xbrl/companyfacts/CIK{cik10}.json"
    limiter.wait()
    return session.get(url, timeout=30).json()

facts = company_facts("0000320193")
# Drill into one concept:
revenue = facts["facts"]["us-gaap"]["RevenueFromContractWithCustomerExcludingAssessedTax"]
for row in revenue["units"]["USD"]:
    print(row["fy"], row["fp"], row["end"], row["val"], row["form"])

If you only want one line item, the Company Concept API is lighter. It returns the full history of a single concept for one company.

def company_concept(cik10, tag, taxonomy="us-gaap"):
    url = f"https://data.sec.gov/api/xbrl/companyconcept/CIK{cik10}/{taxonomy}/{tag}.json"
    limiter.wait()
    return session.get(url, timeout=30).json()

# Apple's annual revenue, straight from the 10-Ks:
data = company_concept("0000320193", "RevenueFromContractWithCustomerExcludingAssessedTax")
annual = [r for r in data["units"]["USD"] if r.get("form") == "10-K" and r.get("fp") == "FY"]
for r in annual:
    print(r["fy"], r["val"])

The Frames API flips the question around. Instead of one concept across time for one company, it gives you one concept for one period across every company that reported it. This is how you build a cross-sectional dataset in a single request.

def frame(tag, period, unit="USD", taxonomy="us-gaap"):
    url = f"https://data.sec.gov/api/xbrl/frames/{taxonomy}/{tag}/{unit}/{period}.json"
    limiter.wait()
    return session.get(url, timeout=30).json()

# Every filer's Q1 2024 revenue in one call:
rows = frame("RevenueFromContractWithCustomerExcludingAssessedTax", "CY2024Q1")["data"]
print(len(rows), "companies reported this concept")

The period string in Frames trips people up, so here is the rule. CY2024 is a full calendar year. CY2024Q1 is a quarter measured over its duration, correct for flow items like revenue. CY2024Q1I with the trailing I is instantaneous, a single point in time, correct for balance-sheet items like cash or total assets. Ask for a duration period on a balance-sheet concept and you get an empty result and a confused afternoon.

A few high-value us-gaap concept tags to get you started:

Concept tagWhat it measures
`RevenueFromContractWithCustomerExcludingAssessedTax`Total revenue (current standard tag)
`NetIncomeLoss`Net income or loss
`Assets`Total assets (instantaneous)
`Liabilities`Total liabilities (instantaneous)
`CashAndCashEquivalentsAtCarryingValue`Cash on hand (instantaneous)
`EarningsPerShareDiluted`Diluted EPS
`StockholdersEquity`Total shareholder equity (instantaneous)

One honest caveat: companies do not all tag identically. Older filings may use retired revenue tags such as Revenues or SalesRevenueNet, so a complete pipeline checks a short list of candidate tags per concept rather than assuming one. That messiness is the real work in EDGAR analysis, and it is worth knowing before you promise someone a clean number.

Scale politely with the SparkProxy Scraping API

Here is the honest positioning, because it matters more than a sales pitch. For the JSON APIs on data.sec.gov, you do not need proxies. Plain requests with a correct User-Agent and the rate limiter above is the right tool, and reaching for anything heavier is over-engineering.

Proxies and a managed scraper earn their place in three specific situations:

  1. Parsing filing documents at volume. When you are pulling thousands of 10-K HTML documents from the archive and converting each to clean text, a managed API gives you automatic retries, timeout handling, and one-call HTML-to-markdown conversion instead of a pile of BeautifulSoup.
  2. Rendered pages. A handful of EDGAR viewer pages and exhibits render content with JavaScript. A headless-browser API handles those without you running a browser fleet.
  3. Resilience. If your single IP gets temporarily throttled, clean managed egress plus retry logic keeps a long harvest moving.

The SparkProxy Scraping API fits all three, and critically, it lets you keep the SEC-required User-Agent while it works. Set it with the custom_ua parameter so every request through the pool still identifies you the way the SEC demands.

import requests

SPARK_KEY = "sk-your-key"   # from your SparkProxy dashboard

def fetch_filing_markdown(filing_url):
    resp = requests.get(
        "https://scrape.sparkproxy.io/api/v1",
        headers={"X-API-Key": SPARK_KEY},
        params={
            "url": filing_url,
            "render_js": "false",       # EDGAR filings are static HTML
            "format": "md",             # convert the 10-K straight to clean markdown
            "custom_ua": "SparkProxy Research research@sparkproxy.io",  # SEC-required UA
        },
        timeout=120,
    )
    return resp.text

markdown = fetch_filing_markdown(
    "https://www.sec.gov/Archives/edgar/data/320193/000032019323000106/aapl-20230930.htm"
)

That format=md conversion is genuinely useful when you feed filings into a language model, because a raw 10-K is hundreds of kilobytes of nested HTML tables and a markdown version is far cheaper to process. If you prefer to pass the header yourself, forward_headers takes a JSON object: forward_headers='{"User-Agent": "SparkProxy Research research@sparkproxy.io"}'.

The rule does not change when you add a proxy: keep your aggregate request rate at or below the SEC's ceiling and keep the descriptive User-Agent attached. The proxy handles rendering, retries, and clean egress. It does not buy you permission to go faster than fair access allows. For the broader pattern of backing off politely under load, see our retry and backoff strategies guide, and for warehousing what you pull, how to store scraped data.

Compliance checklist and common gotchas

Run through this before you turn a scraper loose on EDGAR:

  • Send a descriptive User-Agent with your app name and a real contact email on every request. No exceptions, or it is 403 on the first call.
  • Cap your rate below 10 requests per second in aggregate across all threads and all IPs. Slower is safer.
  • Cache everything locally. A filing never changes after it is accepted, so re-fetching the same document wastes the SEC's bandwidth and yours.
  • Do not use proxy rotation to beat the rate limit. That is the one proxy use case the fair-access policy rules out.
  • Zero-pad CIKs to 10 digits for data.sec.gov, but use the un-padded integer in the Archives path.
  • Handle amendments. A 10-K/A supersedes or corrects an earlier 10-K; do not treat them as duplicates.
  • Match the frame period to the concept. Instantaneous (I suffix) for balance-sheet items, duration for flows.
  • Check multiple XBRL tags per concept, because tagging conventions changed over the years and one tag rarely covers a full history.

Get those eight right and EDGAR behaves like the reliable public feed it is meant to be. For a wider view of doing this responsibly across any source, our ethical scraping and rate limiting guide is the companion read, and the financial data collection use case shows where filings fit alongside market data.

Frequently asked questions

FAQ

Yes. EDGAR filings are public records in the public domain, and the SEC provides official APIs specifically so you can access them programmatically. The legal expectation is that you follow the fair-access policy: send a descriptive User-Agent and stay under 10 requests per second. Access is allowed; abuse of the servers is what gets you blocked.

No. The data.sec.gov and efts.sec.gov endpoints require no key, no registration, and no payment. The only mandatory credential is an honest User-Agent header that names your application and a contact email. Any service charging you for basic EDGAR access is reselling a free public feed.

A CIK (Central Index Key) is the SEC's unique identifier for each filer, and it is the key every EDGAR API uses. Find it by downloading https://www.sec.gov/files/company_tickers.json, which maps every ticker to its CIK, then zero-pad the number to 10 digits for the data APIs (Apple's 320193 becomes 0000320193).

The submissions API lists a company's filings (which 10-Ks, 10-Qs, and 8-Ks it filed and when). Company facts returns the actual XBRL financial numbers inside those filings for a single company. Frames returns one financial concept across every company for one period, so you use submissions to find documents, company facts to read one firm's numbers, and frames to compare many firms at once.

Use the Company Concept API. A call to https://data.sec.gov/api/xbrl/companyconcept/CIK{cik}/us-gaap/NetIncomeLoss.json returns the full reported history of net income as clean JSON, no HTML parsing required. For a broad list of concepts, add RevenueFromContractWithCustomerExcludingAssessedTax for revenue, and remember older filings may use retired tags.

A 403 almost always means your User-Agent header is missing or is a default library string. Set a descriptive one like Your App yourname@sparkproxy.io and the 403 usually disappears. A proxy does not fix a missing User-Agent; it helps only when you need JavaScript rendering, high-volume document fetching, or resilience against a temporarily throttled IP, and even then you keep the compliant User-Agent and the rate cap in place.

Special Discount ยท 20% off

Get 20% off your first month

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Save up to 15% more on quarterly, half-yearly and yearly plans

Claim Discount

About the Author

This guide was written by the SparkProxy Technical Team. SparkProxy builds data-collection infrastructure used by engineers for large-scale, compliant scraping: datacenter proxies, residential proxies, and the SparkProxy Scraping API that handles rendering, retries, and geo-targeting behind a single endpoint. We publish these guides from hands-on work with public data sources like EDGAR, and we test the endpoints and code patterns we document. Questions or corrections are welcome at support@sparkproxy.io.

Keep reading

Related articles