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

*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:

```json
"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

```python
"""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

```bash
pip install requests
python edgar_watch.py AAPL MSFT TSLA NOTATICKER
```

Real output from my run (trimmed):

```text
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:

```python
"""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:

```text
$ 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](https://apify.com/saadhazz/sec-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.
