Skip to main content

Watch SEC EDGAR for new 8-K, 10-Q and Form 4 filings with Python (free, no API key)

Updated 8 min readMarkdown

I wrote this with help from an AI assistant. Every script below was run by me against the live SEC endpoints on Oct 26, 2026, and the output shown is real.

If you follow a handful of US stocks, the fastest place to learn that something happened is not the news. It's SEC EDGAR. Earnings (8-K item 2.02), a CEO leaving (item 5.02), a cyber incident (item 1.05), or an insider selling shares (Form 4) all land on EDGAR first, usually minutes after filing.

EDGAR has a free JSON API with no key and no sign-up. In this post we'll build a small Python watcher that:

  • turns tickers into SEC CIK numbers,
  • pulls each company's recent filings,
  • keeps only the forms you care about (8-K, 10-Q, 10-K, Form 4),
  • decodes 8-K item codes into plain English,
  • remembers what it already showed you, so each run prints only new filings,
  • and, as a bonus, reads who traded what from a Form 4.

All you need is Python 3 and requests.

The two endpoints you need

  1. https://www.sec.gov/files/company_tickers.json maps every listed ticker to its CIK (the SEC's company ID).
  2. https://data.sec.gov/submissions/CIK##########.json returns a company's profile and its most recent filings. The CIK must be zero-padded to 10 digits.

Two rules matter, and the SEC enforces them:

  • Send a real User-Agent with your name and email, like "Jane Doe jane@example.com". Requests with a generic or missing User-Agent get blocked with a 403.
  • Stay under 10 requests per second. Go faster and your IP gets throttled for a while.

How the submissions JSON is shaped

The filings come back as parallel arrays, not a list of objects. Index i in accessionNumber, form, filingDate, items, and primaryDocument all describe the same filing:

"filings": {
  "recent": {
    "accessionNumber": ["0001628280-26-066775", "..."],
    "filingDate": ["2026-10-22", "..."],
    "form": ["10-Q", "..."],
    "items": ["", "2.02,9.01", "..."],
    "primaryDocument": ["tsla-20260930.htm", "..."]
  }
}

To build a link to the actual document, strip the dashes from the accession number and join it with the CIK (no padding) and the primary document:

https://www.sec.gov/Archives/edgar/data/{cik}/{accession_without_dashes}/{primaryDocument}

The watcher

"""Watch SEC EDGAR for new filings by ticker (8-K, 10-Q, 10-K, Form 4).

Free, no API key. SEC asks for a descriptive User-Agent with contact info
and at most 10 requests per second.
"""
import json
import sys
import time
from pathlib import Path

import requests

UA = "Saad Data APIs saad.dataapis@mail.grokbot.com"  # use your own name + email
S = requests.Session()
S.headers.update({"User-Agent": UA, "Accept-Encoding": "gzip, deflate"})

ITEM_NAMES = {
    "1.01": "Entry into a Material Definitive Agreement",
    "1.02": "Termination of a Material Definitive Agreement",
    "1.05": "Material Cybersecurity Incidents",
    "2.01": "Completion of Acquisition or Disposition of Assets",
    "2.02": "Results of Operations and Financial Condition",
    "2.03": "Creation of a Direct Financial Obligation",
    "2.05": "Costs Associated with Exit or Disposal Activities",
    "2.06": "Material Impairments",
    "3.01": "Notice of Delisting",
    "4.01": "Changes in Registrant's Certifying Accountant",
    "4.02": "Non-Reliance on Previously Issued Financial Statements",
    "5.02": "Departure/Election of Directors or Officers",
    "5.03": "Amendments to Articles of Incorporation or Bylaws",
    "5.07": "Submission of Matters to a Vote of Security Holders",
    "7.01": "Regulation FD Disclosure",
    "8.01": "Other Events",
    "9.01": "Financial Statements and Exhibits",
}

_last_call = 0.0


def get_json(url):
    """GET with a simple throttle (max ~8 req/s) and retry on 429/5xx."""
    global _last_call
    for attempt in range(4):
        wait = 0.125 - (time.monotonic() - _last_call)
        if wait > 0:
            time.sleep(wait)
        _last_call = time.monotonic()
        r = S.get(url, timeout=30)
        if r.status_code in (429, 500, 502, 503):
            time.sleep(2 ** attempt)
            continue
        r.raise_for_status()
        return r.json()
    r.raise_for_status()


def ticker_map():
    data = get_json("https://www.sec.gov/files/company_tickers.json")
    return {row["ticker"].upper(): row["cik_str"] for row in data.values()}


def recent_filings(cik):
    sub = get_json(f"https://data.sec.gov/submissions/CIK{cik:010d}.json")
    rec = sub["filings"]["recent"]
    for i in range(len(rec["accessionNumber"])):
        acc = rec["accessionNumber"][i]
        items = [x for x in rec["items"][i].split(",") if x]
        yield {
            "company": sub["name"],
            "cik": cik,
            "form": rec["form"][i],
            "filed": rec["filingDate"][i],
            "accession": acc,
            "items": items,
            "item_names": [ITEM_NAMES.get(x, "?") for x in items],
            "url": (f"https://www.sec.gov/Archives/edgar/data/{cik}/"
                    f"{acc.replace('-', '')}/{rec['primaryDocument'][i]}"),
        }


def check(tickers, forms, since, state_path=Path("seen.json")):
    seen = set(json.loads(state_path.read_text())) if state_path.exists() else set()
    ciks = ticker_map()
    new = []
    for t in tickers:
        cik = ciks.get(t.upper())
        if cik is None:
            print(f"unknown ticker: {t}", file=sys.stderr)
            continue
        for f in recent_filings(cik):
            if f["form"] in forms and f["filed"] >= since and f["accession"] not in seen:
                new.append({"ticker": t.upper(), **f})
                seen.add(f["accession"])
    state_path.write_text(json.dumps(sorted(seen)))
    return sorted(new, key=lambda f: f["filed"], reverse=True)


if __name__ == "__main__":
    tickers = sys.argv[1:] or ["AAPL", "MSFT", "TSLA"]
    found = check(tickers, forms={"8-K", "10-Q", "10-K", "4"}, since="2026-10-01")
    for f in found:
        items = ", ".join(f"{i} {n}" for i, n in zip(f["items"], f["item_names"]))
        print(f"{f['filed']}  {f['ticker']:5} {f['form']:5} {items}\n    {f['url']}")
    print(f"\n{len(found)} new filing(s)")

A few choices worth explaining:

  • Throttle plus retry. get_json waits at least 125 ms between calls (about 8 per second, safely under the limit) and backs off on 429 and 5xx responses.
  • seen.json holds accession numbers. An accession number is unique per filing, so it's the right key for "have I shown this already?" Run the script from cron every 15 minutes and you only ever see what's new.
  • Item codes come as one comma-separated string, like "2.02,9.01". Splitting it and mapping each code to a name turns a cryptic row into "Results of Operations and Financial Condition", which is how you spot earnings at a glance.

Running it

pip install requests
python edgar_watch.py AAPL MSFT TSLA NOTATICKER

Real output from my run (trimmed):

unknown ticker: NOTATICKER
2026-10-22  AAPL  4
    https://www.sec.gov/Archives/edgar/data/320193/000114036126040540/xslF345X06/form4.xml
2026-10-22  TSLA  10-Q
    https://www.sec.gov/Archives/edgar/data/1318605/000162828026066775/tsla-20260930.htm
2026-10-21  TSLA  8-K   2.02 Results of Operations and Financial Condition, 9.01 Financial Statements and Exhibits
    https://www.sec.gov/Archives/edgar/data/1318605/000162828026066738/tsla-20261021.htm
2026-10-15  MSFT  4
    https://www.sec.gov/Archives/edgar/data/789019/000078901926000228/xslF345X06/form4.xml
...
15 new filing(s)

Running it a second time straight away prints 0 new filing(s), because every accession number is now in seen.json.

Bonus: who bought or sold, from a Form 4

The primaryDocument for a Form 4 points to xslF345X06/form4.xml, which is an HTML view the SEC renders with a stylesheet. Remove that folder from the path and you get the raw XML the insider filed, which is easy to parse:

"""Read the insider name and trades out of a Form 4 XML filing."""
import xml.etree.ElementTree as ET

from edgar_watch import S


def form4_trades(url):
    # The primaryDocument path points at an XSL-rendered view; drop that
    # folder to get the raw XML that the filer actually submitted.
    raw_url = url.replace("/xslF345X06/", "/")
    root = ET.fromstring(S.get(raw_url, timeout=30).content)
    owner = root.findtext("reportingOwner/reportingOwnerId/rptOwnerName")
    for t in root.iter("nonDerivativeTransaction"):
        yield {
            "owner": owner,
            "date": t.findtext("transactionDate/value"),
            "code": t.findtext("transactionCoding/transactionCode"),  # P buy, S sell, M option exercise, F tax
            "shares": float(t.findtext("transactionAmounts/transactionShares/value") or 0),
            "price": float(t.findtext("transactionAmounts/transactionPricePerShare/value") or 0),
            "acq_disp": t.findtext("transactionAmounts/transactionAcquiredDisposedCode/value"),
        }


if __name__ == "__main__":
    import sys
    for row in form4_trades(sys.argv[1]):
        print(row)

Running it on two of the filings above:

$ python form4.py https://www.sec.gov/Archives/edgar/data/320193/000114036126040540/xslF345X06/form4.xml
{'owner': 'Newstead Jennifer', 'date': '2026-10-20', 'code': 'S', 'shares': 2399.0, 'price': 339.62, 'acq_disp': 'D'}

$ python form4.py https://www.sec.gov/Archives/edgar/data/789019/000078901926000228/xslF345X06/form4.xml
{'owner': 'Coleman Amy', 'date': '2026-10-15', 'code': 'F', 'shares': 31.944, 'price': 533.69, 'acq_disp': 'D'}

The transaction code matters more than the direction. S is an open-market sale and P an open-market purchase, which are the ones people usually watch. F means shares withheld to pay tax on vesting stock, and M is an option exercise. Neither tells you much about what the insider thinks of the stock.

Gotchas I hit

  • recent isn't everything. It holds roughly the last 1,000 filings or one year, whichever is more. Older ones are split into extra files listed under filings.files in the same JSON.
  • Some filers have no ticker. Private funds and many insiders only have a CIK, so they won't be in company_tickers.json.
  • Amendments have their own form type, like 8-K/A or 10-K/A. Add those to forms if you want them.
  • Dates are strings in YYYY-MM-DD, so comparing them as strings works, which is why f["filed"] >= since is fine.

If you'd rather not run it yourself

I also packaged this as a hosted tool: the SEC EDGAR Filings Monitor on Apify takes a list of tickers and returns the same fields (form type, filing date, 8-K items, document links) as JSON or CSV, and you can schedule it without keeping a server around. The script above is the whole idea, though, and it's free to run.

Wrap-up

With two free endpoints and about 100 lines of Python you get a filings feed that beats most news alerts: 8-K item codes tell you what kind of event it was, and Form 4 tells you who traded and why. Point it at your watchlist, run it on a schedule, and send the output wherever you read things.

About the author

  • I build data APIs for developers: job listings from 13k company career sites, bulk WHOIS/RDAP domain checks, sitemaps, PDFs and SEC filings. Python, scraping without the pain, pay-per-result.

No comments yet. Be the first to comment.